@hraness/slopcamera 3.6.0 → 3.8.0

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.
Files changed (57) hide show
  1. package/README.md +49 -33
  2. package/apps/desktop/README.md +3 -10
  3. package/apps/desktop/application/html-overlay-renderer.ts +6 -0
  4. package/apps/desktop/application/operation.ts +1 -1
  5. package/apps/desktop/application/spatial-assets.ts +1 -1
  6. package/apps/desktop/cli/{menubar-status.ts → activity-status.ts} +24 -21
  7. package/apps/desktop/cli/args.ts +62 -10
  8. package/apps/desktop/cli/command-host-resources.ts +5 -1
  9. package/apps/desktop/cli/commands.ts +26 -9
  10. package/apps/desktop/cli/desktop-status.ts +271 -0
  11. package/apps/desktop/cli/help.ts +36 -28
  12. package/apps/desktop/cli/html-film-init.ts +158 -0
  13. package/apps/desktop/cli/html-film-names.ts +17 -0
  14. package/apps/desktop/cli/html-film.ts +471 -0
  15. package/apps/desktop/cli/html-overlay-renderer.ts +29 -2
  16. package/apps/desktop/cli/html-scene.ts +3 -3
  17. package/apps/desktop/cli/legacy-login.ts +184 -0
  18. package/apps/desktop/cli/main.ts +9 -0
  19. package/apps/desktop/cli/outputs.ts +19 -0
  20. package/apps/desktop/cli/portable-surface.ts +55 -1
  21. package/apps/desktop/cli/registry.ts +314 -0
  22. package/apps/desktop/cli/verbs.ts +193 -0
  23. package/apps/desktop/dist/cli/main.js +338 -316
  24. package/apps/desktop/html-overlay/film.ts +418 -0
  25. package/dist/cli.js +13 -2
  26. package/dist/generate.js +1 -1
  27. package/dist/{index-4z9y2wa2.js → index-842t02pp.js} +1 -1
  28. package/dist/{index-qxfexvj3.js → index-k9bjw80z.js} +20 -4
  29. package/dist/index-p74xy76d.js +3 -0
  30. package/dist/{index-fevf4ppk.js → index-y8spxs77.js} +10 -7
  31. package/dist/index.js +1 -1
  32. package/dist/operations.js +1 -1
  33. package/dist/workflow.js +1 -1
  34. package/docs/README.md +6 -6
  35. package/examples/html/launch-film/README.md +51 -0
  36. package/examples/html/launch-film/build.ts +158 -0
  37. package/examples/html/launch-film/film.css +593 -0
  38. package/examples/html/launch-film/film.html +85 -0
  39. package/examples/html/launch-film/film.js +410 -0
  40. package/examples/html/launch-film/film.json +63 -0
  41. package/examples/html/launch-film/mockups.tsx +74 -0
  42. package/examples/html/launch-film/timeline.ts +57 -0
  43. package/package.json +24 -11
  44. package/skills/slopcamera/SKILL.md +4 -3
  45. package/skills/slopcamera/references/install.md +3 -3
  46. package/skills/slopcamera/references/launch-films.md +158 -0
  47. package/skills/slopcamera/references/reference-led-3d.md +1 -1
  48. package/skills/slopcamera/references/visual-communication.md +1 -1
  49. package/src/cli.ts +125 -9
  50. package/src/generation-pricing.ts +21 -1
  51. package/src/icon-set.ts +1086 -0
  52. package/src/icon.ts +396 -59
  53. package/src/index.ts +1 -0
  54. package/src/operations.ts +52 -0
  55. package/src/version.ts +1 -1
  56. package/apps/desktop/cli/menubar.ts +0 -272
  57. package/dist/index-nxtmy18t.js +0 -3
@@ -0,0 +1,184 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readSync } from "node:fs";
3
+ import { isAbsolute, join } from "node:path";
4
+
5
+ import {
6
+ MAX_LOGIN_ITEM_BYTES,
7
+ retireLegacyLoginItem,
8
+ type LegacyLoginItem,
9
+ type Retired,
10
+ } from "@hraness/desktop-foundation/retire";
11
+
12
+ import { CliError } from "./errors";
13
+
14
+ /**
15
+ * Login items earlier Slopcamera releases wrote to start the retired menu
16
+ * bar. Newest first: the desktop-foundation helper's item, the name that
17
+ * helper used before, and the plist the CLI itself wrote before that.
18
+ */
19
+ export const LEGACY_LOGIN_LABELS = [
20
+ "app.hraness.slopcamera",
21
+ "app.hraness.companion.slopcamera",
22
+ "com.hraness.slopcamera.menubar",
23
+ ] as const;
24
+
25
+ const OLD_CLI_LABEL = "com.hraness.slopcamera.menubar";
26
+ const MENU_BINARY = /\/slopcamera-menubar$/u;
27
+ const LOCAL_APP = /\/Slopcamera\.app\/Contents\/MacOS\/[^/]+$/u;
28
+
29
+ function xml(value: string): string {
30
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
31
+ }
32
+
33
+ function unxml(value: string): string {
34
+ return value.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", '"').replaceAll("&apos;", "'").replaceAll("&amp;", "&");
35
+ }
36
+
37
+ /** The exact plist the old `slopcamera menubar install` wrote. */
38
+ export function oldCliPlist(binary: string): string {
39
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${xml(OLD_CLI_LABEL)}</string><key>ProgramArguments</key><array><string>${xml(binary)}</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><false/><key>ProcessType</key><string>Interactive</string><key>LimitLoadToSessionType</key><string>Aqua</string></dict></plist>\n`;
40
+ }
41
+
42
+ function programArguments(text: string): string[] {
43
+ const array = /<key>ProgramArguments<\/key><array>((?:<string>[^<]*<\/string>)*)<\/array>/u.exec(text);
44
+ if (array === null) return [];
45
+ return [...array[1]!.matchAll(/<string>([^<]*)<\/string>/gu)].map(match => unxml(match[1]!));
46
+ }
47
+
48
+ /**
49
+ * True only for an item Slopcamera wrote for its menu bar. The helper's
50
+ * items carry a header with the SHA-256 of the body, so an edited file is
51
+ * not ours. The old CLI item must match what that CLI wrote byte for byte.
52
+ */
53
+ export function isSlopcameraMenubarItem(item: LegacyLoginItem): boolean {
54
+ if (item.label === OLD_CLI_LABEL) {
55
+ const [program, ...rest] = programArguments(item.text);
56
+ return program !== undefined && rest.length === 0 && MENU_BINARY.test(program) && item.text === oldCliPlist(program);
57
+ }
58
+ const newline = item.text.indexOf("\n");
59
+ if (newline < 0) return false;
60
+ const body = item.text.slice(newline + 1);
61
+ const digest = createHash("sha256").update(body).digest("hex");
62
+ if (item.text.slice(0, newline + 1) !== `<!-- hraness-companion autostart slopcamera sha256:${digest} -->\n`) return false;
63
+ if (!body.includes(`<key>Label</key><string>${xml(item.label)}</string>`)) return false;
64
+ const [program] = programArguments(body);
65
+ return program !== undefined && (MENU_BINARY.test(program) || LOCAL_APP.test(program));
66
+ }
67
+
68
+ export type LegacyLoginState = "none" | "found" | "not-ours";
69
+
70
+ export interface LegacyLoginReport {
71
+ readonly state: LegacyLoginState;
72
+ /** The label of the item found, when there is one. */
73
+ readonly label?: string;
74
+ }
75
+
76
+ function readSmallFile(path: string): string | undefined {
77
+ let fd: number | undefined;
78
+ try {
79
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
80
+ const info = fstatSync(fd);
81
+ if (!info.isFile() || info.size > MAX_LOGIN_ITEM_BYTES || info.uid !== process.getuid?.()) return undefined;
82
+ const bytes = Buffer.alloc(info.size);
83
+ let count = 0;
84
+ while (count < bytes.length) {
85
+ const read = readSync(fd, bytes, count, bytes.length - count, count);
86
+ if (read === 0) break;
87
+ count += read;
88
+ }
89
+ return bytes.subarray(0, count).toString("utf8");
90
+ } catch {
91
+ return undefined;
92
+ } finally {
93
+ if (fd !== undefined) closeSync(fd);
94
+ }
95
+ }
96
+
97
+ function launchAgents(home: string | undefined): string | undefined {
98
+ return home === undefined || home === "" || !isAbsolute(home) ? undefined : join(home, "Library", "LaunchAgents");
99
+ }
100
+
101
+ /** Reads the LaunchAgents folder and reports the old menu bar item. Changes nothing. */
102
+ export function inspectLegacyLogin(home: string | undefined): LegacyLoginReport {
103
+ const directory = launchAgents(home);
104
+ if (directory === undefined) return { state: "none" };
105
+ let foreign: string | undefined;
106
+ for (const label of LEGACY_LOGIN_LABELS) {
107
+ const path = join(directory, `${label}.plist`);
108
+ try {
109
+ lstatSync(path);
110
+ } catch {
111
+ continue;
112
+ }
113
+ const text = readSmallFile(path);
114
+ if (text !== undefined && isSlopcameraMenubarItem({ label, path, text })) return { state: "found", label };
115
+ foreign ??= label;
116
+ }
117
+ return foreign === undefined ? { state: "none" } : { state: "not-ours", label: foreign };
118
+ }
119
+
120
+ export interface RetireDependencies {
121
+ readonly bootout?: (label: string) => Promise<void>;
122
+ readonly now?: () => Date;
123
+ }
124
+
125
+ /**
126
+ * Stops the old menu bar starting at login: boots out its label and renames
127
+ * the plist to `<name>.retired-<ms>`. Nothing is deleted, no process is
128
+ * signalled by pid, and items Slopcamera did not write are left alone.
129
+ */
130
+ export async function retireLegacyLogin(home: string | undefined, dependencies: RetireDependencies = {}): Promise<Retired[]> {
131
+ if (launchAgents(home) === undefined) throw new CliError("unavailable", "HOME must be an absolute directory to look for the old menu bar login item.");
132
+ const retired: Retired[] = [];
133
+ // One call retires the first match; repeat so every old copy is moved aside.
134
+ for (let attempt = 0; attempt < LEGACY_LOGIN_LABELS.length; attempt += 1) {
135
+ const labels = LEGACY_LOGIN_LABELS.filter(label => !retired.some(item => item.label === label));
136
+ const result = await retireLegacyLoginItem({
137
+ home: home!,
138
+ labels,
139
+ accepts: isSlopcameraMenubarItem,
140
+ ...(dependencies.bootout === undefined ? {} : { bootout: dependencies.bootout }),
141
+ ...(dependencies.now === undefined ? {} : { now: dependencies.now }),
142
+ });
143
+ if (result === null) break;
144
+ retired.push(result);
145
+ }
146
+ return retired;
147
+ }
148
+
149
+ export interface RetiredLegacyLogin {
150
+ readonly label: string;
151
+ readonly path: string;
152
+ /** Puts the item back and loads it, undoing `slopcamera legacy retire`. */
153
+ readonly restore: string;
154
+ }
155
+
156
+ function shellQuote(value: string): string {
157
+ return `'${value.replaceAll("'", "'\\''")}'`;
158
+ }
159
+
160
+ /** Login items `legacy retire` moved aside, with the command that restores each. Changes nothing. */
161
+ export function retiredLegacyLogins(home: string | undefined): RetiredLegacyLogin[] {
162
+ const directory = launchAgents(home);
163
+ if (directory === undefined) return [];
164
+ let names: string[];
165
+ try {
166
+ names = readdirSync(directory);
167
+ } catch {
168
+ return [];
169
+ }
170
+ const found: RetiredLegacyLogin[] = [];
171
+ for (const label of LEGACY_LOGIN_LABELS) {
172
+ const prefix = `${label}.plist.retired-`;
173
+ for (const name of names.filter(item => item.startsWith(prefix) && /^[0-9]{1,16}$/u.test(item.slice(prefix.length))).sort()) {
174
+ const path = join(directory, name);
175
+ const original = join(directory, `${label}.plist`);
176
+ found.push({
177
+ label,
178
+ path,
179
+ restore: `mv -n ${shellQuote(path)} ${shellQuote(original)} && launchctl bootstrap gui/$(id -u) ${shellQuote(original)}`,
180
+ });
181
+ }
182
+ }
183
+ return found;
184
+ }
@@ -5,6 +5,7 @@ import { runProductSupportCommand, showProductSupportInvitation, standaloneSuppo
5
5
  import { parseCliArgs } from "./args";
6
6
  import { runCli } from "./commands";
7
7
  import { asCliError, CliError, EXIT_CODE } from "./errors";
8
+ import { isRegistryCommand, runRegistry } from "./registry";
8
9
  import { BunProcessRunner, processIo } from "./io";
9
10
  import { resolveRepositoryPaths } from "./paths";
10
11
  import {
@@ -42,6 +43,13 @@ export async function main(
42
43
  return await runProductSupportCommand(argv.slice(1),
43
44
  supportEnvironment === undefined ? {} : { env: supportEnvironment });
44
45
  }
46
+ if (isRegistryCommand(argv)) {
47
+ // status, tui, commands, legacy retire and outputs list|open|reveal
48
+ // answer in the shared desktop-foundation envelope.
49
+ return await runRegistry(argv, { stdout: process.stdout, stderr: process.stderr, env: process.env }, {
50
+ env: process.env, platform: process.platform, now: () => new Date(),
51
+ });
52
+ }
45
53
  const unifiedArgv = canonicalizeUnifiedCliArgs(argv);
46
54
  const portableExitCode = await runPortableSurface(unifiedArgv, portableDependencies);
47
55
  if (portableExitCode !== undefined) return portableExitCode;
@@ -55,6 +63,7 @@ export async function main(
55
63
  }
56
64
  const paths = await resolveRepositoryPaths(processIo.cwd(), processIo.env);
57
65
  if (earlyCommand.kind === "html-render" || earlyCommand.kind === "spatial-world" || earlyCommand.kind === "spatial-asset" || earlyCommand.kind === "spatial-design" || earlyCommand.kind === "directing" || earlyCommand.kind === "studio"
66
+ || earlyCommand.kind === "html-film"
58
67
  || earlyCommand.kind === "spatial-scene" && (earlyCommand.action === "render" || earlyCommand.action === "camera-track" || earlyCommand.action === "render-audit" || earlyCommand.action === "review")) {
59
68
  const controller = new AbortController();
60
69
  const cancel = () => controller.abort(new CliError("cancelled", "Command interrupted."));
@@ -0,0 +1,19 @@
1
+ import { dirname, join, sep } from "node:path";
2
+
3
+ import type { CliIo } from "./io";
4
+ import { writeJson, writeLine } from "./io";
5
+ import { ensurePrivateDirectory } from "./paths";
6
+
7
+ /** The directory agents write user-facing outputs into. */
8
+ export function outputsDirectory(stateRoot: string): string {
9
+ const productRoot = stateRoot.endsWith(`${sep}cli`) ? dirname(stateRoot) : stateRoot;
10
+ return join(productRoot, "outputs");
11
+ }
12
+
13
+ /** `slopcamera outputs`: creates the private outputs folder and prints its path. */
14
+ export async function reportOutputsRoot(io: CliIo, stateRoot: string, asJson: boolean): Promise<void> {
15
+ const directory = outputsDirectory(stateRoot);
16
+ await ensurePrivateDirectory(directory);
17
+ if (asJson) writeJson(io, { outputs: directory });
18
+ else writeLine(io, directory);
19
+ }
@@ -15,6 +15,10 @@ import {
15
15
  type HtmlOverlayScaffoldKind,
16
16
  } from "../html-overlay";
17
17
  import { CliError } from "./errors";
18
+ import {
19
+ createHtmlFilmProject, HTML_FILM_ASPECTS, HTML_FILM_TEMPLATES,
20
+ type HtmlFilmAspect, type HtmlFilmInitResult, type HtmlFilmTemplate,
21
+ } from "./html-film-init";
18
22
 
19
23
  const HEADLESS_SLOPCAMERA_CLI_MODULE = "@hraness/slopcamera/cli";
20
24
 
@@ -42,6 +46,7 @@ export interface PortableSurfaceDependencies {
42
46
  readonly log?: (value: string) => void;
43
47
  readonly runHeadless?: (argv: readonly string[], options?: Readonly<{ onUsefulResult?: UsefulResultObserver }>) => Promise<void>;
44
48
  readonly writeScaffold?: (path: string, html: string) => Promise<void>;
49
+ readonly createFilm?: typeof createHtmlFilmProject;
45
50
  }
46
51
  function optionValue(argv: readonly string[], name: string): string | undefined {
47
52
  const indexes = argv.flatMap((value, index) => value === name ? [index] : []);
@@ -215,6 +220,53 @@ function runHtmlCatalog(
215
220
  return 0;
216
221
  }
217
222
 
223
+ const HTML_INIT_USAGE = "Use slopcamera html init <dir> --template launch-film [--aspect 16:9|1:1|9:16] [--json].";
224
+
225
+ async function runHtmlInit(
226
+ argv: readonly string[],
227
+ dependencies: PortableSurfaceDependencies,
228
+ ): Promise<number> {
229
+ let directory: string | undefined;
230
+ let template: string | undefined;
231
+ let aspect: string | undefined;
232
+ let json = false;
233
+ for (let index = 2; index < argv.length; index += 1) {
234
+ const value = argv[index]!;
235
+ if (value === "--json") {
236
+ if (json) throw new CliError("usage", "--json may be supplied at most once.");
237
+ json = true;
238
+ } else if (value === "--template" || value === "--aspect") {
239
+ const next = argv[index + 1];
240
+ if (next === undefined || next.startsWith("--")) throw new CliError("usage", `${value} requires a value.`);
241
+ if ((value === "--template" ? template : aspect) !== undefined) throw new CliError("usage", `${value} may be supplied at most once.`);
242
+ if (value === "--template") template = next;
243
+ else aspect = next;
244
+ index += 1;
245
+ } else if (value.startsWith("-") || directory !== undefined) {
246
+ throw new CliError("usage", HTML_INIT_USAGE);
247
+ } else {
248
+ directory = value;
249
+ }
250
+ }
251
+ if (directory === undefined || template === undefined) throw new CliError("usage", HTML_INIT_USAGE);
252
+ if (!(HTML_FILM_TEMPLATES as readonly string[]).includes(template)) {
253
+ throw new CliError("usage", `Unknown film template ${template}. Use one of: ${HTML_FILM_TEMPLATES.join(", ")}.`);
254
+ }
255
+ const selectedAspect = aspect ?? "16:9";
256
+ if (!(HTML_FILM_ASPECTS as readonly string[]).includes(selectedAspect)) {
257
+ throw new CliError("usage", `--aspect must be one of ${HTML_FILM_ASPECTS.join(", ")}.`);
258
+ }
259
+ const result: HtmlFilmInitResult = await (dependencies.createFilm ?? createHtmlFilmProject)(
260
+ resolve((dependencies.cwd ?? process.cwd)(), directory),
261
+ { template: template as HtmlFilmTemplate, aspect: selectedAspect as HtmlFilmAspect },
262
+ );
263
+ (dependencies.log ?? console.log)(json
264
+ ? JSON.stringify(result)
265
+ : [`Created ${result.directory} from ${result.template} (${result.aspect})`, ...result.next.map(line => `next ${line}`)].join("\n"));
266
+ reportUsefulResult(dependencies.onUsefulResult);
267
+ return 0;
268
+ }
269
+
218
270
  export async function runPortableSurface(
219
271
  argvInput: readonly string[],
220
272
  dependencies: PortableSurfaceDependencies = {},
@@ -222,8 +274,10 @@ export async function runPortableSurface(
222
274
  const argv = canonicalizeUnifiedCliArgs(argvInput);
223
275
  if (argv[0] === "style" && (argv.includes("--help") || argv.includes("-h"))) return undefined;
224
276
  if (argv[0] === "html") {
225
- if (argv[1] === "render" || argv.includes("--help") || argv.includes("-h")) return undefined;
277
+ if (argv[1] === "render" || argv[1] === "still" || argv[1] === "preview" || argv[1] === "deliver"
278
+ || argv.includes("--help") || argv.includes("-h")) return undefined;
226
279
  if (argv[1] === "catalog") return runHtmlCatalog(argv, dependencies);
280
+ if (argv[1] === "init") return await runHtmlInit(argv, dependencies);
227
281
  return await runHtmlScaffold(argv, dependencies);
228
282
  }
229
283
  const delegatesToHeadless = argv[0] === "diagram"
@@ -0,0 +1,314 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ defineRegistry,
6
+ HranessError,
7
+ okEnvelope,
8
+ runCli as runRegistryCli,
9
+ type CliIO,
10
+ type ErrorCode,
11
+ type Registry,
12
+ type Verb,
13
+ } from "@hraness/desktop-foundation/registry";
14
+ import { chooseMode, renderSnapshot, runTui, type TuiIO } from "@hraness/desktop-foundation/tui";
15
+
16
+ import { SLOPCAMERA_VERSION } from "../../../src/version";
17
+ import { listOutputs, OUTPUTS_LIMIT, statusEnvelope, statusViews, STATUS_SCHEMA, type StatusOutputs } from "./desktop-status";
18
+ import type { ProcessRunner } from "./io";
19
+ import { CliError } from "./errors";
20
+ import { BunProcessRunner } from "./io";
21
+ import { readActivityStatus } from "./activity-status";
22
+ import { inspectLegacyLogin, retireLegacyLogin, type RetireDependencies } from "./legacy-login";
23
+ import { outputsDirectory } from "./outputs";
24
+ import { defaultCliStateRoot } from "./paths";
25
+ import { CLI_VERBS, type VerbRow } from "./verbs";
26
+
27
+ /**
28
+ * The verbs `slopcamera` answers through the desktop-foundation registry:
29
+ * `status`, `tui`, `commands`, `legacy retire` and `outputs list|open|reveal`.
30
+ * Every other verb keeps its own parser in `args.ts`; the registry still
31
+ * lists it with its class so `commands --json` covers the whole CLI.
32
+ */
33
+ export const PRODUCT = "slopcamera";
34
+ export const OUTPUTS_LIST_LIMIT = 200;
35
+
36
+ /** First words (and `outputs` sub-verbs) the registry answers. */
37
+ export function isRegistryCommand(argv: readonly string[]): boolean {
38
+ const [first, second] = argv;
39
+ if (first === "status" || first === "tui" || first === "commands" || first === "legacy") return true;
40
+ return first === "outputs" && (second === "list" || second === "open" || second === "reveal");
41
+ }
42
+
43
+ export interface RegistryDependencies {
44
+ readonly env: Readonly<Record<string, string | undefined>>;
45
+ readonly platform: NodeJS.Platform;
46
+ readonly now: () => Date;
47
+ readonly stateRoot?: string;
48
+ readonly version?: string;
49
+ readonly runner?: ProcessRunner;
50
+ readonly retire?: RetireDependencies;
51
+ readonly stdoutIsTerminal?: boolean;
52
+ readonly tuiIo?: TuiIO;
53
+ }
54
+
55
+ function stateRootOf(deps: RegistryDependencies): string {
56
+ return deps.stateRoot ?? defaultCliStateRoot(deps.platform, deps.env);
57
+ }
58
+
59
+ /** Loads everything `status` and `tui` show. Reads only; creates nothing. */
60
+ export function loadStatus(deps: RegistryDependencies) {
61
+ const stateRoot = stateRootOf(deps);
62
+ return statusEnvelope({
63
+ version: deps.version ?? SLOPCAMERA_VERSION,
64
+ status: readActivityStatus(stateRoot),
65
+ outputs: listOutputs(outputsDirectory(stateRoot), OUTPUTS_LIMIT),
66
+ legacyLogin: inspectLegacyLogin(deps.env.HOME),
67
+ now: deps.now(),
68
+ });
69
+ }
70
+
71
+ function schemaFor(path: readonly string[]): string {
72
+ return `${PRODUCT}.${path.join(".")}/1`;
73
+ }
74
+
75
+ /**
76
+ * One output by its file name, as `outputs list` prints it. Only a plain
77
+ * name of a regular, visible file directly in the outputs folder is
78
+ * accepted, so the verb can never open anything else.
79
+ */
80
+ export function outputPath(root: string, name: string | undefined): string {
81
+ if (name === undefined || name === "") throw new HranessError("usage", "Name one output, as `slopcamera outputs list` prints it.");
82
+ if (name.includes("/") || name.includes("\\") || name.startsWith(".") || name.includes("\0") || name.length > 255) {
83
+ throw new HranessError("usage", "Give the output's file name only, as `slopcamera outputs list` prints it.");
84
+ }
85
+ const path = join(root, name);
86
+ let regular = false;
87
+ try {
88
+ regular = lstatSync(path).isFile();
89
+ } catch {
90
+ regular = false;
91
+ }
92
+ if (!regular) {
93
+ throw new HranessError("not-found", `No output named ${JSON.stringify(name)}.`, undefined, [
94
+ { command: `${PRODUCT} outputs list --json`, why: "List the outputs by name.", audience: "agent" },
95
+ ]);
96
+ }
97
+ return path;
98
+ }
99
+
100
+ async function finder(deps: RegistryDependencies, argv: readonly [string, ...string[]]): Promise<void> {
101
+ if (deps.platform !== "darwin") {
102
+ throw new HranessError("unsupported-platform", "Opening Finder works on macOS only. Nothing opened.", undefined, [
103
+ { command: `${PRODUCT} outputs --json`, why: "Print the outputs folder to open it yourself.", audience: "agent" },
104
+ ]);
105
+ }
106
+ const result = await (deps.runner ?? new BunProcessRunner()).run(argv, { maxOutputBytes: 4_096 });
107
+ if (result.exitCode !== 0) throw new HranessError("internal", "Finder didn't open. Try again.");
108
+ }
109
+
110
+ interface OutputsListData {
111
+ readonly root: string;
112
+ readonly total: number;
113
+ readonly outputs: StatusOutputs["latest"];
114
+ }
115
+
116
+ interface OpenedData {
117
+ readonly opened: string;
118
+ readonly reveal: boolean;
119
+ }
120
+
121
+ // defineRegistry takes a heterogeneous verb list typed Verb<any, any>[] upstream.
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
123
+ function implemented(deps: RegistryDependencies): Verb<any, any>[] {
124
+ const stateRoot = () => stateRootOf(deps);
125
+ const status: Verb<Record<string, never>, number> = {
126
+ path: ["status"],
127
+ opClass: "read",
128
+ schema: STATUS_SCHEMA,
129
+ summary: "What Slopcamera is doing, credits, newest outputs and old login items",
130
+ output: "raw",
131
+ input: () => ({}),
132
+ async run(_input, ctx) {
133
+ const envelope = loadStatus(deps);
134
+ if (ctx.json) {
135
+ ctx.io.stdout.write(`${JSON.stringify(envelope)}\n`);
136
+ } else if (envelope.ok) {
137
+ ctx.io.stdout.write(renderSnapshot(statusViews(deps.now), envelope.data));
138
+ for (const next of envelope.next ?? []) ctx.io.stdout.write(`next: ${next.command} (${next.why})\n`);
139
+ }
140
+ return 0;
141
+ },
142
+ };
143
+ const tui: Verb<{ snapshot: boolean; width?: number }, number> = {
144
+ path: ["tui"],
145
+ opClass: "read",
146
+ schema: STATUS_SCHEMA,
147
+ summary: "The status screen; --snapshot prints it once, --json prints the status",
148
+ flags: ["snapshot"],
149
+ valueFlags: ["width"],
150
+ usage: "[--snapshot|--json] [--width N]",
151
+ output: "raw",
152
+ input(argv) {
153
+ if (argv.positionals.length > 0) throw new HranessError("usage", "tui takes no arguments.");
154
+ const width = argv.flags.width;
155
+ if (width === undefined) return { snapshot: argv.flags.snapshot === true };
156
+ if (typeof width !== "string" || !/^[0-9]{1,4}$/u.test(width) || Number(width) < 20 || Number(width) > 400) {
157
+ throw new HranessError("usage", "--width takes a number of columns from 20 to 400.");
158
+ }
159
+ return { snapshot: argv.flags.snapshot === true, width: Number(width) };
160
+ },
161
+ async run(input, ctx) {
162
+ const mode = chooseMode(ctx.json, input.snapshot, deps.stdoutIsTerminal ?? process.stdout.isTTY === true);
163
+ return await runTui({
164
+ load: async () => loadStatus(deps),
165
+ views: statusViews(deps.now),
166
+ mode,
167
+ ...(input.width === undefined ? {} : { width: input.width }),
168
+ io: deps.tuiIo ?? { stdout: { write: (text: string) => ctx.io.stdout.write(text), ...(process.stdout.columns === undefined ? {} : { columns: process.stdout.columns }) }, stdin: process.stdin },
169
+ });
170
+ },
171
+ };
172
+ const legacyRetire: Verb<Record<string, never>, { retired: { label: string; from: string; to: string }[]; loginItem: ReturnType<typeof inspectLegacyLogin> }> = {
173
+ path: ["legacy", "retire"],
174
+ opClass: "operate",
175
+ schema: schemaFor(["legacy", "retire"]),
176
+ summary: "Stop the old menu bar opening at login, keeping its file",
177
+ input(argv) {
178
+ if (argv.positionals.length > 0) throw new HranessError("usage", "legacy retire takes no arguments.");
179
+ return {};
180
+ },
181
+ async run() {
182
+ if (deps.platform !== "darwin") return { retired: [], loginItem: { state: "none" } };
183
+ const retired = await retireLegacyLogin(deps.env.HOME, deps.retire ?? {});
184
+ return { retired, loginItem: inspectLegacyLogin(deps.env.HOME) };
185
+ },
186
+ text(output) {
187
+ const moved = output.retired.map(item => `Moved ${item.from} to ${item.to}. The old menu bar no longer opens at login.`);
188
+ if (output.loginItem.state === "not-ours") moved.push("A menu bar login item was changed outside Slopcamera, so it was left alone.");
189
+ return moved.length === 0 ? "Nothing to retire. The old menu bar doesn't open at login." : moved.join("\n");
190
+ },
191
+ };
192
+ const outputsList: Verb<Record<string, never>, OutputsListData> = {
193
+ path: ["outputs", "list"],
194
+ opClass: "read",
195
+ schema: schemaFor(["outputs", "list"]),
196
+ summary: "List the files in the outputs folder, newest first",
197
+ input(argv) {
198
+ if (argv.positionals.length > 0) throw new HranessError("usage", "outputs list takes no arguments.");
199
+ return {};
200
+ },
201
+ async run() {
202
+ const listed = listOutputs(outputsDirectory(stateRoot()), OUTPUTS_LIST_LIMIT);
203
+ return { root: listed.root, total: listed.total, outputs: listed.latest };
204
+ },
205
+ text(output) {
206
+ if (output.outputs.length === 0) return `No outputs yet in ${output.root}`;
207
+ return output.outputs.map(item => `${item.name}\t${item.bytes}\t${item.modifiedAt}`).join("\n");
208
+ },
209
+ };
210
+ const openVerb = (reveal: boolean): Verb<{ name?: string }, OpenedData> => ({
211
+ path: ["outputs", reveal ? "reveal" : "open"],
212
+ opClass: "operate",
213
+ schema: schemaFor(["outputs", reveal ? "reveal" : "open"]),
214
+ summary: reveal ? "Show one output in Finder" : "Open the outputs folder, or one output, on this Mac",
215
+ usage: reveal ? "<name>" : "[<name>]",
216
+ input(argv) {
217
+ if (argv.positionals.length > 1) throw new HranessError("usage", "Name at most one output.");
218
+ const name = argv.positionals[0];
219
+ if (reveal && name === undefined) throw new HranessError("usage", "Name the output to show, as `slopcamera outputs list` prints it.");
220
+ return name === undefined ? {} : { name };
221
+ },
222
+ async run(input) {
223
+ const root = outputsDirectory(stateRoot());
224
+ if (input.name === undefined) {
225
+ let folder = false;
226
+ try {
227
+ folder = lstatSync(root).isDirectory();
228
+ } catch {
229
+ folder = false;
230
+ }
231
+ if (!folder) {
232
+ throw new HranessError("not-found", "There's no outputs folder yet. Agents create it when they save their first output.", undefined, [
233
+ { command: `${PRODUCT} outputs --json`, why: "Create the outputs folder and print its path.", audience: "agent" },
234
+ ]);
235
+ }
236
+ await finder(deps, ["/usr/bin/open", "--", root]);
237
+ return { opened: root, reveal: false };
238
+ }
239
+ const path = outputPath(root, input.name);
240
+ await finder(deps, reveal ? ["/usr/bin/open", "-R", "--", path] : ["/usr/bin/open", "--", path]);
241
+ return { opened: path, reveal };
242
+ },
243
+ text(output) {
244
+ return output.reveal ? `Showed ${output.opened} in Finder.` : `Opened ${output.opened}.`;
245
+ },
246
+ });
247
+ return [status, tui, legacyRetire, outputsList, openVerb(false), openVerb(true)];
248
+ }
249
+
250
+ /** A verb `args.ts` parses and runs; the registry only lists it. */
251
+ function listed(row: VerbRow): Verb<unknown, unknown> {
252
+ return {
253
+ path: row.path,
254
+ opClass: row.opClass,
255
+ schema: schemaFor(row.path),
256
+ summary: row.summary,
257
+ input: () => {
258
+ throw new HranessError("internal", `slopcamera ${row.path.join(" ")} is run by the main parser.`);
259
+ },
260
+ run: async () => undefined,
261
+ };
262
+ }
263
+
264
+ /** The shared envelope codes a Slopcamera error keeps as is. */
265
+ const SHARED_CODES = new Set<ErrorCode>(["usage", "not-found", "conflict", "internal"]);
266
+
267
+ /**
268
+ * Answers a Slopcamera application error with a declared envelope code: the
269
+ * shared code when there is one, otherwise `slopcamera.<code>`. Without this
270
+ * the registry reports every such error as an unexpected internal failure.
271
+ */
272
+ export function asHranessError(error: unknown): unknown {
273
+ if (!(error instanceof CliError)) return error;
274
+ const shared = error.code as ErrorCode;
275
+ const code: ErrorCode = SHARED_CODES.has(shared) ? shared : `${PRODUCT}.${error.code}`;
276
+ return new HranessError(code, error.message);
277
+ }
278
+
279
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
280
+ function withDeclaredErrors(verb: Verb<any, any>): Verb<any, any> {
281
+ return {
282
+ ...verb,
283
+ input(argv) {
284
+ try {
285
+ return verb.input(argv);
286
+ } catch (error) {
287
+ throw asHranessError(error);
288
+ }
289
+ },
290
+ async run(input, ctx) {
291
+ try {
292
+ return await verb.run(input, ctx);
293
+ } catch (error) {
294
+ throw asHranessError(error);
295
+ }
296
+ },
297
+ };
298
+ }
299
+
300
+ export function slopcameraRegistry(deps: RegistryDependencies): Registry {
301
+ const own = implemented(deps).map(withDeclaredErrors);
302
+ const ownPaths = new Set(own.map(verb => verb.path.join(" ")));
303
+ return defineRegistry(PRODUCT, [
304
+ ...own,
305
+ ...CLI_VERBS.filter(row => !ownPaths.has(row.path.join(" "))).map(listed),
306
+ ]);
307
+ }
308
+
309
+ /** Runs a registry command line and returns its exit status. */
310
+ export async function runRegistry(argv: readonly string[], io: CliIO, deps: RegistryDependencies): Promise<number> {
311
+ return await runRegistryCli(slopcameraRegistry(deps), argv, io);
312
+ }
313
+
314
+ export { okEnvelope };