@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.
- package/README.md +38 -4
- package/bin/hypit.mjs +12 -9
- package/dist/public/generation.d.ts +4 -5
- package/examples/provider-package/README.md +92 -16
- package/examples/provider-package/hypit.runtime.json +8 -1
- package/examples/provider-package/packages/provider-videos/package.json +11 -0
- package/examples/provider-package/packages/provider-videos/src/activation.ts +24 -0
- package/examples/provider-package/packages/provider-videos/src/provider.ts +181 -0
- package/examples/provider-package/packages/provider-videos/tsconfig.json +9 -0
- package/package.json +1 -1
- package/packages/build-result/package.json +1 -1
- package/packages/build-result/src/store.ts +1 -1
- package/packages/caption-fine-studio/package.json +1 -1
- package/packages/cli/src/commands/environment.ts +12 -5
- package/packages/cli/src/output.ts +1 -1
- package/packages/credential-store-file/README.md +9 -4
- package/packages/credential-store-file/package.json +2 -1
- package/packages/credential-store-file/src/activation.ts +3 -2
- package/packages/credential-store-file/src/store.ts +3 -2
- package/packages/credential-store-os/runtime/windows-credential.ps1 +1 -2
- package/packages/credential-store-os/src/store.ts +4 -61
- package/packages/credential-store-os/src/windows.ts +47 -0
- package/packages/credential-store-platform/README.md +2 -1
- package/packages/credential-store-platform/src/activation.ts +1 -3
- package/packages/file-io-node/README.md +20 -0
- package/packages/file-io-node/package.json +13 -0
- package/packages/file-io-node/src/index.ts +1 -0
- package/packages/{build-result → file-io-node}/src/replace-file-windows.ts +3 -3
- package/packages/generation/README.md +6 -0
- package/packages/generation/src/mapping.ts +30 -10
- package/packages/media-execution/src/process-env.ts +3 -2
- package/packages/media-execution/src/toolchain.ts +10 -36
- package/packages/performance/package.json +2 -1
- package/packages/provider-hyperframes-local/src/process.ts +6 -2
- package/packages/provider-hyperframes-local/src/program.ts +2 -1
- package/packages/provider-media-local/README.md +2 -1
- package/packages/runtime-local/src/programs.ts +10 -5
- package/packages/video-cli/README.md +6 -4
- package/packages/video-cli/package.json +1 -0
- package/packages/video-cli/src/distribution.ts +5 -2
- /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,
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
@@ -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(
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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 @@
|
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
/**
|
|
153
|
-
|
|
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")
|
|
156
|
-
|
|
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
|
-
*
|
|
181
|
+
* Check structural port coverage and required item fields against the model declaration.
|
|
161
182
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
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
|
|
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" }
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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: "
|
|
39
|
+
apiKey: { store: "platform", key: "hypihub.oauth" },
|
|
37
40
|
},
|
|
38
41
|
},
|
|
39
42
|
"media.local": {
|
|
File without changes
|