@hypit/hypit 0.2.1 → 0.2.3

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 (41) hide show
  1. package/README.md +38 -4
  2. package/bin/hypit.mjs +12 -9
  3. package/dist/public/generation.d.ts +4 -5
  4. package/examples/provider-package/README.md +92 -16
  5. package/examples/provider-package/hypit.runtime.json +8 -1
  6. package/examples/provider-package/packages/provider-videos/package.json +11 -0
  7. package/examples/provider-package/packages/provider-videos/src/activation.ts +24 -0
  8. package/examples/provider-package/packages/provider-videos/src/provider.ts +181 -0
  9. package/examples/provider-package/packages/provider-videos/tsconfig.json +9 -0
  10. package/package.json +1 -1
  11. package/packages/build-result/package.json +1 -1
  12. package/packages/build-result/src/store.ts +1 -1
  13. package/packages/caption-fine-studio/package.json +1 -1
  14. package/packages/cli/src/commands/environment.ts +12 -5
  15. package/packages/cli/src/output.ts +1 -1
  16. package/packages/credential-store-file/README.md +9 -4
  17. package/packages/credential-store-file/package.json +2 -1
  18. package/packages/credential-store-file/src/activation.ts +3 -2
  19. package/packages/credential-store-file/src/store.ts +3 -2
  20. package/packages/credential-store-os/runtime/windows-credential.ps1 +1 -2
  21. package/packages/credential-store-os/src/store.ts +4 -61
  22. package/packages/credential-store-os/src/windows.ts +47 -0
  23. package/packages/credential-store-platform/README.md +2 -1
  24. package/packages/credential-store-platform/src/activation.ts +1 -3
  25. package/packages/file-io-node/README.md +20 -0
  26. package/packages/file-io-node/package.json +13 -0
  27. package/packages/file-io-node/src/index.ts +1 -0
  28. package/packages/{build-result → file-io-node}/src/replace-file-windows.ts +3 -3
  29. package/packages/generation/README.md +6 -0
  30. package/packages/generation/src/mapping.ts +30 -10
  31. package/packages/media-execution/src/process-env.ts +3 -2
  32. package/packages/media-execution/src/toolchain.ts +10 -36
  33. package/packages/performance/package.json +2 -1
  34. package/packages/provider-hyperframes-local/src/process.ts +6 -2
  35. package/packages/provider-hyperframes-local/src/program.ts +2 -1
  36. package/packages/provider-media-local/README.md +2 -1
  37. package/packages/runtime-local/src/programs.ts +10 -5
  38. package/packages/video-cli/README.md +6 -4
  39. package/packages/video-cli/package.json +1 -0
  40. package/packages/video-cli/src/distribution.ts +5 -2
  41. /package/packages/{build-result → file-io-node}/src/replace-file.ts +0 -0
@@ -47,7 +47,8 @@ report the read error rather than treating corruption as a missing credential.
47
47
  ## Storage
48
48
 
49
49
  The default directory is `credentials` under the Host state root printed by `hypit paths`.
50
- `config.path` explicitly selects a directory, resolving relative paths against that Host root.
50
+ `config.path` explicitly selects a directory, including an absolute directory outside that root;
51
+ relative paths resolve against the Host root.
51
52
  Opening the adapter and reading an absent key do not create files. Keep this directory outside Git.
52
53
 
53
54
  Each key has its own JSON file containing only `secret` and optional `expiresAt`, as defined by
@@ -56,7 +57,11 @@ CredentialValue. Filenames use reversible lower-case hex encoding of the key's U
56
57
  case-insensitive filesystems and keeps slashes and reserved names out of filesystem path syntax.
57
58
  Filesystem path-length limits still apply; an overlong key fails rather than selecting another name.
58
59
 
59
- Writes create an owner-only temporary file and replace the single destination with rename. Different
60
- keys do not overwrite each other's updates; concurrent writes to the same key keep the last completed
61
- replacement. The Store does not enumerate credentials, maintain an index, or record write history.
60
+ Writes create an owner-only temporary file and replace the single destination through `@hypit/file-io-node`.
61
+ On Windows this uses native POSIX replacement semantics, so an open reader keeps the old file
62
+ while a new open sees the replacement; it does not use Node’s `MoveFileExW` path. Different
63
+ keys do not overwrite each other's updates. Reads see a complete document, and the last completed
64
+ replacement of the same key supplies its value. There is no process-local queue or cross-process
65
+ coordination; filesystem errors propagate to the caller rather than being retried or hidden.
66
+ The Store does not enumerate credentials, maintain an index, or record write history.
62
67
  Permission and I/O errors propagate. Existing directory permissions are not silently changed.
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@hypit/runtime": "workspace:*",
19
- "@hypit/runtime-kit": "workspace:*"
19
+ "@hypit/runtime-kit": "workspace:*",
20
+ "@hypit/file-io-node": "workspace:*"
20
21
  }
21
22
  }
@@ -17,8 +17,9 @@ const fileCredentialStoreAdapter = createRuntimeCredentialStoreAdapterFacet({
17
17
  open(context) {
18
18
  const config = runtimeConfigObject(context.config, "file CredentialStore");
19
19
  const path = runtimeConfigString(config.path, "file credential path");
20
- return { value: new FileCredentialStore(path === undefined
21
- ? join(context.hostStateRoot, "credentials") : resolve(context.hostStateRoot, path)) };
20
+ return { value: new FileCredentialStore(
21
+ path === undefined ? join(context.hostStateRoot, "credentials") : resolve(context.hostStateRoot, path),
22
+ ) };
22
23
  },
23
24
  });
24
25
 
@@ -1,5 +1,6 @@
1
+ import { replaceFile } from "@hypit/file-io-node";
1
2
  import { randomUUID } from "node:crypto";
2
- import { mkdir, open, rename, rm, stat, unlink } from "node:fs/promises";
3
+ import { mkdir, open, rm, stat, unlink } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { verifyCredentialRef } from "@hypit/runtime";
5
6
  import type { CredentialRef, CredentialValue, WritableCredentialStore } from "@hypit/runtime";
@@ -74,7 +75,7 @@ export class FileCredentialStore implements WritableCredentialStore {
74
75
  const file = await open(temporary, "wx", 0o600);
75
76
  try {
76
77
  try { await file.writeFile(`${contents}\n`, "utf8"); } finally { await file.close(); }
77
- await rename(temporary, path);
78
+ await replaceFile(temporary, path);
78
79
  } finally { await rm(temporary, { force: true }); }
79
80
  }
80
81
 
@@ -35,8 +35,7 @@ switch ($Operation) {
35
35
  @{ found = $true; secret = [Convert]::ToBase64String($bytes) } | ConvertTo-Json -Compress
36
36
  }
37
37
  "write" {
38
- $existing = Find-Credential
39
- if ($null -ne $existing) { $vault.Remove($existing) }
38
+ # Add replaces the same resource/account; do not destroy the old value before it succeeds.
40
39
  $bytes = [Convert]::FromBase64String([string]$request.secret)
41
40
  $secret = [System.Text.Encoding]::UTF8.GetString($bytes)
42
41
  $credential = New-Object Windows.Security.Credentials.PasswordCredential(
@@ -1,5 +1,5 @@
1
- import { execFile, spawn } from "node:child_process";
2
- import { fileURLToPath } from "node:url";
1
+ import { execFile } from "node:child_process";
2
+ import { windowsCredential } from "./windows.js";
3
3
 
4
4
  import { verifyCredentialRef } from "@hypit/runtime";
5
5
  import type {
@@ -59,64 +59,6 @@ function macosDeleter(service: string): OsCredentialDeleter {
59
59
  });
60
60
  }
61
61
 
62
- type WindowsCredentialResult = {
63
- readonly found?: boolean;
64
- readonly deleted?: boolean;
65
- readonly secret?: string;
66
- };
67
-
68
- const windowsScript = fileURLToPath(new URL("../runtime/windows-credential.ps1", import.meta.url));
69
-
70
- function windowsCredential(
71
- operation: "read" | "write" | "delete",
72
- service: string,
73
- account: string,
74
- secret?: string,
75
- ): Promise<WindowsCredentialResult> {
76
- return new Promise((resolve, reject) => {
77
- const child = spawn("powershell.exe", [
78
- "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
79
- "-File", windowsScript, "-Operation", operation,
80
- ], { shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
81
- let stdout = "";
82
- let stderr = "";
83
- let settled = false;
84
- const fail = (error: Error): void => {
85
- if (settled) return;
86
- settled = true;
87
- reject(error);
88
- };
89
- child.on("error", fail);
90
- child.stdout.on("data", (chunk: Buffer) => {
91
- stdout += chunk.toString("utf8");
92
- if (stdout.length > 4 * 1024 * 1024) fail(new Error("Windows credential response is too large"));
93
- });
94
- child.stderr.on("data", (chunk: Buffer) => {
95
- stderr += chunk.toString("utf8");
96
- if (stderr.length > 64 * 1024) fail(new Error("Windows credential error is too large"));
97
- });
98
- child.on("close", (code) => {
99
- if (settled) return;
100
- if (code !== 0) {
101
- fail(new Error(`Windows credential ${operation} for ${account} failed${stderr.trim().length === 0 ? "" : `: ${stderr.trim()}`}`));
102
- return;
103
- }
104
- try {
105
- const result = JSON.parse(stdout.replace(/^\uFEFF/u, "").trim()) as WindowsCredentialResult;
106
- settled = true;
107
- resolve(result);
108
- } catch {
109
- fail(new Error(`Windows credential ${operation} returned an invalid response`));
110
- }
111
- });
112
- child.stdin.end(JSON.stringify({
113
- service,
114
- account,
115
- ...(secret === undefined ? {} : { secret: Buffer.from(secret, "utf8").toString("base64") }),
116
- }));
117
- });
118
- }
119
-
120
62
  function windowsBackend(): {
121
63
  readonly read: OsCredentialReader;
122
64
  readonly write: OsCredentialWriter;
@@ -142,8 +84,9 @@ function platformBackend(service: string) {
142
84
  }
143
85
  if (process.platform === "win32") return windowsBackend();
144
86
  // A Profile that selects this Store cannot be repaired by anything the user does here, and the
145
- // other two Stores are the answer, so name them where the failure is read.
87
+ // other Stores are the answer, so name them where the failure is read.
146
88
  throw new Error("OS CredentialStore supports macOS and Windows only; select "
89
+ + "@hypit/credential-store-platform (platform locker, owner-private file on Linux), "
147
90
  + "@hypit/credential-store-file (owner-private local file) or @hypit/credential-store-env "
148
91
  + "(externally supplied value) in this Profile's credentials instead");
149
92
  }
@@ -0,0 +1,47 @@
1
+ import { execFile } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ type WindowsCredentialResult = {
5
+ readonly found?: boolean;
6
+ readonly deleted?: boolean;
7
+ readonly secret?: string;
8
+ };
9
+
10
+ const windowsScript = fileURLToPath(new URL("../runtime/windows-credential.ps1", import.meta.url));
11
+
12
+ /** The OS adapter owns this one child; Node owns its timeout, bounded output and termination. */
13
+ export function windowsCredential(
14
+ operation: "read" | "write" | "delete",
15
+ service: string,
16
+ account: string,
17
+ secret?: string,
18
+ ): Promise<WindowsCredentialResult> {
19
+ return new Promise((resolve, reject) => {
20
+ const child = execFile("powershell.exe", [
21
+ "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
22
+ "-File", windowsScript, "-Operation", operation,
23
+ ], {
24
+ shell: false, windowsHide: true, encoding: "utf8", timeout: 10_000,
25
+ maxBuffer: 4 * 1024 * 1024,
26
+ }, (error, stdout, stderr) => {
27
+ if (error !== null) {
28
+ reject(new Error(`Windows credential ${operation} for ${account} failed`
29
+ + (error.code === undefined ? "" : ` (${error.code})`)
30
+ + (error.killed ? " (child terminated)" : "")
31
+ + (stderr.trim().length === 0 ? "" : `: ${stderr.trim()}`)));
32
+ return;
33
+ }
34
+ try { resolve(JSON.parse(stdout.replace(/^\uFEFF/u, "").trim()) as WindowsCredentialResult); }
35
+ catch { reject(new Error(`Windows credential ${operation} returned an invalid response`)); }
36
+ });
37
+ // A closed input pipe is an operation failure too. Do not leave the child waiting for a request.
38
+ child.stdin!.on("error", () => {
39
+ child.kill();
40
+ reject(new Error(`Windows credential ${operation} request could not be written`));
41
+ });
42
+ child.stdin!.end(JSON.stringify({
43
+ service, account,
44
+ ...(secret === undefined ? {} : { secret: Buffer.from(secret, "utf8").toString("base64") }),
45
+ }));
46
+ });
47
+ }
@@ -53,7 +53,8 @@ credential stored in a locker stays in that locker; a credential stored in a fil
53
53
 
54
54
  On macOS and Windows the credential is held by the platform locker, with exactly the rules of
55
55
  `@hypit/credential-store-os`: it is encrypted and access-controlled by the operating system, and it
56
- is not readable from another machine.
56
+ follows that locker’s access and synchronization policy. Windows Credential Locker may roam
57
+ credentials through the user’s Microsoft account.
57
58
 
58
59
  On Linux the credential is held by an owner-private document with exactly the rules of
59
60
  `@hypit/credential-store-file`: unencrypted JSON, one document per key, directory mode `0700`, file
@@ -23,9 +23,7 @@ const platformCredentialStoreAdapter = createRuntimeCredentialStoreAdapterFacet(
23
23
  value: new PlatformCredentialStore({
24
24
  // Linux uses the same directory the file Store uses by default, so a Profile
25
25
  // that switches between them on Linux finds the credential it stored.
26
- directory: path === undefined
27
- ? join(context.hostStateRoot, "credentials")
28
- : resolve(context.hostStateRoot, path),
26
+ directory: path === undefined ? join(context.hostStateRoot, "credentials") : resolve(context.hostStateRoot, path),
29
27
  ...(service === undefined ? {} : { service }),
30
28
  }),
31
29
  };
@@ -0,0 +1,20 @@
1
+ # `@hypit/file-io-node`
2
+
3
+ Node filesystem operations shared by file-backed adapters. This package has no knowledge of
4
+ credentials, Build Results, Runtime or repository formats.
5
+
6
+ `replaceFile(from, to)` publishes a complete file at a destination on the same filesystem. The
7
+ caller owns creating and closing the source file, its contents and permissions, and temporary-file
8
+ cleanup. Existing readers retain the old file; subsequent opens see the new file. The destination
9
+ may be absent. Concurrent replacements are ordered by the filesystem, with the last replacement
10
+ supplying the current value.
11
+
12
+ POSIX uses Node's `rename`. Windows uses `SetFileInformationByHandle(FileRenameInfoEx)` with
13
+ `REPLACE_IF_EXISTS | POSIX_SEMANTICS`, the same primitive previously owned by `build-result`.
14
+ This avoids `MoveFileExW` rejecting replacement while another process has the destination open.
15
+ The native structure layout comes from Koffi; constants are Windows API values.
16
+
17
+ There is no pre-read, queue, lock file, retry or fallback. Windows filesystems must support this
18
+ operation; unsupported operations, access denials and other I/O errors are reported to the caller.
19
+ Read-only destinations are not overridden. The package does not promise power-loss durability or
20
+ coordinate transactions across multiple files.
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@hypit/file-io-node",
3
+ "version": "0.0.0-dev",
4
+ "license": "SEE LICENSE IN LICENSE",
5
+ "private": true,
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "dependencies": {
11
+ "koffi": "3.2.1"
12
+ }
13
+ }
@@ -0,0 +1 @@
1
+ export { replaceFile } from "./replace-file.js";
@@ -1,4 +1,4 @@
1
- import { toNamespacedPath } from "node:path";
1
+ import { resolve, toNamespacedPath } from "node:path";
2
2
  import koffi from "koffi";
3
3
 
4
4
  const kernel = koffi.load("kernel32.dll");
@@ -35,7 +35,7 @@ function failure(syscall: string, from: string, to: string): Error {
35
35
  * https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/4217551b-d2c0-42cb-9dc1-69a716cf6d0c
36
36
  */
37
37
  export function replaceWindowsFile(from: string, to: string): void {
38
- const filename = Buffer.from(toNamespacedPath(to), "utf16le");
38
+ const filename = Buffer.from(toNamespacedPath(resolve(to)), "utf16le");
39
39
  const nameOffset = koffi.offsetof(renameInfo, "FileName");
40
40
  // Win32 requires a NUL-terminated FileName even though FileNameLength excludes
41
41
  // that terminator. Buffer.alloc leaves the final WCHAR zero-initialized.
@@ -45,7 +45,7 @@ export function replaceWindowsFile(from: string, to: string): void {
45
45
  info.writeUInt32LE(filename.length, koffi.offsetof(renameInfo, "FileNameLength"));
46
46
  filename.copy(info, nameOffset);
47
47
 
48
- const handle = createFile(toNamespacedPath(from), DELETE, SHARE_READ_WRITE_DELETE,
48
+ const handle = createFile(toNamespacedPath(resolve(from)), DELETE, SHARE_READ_WRITE_DELETE,
49
49
  null, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0) as number | bigint;
50
50
  if (handle === -1 || handle === -1n) throw failure("CreateFileW", from, to);
51
51
  try {
@@ -37,3 +37,9 @@ Media wire mappings (`url`, `urlArray`, `itemObject`) may declare `resourceField
37
37
  paths against the declared media fields. False, zero and empty strings remain values; omitted fields
38
38
  remain absent. The resolver implements the service protocol; this package knows no particular
39
39
  service or classification. Providers using custom transports carry those fields through that boundary.
40
+
41
+ A Model item field being optional means authors may omit it. If a request supplies that field,
42
+ `mappingSupportsRequest` and final wire compilation require the mapping to carry it through
43
+ `fieldKeys` or `resourceFields`, including an explicit `false`. A service may support requests
44
+ without a particular optional field and refuse requests that supply it; no field is silently dropped.
45
+ Final compilation checks the whole request before resolving or uploading any reference.
@@ -89,8 +89,9 @@ export function selectWireModelForRequest(
89
89
  request: GenerationRequest,
90
90
  pendingPorts: readonly string[] = [],
91
91
  ): string {
92
- assert(mappingSupportsRequest(mapping, request),
93
- `${mapping.capability.name} request contains a port this Provider cannot map`);
92
+ const unsupported = unsupportedRequestField(mapping, request);
93
+ assert(unsupported === undefined,
94
+ `${mapping.capability.name} request contains ${unsupported} this Provider cannot map`);
94
95
  const present = new Set(presentPorts(request));
95
96
  for (const port of pendingPorts) {
96
97
  assert(mapping.fields[port] !== undefined,
@@ -149,19 +150,38 @@ export async function compileWireRequest(
149
150
  return { model, input: canonicalize(input) };
150
151
  }
151
152
 
152
- /** Does this request only use ports this mapping can write? Needs no model package. */
153
- export function mappingSupportsRequest(mapping: GenerationWireMapping, value: unknown): boolean {
153
+ /** Describe only a supplied value the mapping cannot carry; no model table or saved verdict. */
154
+ function unsupportedRequestField(mapping: GenerationWireMapping, value: unknown): string | undefined {
154
155
  const request = value as GenerationRequest | undefined;
155
- if (request === undefined || request.ports === null || typeof request.ports !== "object") return false;
156
- return Object.keys(request.ports).every((port) => mapping.fields[port] !== undefined);
156
+ if (request === undefined || request === null || request.ports === null || typeof request.ports !== "object") {
157
+ return "invalid ports";
158
+ }
159
+ for (const [port, supplied] of Object.entries(request.ports)) {
160
+ const field = mapping.fields[port];
161
+ if (field === undefined) return `port ${port}`;
162
+ if (field.as !== "url" && field.as !== "urlArray" && field.as !== "itemObject") continue;
163
+ for (const item of supplied as readonly GenerationMediaValue[]) {
164
+ for (const name of Object.keys(item.fields ?? {})) {
165
+ if (!field.resourceFields?.includes(name)
166
+ && !(field.as === "itemObject" && Object.hasOwn(field.fieldKeys, name))) {
167
+ return `field ${port}.${name}`;
168
+ }
169
+ }
170
+ }
171
+ }
172
+ return undefined;
173
+ }
174
+
175
+ /** Optional fields may be omitted by authors, but supplied fields must reach the service. */
176
+ export function mappingSupportsRequest(mapping: GenerationWireMapping, value: unknown): boolean {
177
+ return unsupportedRequestField(mapping, value) === undefined;
157
178
  }
158
179
 
159
180
  /**
160
- * Prove one service mapping covers every port the model declares.
181
+ * Check structural port coverage and required item fields against the model declaration.
161
182
  *
162
- * This is the check the old hand-written per-model translators could not have:
163
- * a forgotten reference role or item field used to surface only after a paid
164
- * generation returned the wrong result.
183
+ * A service may omit optional item capabilities. mappingSupportsRequest and final compilation
184
+ * additionally refuse requests that actually supply such an unmapped field.
165
185
  */
166
186
  export function assertMappingCoversPorts(
167
187
  table: GenerationPortTable,
@@ -1,4 +1,4 @@
1
- const windowsProcessCreation = ["PATHEXT", "SYSTEMROOT", "WINDIR", "ComSpec"] as const;
1
+ const windowsProcessCreation = ["PATHEXT", "SYSTEMROOT", "WINDIR", "ComSpec", "TEMP", "TMP"] as const;
2
2
 
3
3
  /**
4
4
  * ffmpeg and ffprobe are started as PATH executables. The Host environment is
@@ -7,9 +7,10 @@ const windowsProcessCreation = ["PATHEXT", "SYSTEMROOT", "WINDIR", "ComSpec"] as
7
7
  */
8
8
  export function mediaProcessEnv(
9
9
  extra?: Readonly<Record<string, string>>,
10
+ platform: NodeJS.Platform = process.platform,
10
11
  ): NodeJS.ProcessEnv {
11
12
  const env: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? "" };
12
- if (process.platform === "win32") {
13
+ if (platform === "win32") {
13
14
  for (const name of windowsProcessCreation) {
14
15
  const value = process.env[name];
15
16
  if (value !== undefined && value.length > 0) env[name] = value;
@@ -1,18 +1,14 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { mediaProcessEnv } from "./process-env.js";
2
3
 
3
4
  export type MediaToolchainState =
4
5
  | { readonly state: "ready"; readonly ffprobeVersion: string; readonly ffmpegVersion?: string }
5
6
  | { readonly state: "down" | "mismatch"; readonly detail: string };
6
7
 
7
- const REQUIRED_ENCODERS = ["aac", "libx264", "pcm_s16le"] as const;
8
- const REQUIRED_FILTERS = [
9
- "aformat", "amix", "aresample", "asetpts", "atempo", "atrim",
10
- "loop", "pad", "scale", "select", "setpts", "setsar", "trim",
11
- ] as const;
12
-
13
- function run(executable: string, args: readonly string[]): Promise<{ ok: boolean; output: string }> {
8
+ function run(executable: string, args: readonly string[], env: NodeJS.ProcessEnv): Promise<{ ok: boolean; output: string }> {
14
9
  return new Promise((resolve) => {
15
10
  execFile(executable, [...args], {
11
+ env,
16
12
  timeout: 15_000,
17
13
  shell: false,
18
14
  windowsHide: true,
@@ -29,22 +25,18 @@ function firstLine(value: string): string {
29
25
  return value.split(/\r?\n/u, 1)[0]?.trim() ?? "";
30
26
  }
31
27
 
32
- function missingNames(output: string, required: readonly string[]): readonly string[] {
33
- return required.filter((name) => !new RegExp(`(?:^|\\s)${name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}(?:\\s|$)`, "mu")
34
- .test(output));
35
- }
36
-
37
28
  /**
38
- * Compatibility probe for a deployment-owned FFmpeg toolchain. It deliberately
39
- * checks the commands the shared execution body uses instead of requiring one
40
- * blessed version number. A managed distribution may pin binaries separately;
41
- * a custom installation remains valid when it supplies the same capability.
29
+ * Check availability of the selected executables in their execution environment.
30
+ * This does not predict compatibility with every future media command: the execution
31
+ * implementation owns those commands and reports the tool's actual failure.
42
32
  */
43
33
  export async function probeMediaToolchain(options: {
44
34
  readonly ffprobePath: string;
45
35
  readonly ffmpegPath?: string;
36
+ readonly environment?: NodeJS.ProcessEnv;
46
37
  }): Promise<MediaToolchainState> {
47
- const probeVersion = await run(options.ffprobePath, ["-version"]);
38
+ const env = options.environment ?? mediaProcessEnv();
39
+ const probeVersion = await run(options.ffprobePath, ["-version"], env);
48
40
  if (!probeVersion.ok) {
49
41
  return { state: "down", detail: `${options.ffprobePath} is unavailable: ${probeVersion.output}` };
50
42
  }
@@ -52,28 +44,10 @@ export async function probeMediaToolchain(options: {
52
44
  if (ffprobeVersion.length === 0) return { state: "mismatch", detail: "ffprobe returned no version" };
53
45
  if (options.ffmpegPath === undefined) return { state: "ready", ffprobeVersion };
54
46
 
55
- const [ffmpegVersionResult, encoders, filters] = await Promise.all([
56
- run(options.ffmpegPath, ["-version"]),
57
- run(options.ffmpegPath, ["-hide_banner", "-encoders"]),
58
- run(options.ffmpegPath, ["-hide_banner", "-filters"]),
59
- ]);
47
+ const ffmpegVersionResult = await run(options.ffmpegPath, ["-version"], env);
60
48
  if (!ffmpegVersionResult.ok) {
61
49
  return { state: "down", detail: `${options.ffmpegPath} is unavailable: ${ffmpegVersionResult.output}` };
62
50
  }
63
- if (!encoders.ok || !filters.ok) {
64
- return { state: "mismatch", detail: "ffmpeg could not enumerate its encoders and filters" };
65
- }
66
- const missingEncoders = missingNames(encoders.output, REQUIRED_ENCODERS);
67
- const missingFilters = missingNames(filters.output, REQUIRED_FILTERS);
68
- if (missingEncoders.length > 0 || missingFilters.length > 0) {
69
- return {
70
- state: "mismatch",
71
- detail: [
72
- missingEncoders.length === 0 ? "" : `missing encoders ${missingEncoders.join(", ")}`,
73
- missingFilters.length === 0 ? "" : `missing filters ${missingFilters.join(", ")}`,
74
- ].filter(Boolean).join("; "),
75
- };
76
- }
77
51
  const ffmpegVersion = firstLine(ffmpegVersionResult.output);
78
52
  return ffmpegVersion.length === 0
79
53
  ? { state: "mismatch", detail: "ffmpeg returned no version" }
@@ -25,6 +25,7 @@
25
25
  "@hypit/markup": "workspace:*",
26
26
  "@hypit/temporal": "workspace:*",
27
27
  "@hypit/temporal-markup": "workspace:*",
28
- "@hypit/media-track": "workspace:*"
28
+ "@hypit/media-track": "workspace:*",
29
+ "@hypit/visual-ir": "workspace:*"
29
30
  }
30
31
  }
@@ -35,8 +35,12 @@ export async function mediaExecutablePath(value: string): Promise<string> {
35
35
  throw new Error(`HyperFrames media executable ${value} is unavailable; correct the Provider's ffmpegPath or ffprobePath.`);
36
36
  }
37
37
 
38
- function processEnvironment(): NodeJS.ProcessEnv {
39
- const names = ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL"] as const;
38
+ export function processEnvironment(platform: NodeJS.Platform = process.platform): NodeJS.ProcessEnv {
39
+ // POSIX temp is TMPDIR; Windows is TEMP/TMP. HyperFrames writes extracted
40
+ // frames under %TEMP%\hf-render-… and ffmpeg creates temporary files the same way.
41
+ const names = platform === "win32"
42
+ ? ["PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "ComSpec", "TEMP", "TMP", "USERPROFILE", "LANG", "LC_ALL"] as const
43
+ : ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL"] as const;
40
44
  return Object.fromEntries(names.flatMap((name) => process.env[name] === undefined
41
45
  ? []
42
46
  : [[name, process.env[name]]])) as NodeJS.ProcessEnv;
@@ -5,6 +5,7 @@ import { probeMediaToolchain } from "@hypit/media-execution";
5
5
  import type { ManagedProgram, ManagedProgramState } from "@hypit/runtime-kit";
6
6
  import { browserCacheDirectory, browserDownloadBaseUrl, browserDownloadUrl, browserExecutablePath, configuredBrowserPath, requireBrowserExecutable, selectedBrowserVersion } from "./browser.js";
7
7
  import type { BrowserOptions } from "./browser.js";
8
+ import { processEnvironment } from "./process.js";
8
9
 
9
10
  /**
10
11
  * This Provider owns browser selection and preparation. Probes never install;
@@ -44,7 +45,7 @@ export function localHyperframesBrowserProgram(
44
45
  async probe(): Promise<ManagedProgramState> {
45
46
  const browser = await probeBrowser();
46
47
  if (browser.state !== "ready") return browser;
47
- const media = await probeMediaToolchain({ ffprobePath: input.ffprobePath, ...(input.ffmpegPath === undefined ? {} : { ffmpegPath: input.ffmpegPath }) });
48
+ const media = await probeMediaToolchain({ environment: processEnvironment(), ffprobePath: input.ffprobePath, ...(input.ffmpegPath === undefined ? {} : { ffmpegPath: input.ffmpegPath }) });
48
49
  return media.state === "ready"
49
50
  ? { state: "ready" }
50
51
  : { state: media.state, detail: media.detail };
@@ -43,7 +43,8 @@ Source. Local HyperFrames extracts alpha-preserving PNGs and composites them aga
43
43
  Canvas and lower visual layers before encoding the final MP4.
44
44
 
45
45
  The Runtime Adapter declares the selected `ffmpeg`/`ffprobe` pair as an external, non-daemon Program.
46
- Its shared probe checks the encoders and filters used by the execution body. Compatible custom paths
46
+ Its probe checks that the selected executables start in the media execution environment. It does not
47
+ guarantee every codec or filter for every task; an unsupported operation reports FFmpeg’s actual error. Custom paths
47
48
  remain valid; the package neither pins a semantic Capability to one FFmpeg version nor mutates a
48
49
  system package manager.
49
50
 
@@ -296,6 +296,8 @@ async function bringUpOwned(
296
296
  const prepared = await prepareOwned(root, program, endpoint, onProgress);
297
297
  if (prepared.state.state !== "ready") return prepared;
298
298
  onProgress?.({ id: program.id, phase: "ready" });
299
+ // A usable tool may have no process. Preserve the preparation action in that case.
300
+ if (program.start === undefined) return prepared;
299
301
  return { ...base, action: "already-running", state: initial };
300
302
  }
301
303
  if (initial.state === "mismatch") {
@@ -504,11 +506,14 @@ export async function takeManagedProgramsDown(
504
506
  if (pid === undefined || !processAlive(pid)) {
505
507
  if (pid !== undefined) await rm(join(directory(dataRoot, program), "process.pid"), { force: true });
506
508
  const state = await program.probe();
507
- return state.state === "down"
508
- ? { ...base, action: "nothing-to-stop", state }
509
- // Someone else's process, or one started by hand. Killing it is not this
510
- // command's business; saying so is.
511
- : { ...base, action: "not-ours", state, detail: `${program.id} is running without a Hypit process record` };
509
+ if (state.state === "down" || program.start === undefined) {
510
+ // Probe-only Programs describe installed tools or resources. Readiness means they are
511
+ // usable, not that a process exists for Hypit to stop.
512
+ return { ...base, action: "nothing-to-stop", state };
513
+ }
514
+ // Someone else's process, or one started by hand. Killing it is not this
515
+ // command's business; saying so is.
516
+ return { ...base, action: "not-ours", state, detail: `${program.id} is running without a Hypit process record` };
512
517
  }
513
518
  const term = await stopProcessTree(pid);
514
519
  if (term === "denied") {
@@ -35,10 +35,12 @@ Keep an existing chosen service, or configure the chosen local or hosted Provide
35
35
  bindings. HypiHub is the recommended integrated hosted route in the official Distribution; other
36
36
  services use project Provider packages. If the user chooses HypiHub,
37
37
  `hypit auth login hypihub.default` connects that account after choosing its CredentialStore.
38
- The starter selects the OS store for macOS Keychain / Windows Credential Locker. On Linux, or when
39
- explicitly choosing file storage, edit the Profile's `credentials` and Endpoint reference as shown
40
- in [file CredentialStore](../credential-store-file/README.md#select-it-before-login) before `auth` or
41
- `runtime up`. This selection is configuration; execution never switches stores automatically.
38
+ The starter selects the [platform CredentialStore](../credential-store-platform/README.md#select-it):
39
+ macOS Keychain or Windows Credential Locker on those two platforms, and an owner-private file on
40
+ Linux, so the Profile it writes needs no edit on any of them. Name another Store in `credentials` and
41
+ in the Endpoint's credential reference as the
42
+ [file CredentialStore](../credential-store-file/README.md#select-it-before-login) shows — to choose it
43
+ explicitly. This selection is configuration; execution never switches stores automatically.
42
44
  `hypit doctor --endpoint <name>` checks a selected Endpoint;
43
45
  `hypit runtime up --endpoint <name>` prepares that Endpoint and starts the Worker. Repeat the flag
44
46
  for several chosen Endpoints; omitting it prepares the whole Profile. `hypit programs up --endpoint
@@ -25,6 +25,7 @@
25
25
  "@hypit/protocol": "workspace:*",
26
26
  "@hypit/provider-hypihub": "workspace:*",
27
27
  "@hypit/runtime": "workspace:*",
28
+ "@hypit/runtime-kit": "workspace:*",
28
29
  "@hypit/runtime-host-node": "workspace:*",
29
30
  "@hypit/runtime-local": "workspace:*",
30
31
  "@hypit/speech": "workspace:*",
@@ -25,15 +25,18 @@ export const videoCliDistribution: CliDistribution = {
25
25
  initialRuntimeProfile: {
26
26
  format: "hypit.runtime-local@1",
27
27
  dataRoot: ".hypit/runtimes/local",
28
+ // The starter selects the portable Store, so the Profile it writes is openable and writable on
29
+ // Linux as well: macOS and Windows keep the platform locker, and a Linux host uses an
30
+ // owner-private file. `os` and `file` stay selectable by name for one explicit backend.
28
31
  credentials: {
29
- os: { use: "@hypit/credential-store-os" },
32
+ platform: { use: "@hypit/credential-store-platform" },
30
33
  },
31
34
  endpoints: {
32
35
  "hypihub.default": {
33
36
  use: "@hypit/provider-hypihub",
34
37
  config: {
35
38
  baseUrl: "https://hypit.ai",
36
- apiKey: { store: "os", key: "hypihub.oauth" },
39
+ apiKey: { store: "platform", key: "hypihub.oauth" },
37
40
  },
38
41
  },
39
42
  "media.local": {