@hypit/hypit 0.2.2 → 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 +18 -6
- 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/credential-store-file/README.md +9 -4
- package/packages/credential-store-file/package.json +2 -1
- package/packages/credential-store-file/src/activation.ts +2 -2
- package/packages/credential-store-file/src/index.ts +0 -1
- package/packages/credential-store-file/src/store.ts +13 -40
- package/packages/credential-store-os/runtime/windows-credential.ps1 +1 -2
- package/packages/credential-store-os/src/store.ts +2 -68
- 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 +2 -2
- 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/toolchain.ts +10 -36
- 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 +2 -0
- package/packages/credential-store-file/src/paths.ts +0 -16
- /package/packages/{build-result → file-io-node}/src/replace-file.ts +0 -0
package/README.md
CHANGED
|
@@ -156,7 +156,7 @@ commands and the repository layout.
|
|
|
156
156
|
</picture>
|
|
157
157
|
</a>
|
|
158
158
|
</td>
|
|
159
|
-
<td>Watcha is an innovative ecosystem and community built for AI builders, connecting great products with high-quality users to turn every Demo into reality. Starting from AI product discovery and genuine feedback, Watcha provides product publishing, distribution, and reputation tracking to support builders from launch to early adoption.</td>
|
|
159
|
+
<td><a href="https://watcha.cn">Watcha</a> is an innovative ecosystem and community built for AI builders, connecting great products with high-quality users to turn every Demo into reality. Starting from AI product discovery and genuine feedback, Watcha provides product publishing, distribution, and reputation tracking to support builders from launch to early adoption.</td>
|
|
160
160
|
</tr>
|
|
161
161
|
<tr>
|
|
162
162
|
<td width="220" align="center">
|
|
@@ -167,13 +167,19 @@ commands and the repository layout.
|
|
|
167
167
|
</picture>
|
|
168
168
|
</a>
|
|
169
169
|
</td>
|
|
170
|
-
<td>TokenDance offers builders a unified multi-model gateway with optimized inference costs. Through programs like the Billion-Token Subsidy and the Developer Program, TokenDance helps early-stage builders cut costs so they can focus on product validation and growth.</td>
|
|
170
|
+
<td><a href="https://tokendance.space/?utm_source=hypit">TokenDance</a> offers builders a unified multi-model gateway with optimized inference costs. Through programs like the Billion-Token Subsidy and the Developer Program, TokenDance helps early-stage builders cut costs so they can focus on product validation and growth.</td>
|
|
171
|
+
</tr>
|
|
172
|
+
<tr>
|
|
173
|
+
<td width="220" align="center">
|
|
174
|
+
<a href="https://autoclaw.z.ai"><img alt="AutoClaw" src="./docs/public/autoclaw.png" width="180"></a>
|
|
175
|
+
</td>
|
|
176
|
+
<td><a href="https://autoclaw.z.ai">AutoClaw</a> is Z.ai’s desktop AI assistant for work, deeply optimized for GLM models and integrated with specialized knowledge bases and workflows for legal, finance, design, and more.</td>
|
|
171
177
|
</tr>
|
|
172
178
|
<tr>
|
|
173
179
|
<td width="220" align="center">
|
|
174
180
|
<a href="https://openagents.org/blog/posts/2026-09-08-pilot-user-program-zh-hypit"><img alt="OpenAgents" src="./docs/public/openagents.png" width="180"></a>
|
|
175
181
|
</td>
|
|
176
|
-
<td>OpenAgents Workspace is a product that helps you manage AI agents, allowing you to invoke agents running on any device (whether on a mobile phone or computer) anytime, anywhere. Agents can collaborate within the platform to help you complete tasks and boost efficiency. It currently supports integration with over 20 types of agents, including Claude, Codex, OpenClaw, and Hermes
|
|
182
|
+
<td><a href="https://openagents.org">OpenAgents Workspace</a> is a product that helps you manage AI agents, allowing you to invoke agents running on any device (whether on a mobile phone or computer) anytime, anywhere. Agents can collaborate within the platform to help you complete tasks and boost efficiency. It currently supports integration with over 20 types of agents, including Claude, Codex, OpenClaw, and Hermes.</td>
|
|
177
183
|
</tr>
|
|
178
184
|
<tr>
|
|
179
185
|
<td width="220" align="center">
|
|
@@ -184,13 +190,19 @@ commands and the repository layout.
|
|
|
184
190
|
</picture>
|
|
185
191
|
</a>
|
|
186
192
|
</td>
|
|
187
|
-
<td>Monid is the OpenRouter for agent tools: one key, one wallet, hundreds of tools, billed per call. Hypit can route Seedance video, images, speech and word-level transcription to Monid.</td>
|
|
193
|
+
<td><a href="https://monid.ai?fpr=alvin-45480b">Monid</a> is the OpenRouter for agent tools: one key, one wallet, hundreds of tools, billed per call. Hypit can route Seedance video, images, speech and word-level transcription to Monid.</td>
|
|
194
|
+
</tr>
|
|
195
|
+
<tr>
|
|
196
|
+
<td width="220" align="center">
|
|
197
|
+
<a href="https://www.hiapi.ai/invite/hypit"><img alt="HiAPI" src="./docs/public/hiapi.png" width="165"></a>
|
|
198
|
+
</td>
|
|
199
|
+
<td><a href="https://www.hiapi.ai/invite/hypit">HiAPI</a> is a unified API platform for AI image, video, audio, and language models. Connect GPT Image 2.5, Seedance 2.0, Veo 3.1, Kling, Wan, and other popular models through one platform. Pay as you go with no monthly fee, with free trial credits available for new users.</td>
|
|
188
200
|
</tr>
|
|
189
201
|
<tr>
|
|
190
202
|
<td width="220" align="center">
|
|
191
|
-
<a href="https://
|
|
203
|
+
<a href="https://api.pollo.ai/?utm_source=hypit"><img alt="Pollo API" src="./docs/public/pollo.jpg" width="120"></a>
|
|
192
204
|
</td>
|
|
193
|
-
<td>
|
|
205
|
+
<td><a href="https://api.pollo.ai/?utm_source=hypit">Pollo API</a> gives developers access to 300+ AI image and video models through a single API, with competitive rates. One API key connects you to multiple model providers and scales from 10 to 10,000+ requests.</td>
|
|
194
206
|
</tr>
|
|
195
207
|
</table>
|
|
196
208
|
|
package/bin/hypit.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { register } from "tsx/esm/api";
|
|
4
|
-
import { readFileSync } from "node:fs";
|
|
5
|
-
import { dirname, resolve } from "node:path";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
5
|
+
import { dirname, resolve, sep } from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
7
|
|
|
8
8
|
if (process.argv.length === 3 && ["--version", "-v"].includes(process.argv[2])) {
|
|
9
9
|
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
@@ -18,27 +18,30 @@ process.emitWarning = function hypitWarning(warning, ...args) {
|
|
|
18
18
|
return emitWarning.call(process, warning, ...args);
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Bootstrap and package activation must agree on the physical Distribution root. Windows short
|
|
22
|
+
// paths can survive Node's ordinary resolution while package lookup expands them through libuv.
|
|
23
|
+
const distributionRoot = realpathSync.native(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
|
|
24
|
+
const distributionUrl = pathToFileURL(distributionRoot + sep);
|
|
22
25
|
register();
|
|
23
26
|
const {
|
|
24
27
|
installDistributionPackageResolution,
|
|
25
28
|
installExternalPackageResolution,
|
|
26
|
-
} = await import("
|
|
29
|
+
} = await import(new URL("packages/package-loader-node/src/distribution-resolution.ts", distributionUrl).href);
|
|
27
30
|
installDistributionPackageResolution([distributionRoot]);
|
|
28
|
-
const { hypitHostPackageRoot } = await import("
|
|
31
|
+
const { hypitHostPackageRoot } = await import(new URL("packages/runtime-host-node/src/index.ts", distributionUrl).href);
|
|
29
32
|
installExternalPackageResolution([hypitHostPackageRoot()]);
|
|
30
33
|
const args = process.argv.slice(2);
|
|
31
34
|
if (args[0] === "studio" || (args[0] === "help" && args[1] === "studio")) {
|
|
32
|
-
const { runStudio } = await import("
|
|
35
|
+
const { runStudio } = await import(new URL("packages/studio/start.ts", distributionUrl).href);
|
|
33
36
|
try {
|
|
34
37
|
await runStudio(args[0] === "help" ? ["--help"] : args.slice(1).filter((arg) => arg !== "--debug"), {
|
|
35
38
|
write: (text) => process.stdout.write(text),
|
|
36
39
|
});
|
|
37
40
|
} catch (error) {
|
|
38
|
-
const { renderCliError } = await import("
|
|
41
|
+
const { renderCliError } = await import(new URL("packages/cli/src/index.ts", distributionUrl).href);
|
|
39
42
|
process.stderr.write(renderCliError(error, { debug: args.includes("--debug") }));
|
|
40
43
|
process.exitCode = 1;
|
|
41
44
|
}
|
|
42
45
|
} else {
|
|
43
|
-
await import("
|
|
46
|
+
await import(new URL("packages/video-cli/src/cli.ts", distributionUrl).href);
|
|
44
47
|
}
|
|
@@ -533,14 +533,13 @@ declare function selectWireModelForRequest(mapping: GenerationWireMapping, reque
|
|
|
533
533
|
* only the mapping and the request, never the model package.
|
|
534
534
|
*/
|
|
535
535
|
declare function compileWireRequest(mapping: GenerationWireMapping, request: GenerationRequest, resolve: GenerationArtifactUrlResolver): Promise<GenerationWireRequest>;
|
|
536
|
-
/**
|
|
536
|
+
/** Optional fields may be omitted by authors, but supplied fields must reach the service. */
|
|
537
537
|
declare function mappingSupportsRequest(mapping: GenerationWireMapping, value: unknown): boolean;
|
|
538
538
|
/**
|
|
539
|
-
*
|
|
539
|
+
* Check structural port coverage and required item fields against the model declaration.
|
|
540
540
|
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
* generation returned the wrong result.
|
|
541
|
+
* A service may omit optional item capabilities. mappingSupportsRequest and final compilation
|
|
542
|
+
* additionally refuse requests that actually supply such an unmapped field.
|
|
544
543
|
*/
|
|
545
544
|
declare function assertMappingCoversPorts(table: GenerationPortTable, mapping: GenerationWireMapping): void;
|
|
546
545
|
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
# A project-owned Provider
|
|
2
2
|
|
|
3
|
-
This
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
This example shows a service that Hypit's official Distribution does not ship, implemented as a
|
|
4
|
+
project package through the public Hypit SDK. Two packages cover the two request shapes a service
|
|
5
|
+
usually differs on:
|
|
6
|
+
|
|
7
|
+
| Package | Model | Request | Lifecycle |
|
|
8
|
+
| --- | --- | --- | --- |
|
|
9
|
+
| `packages/provider-images` | `@hypit/gpt-image@1#gpt-image-2` | one image, optional references | asynchronous |
|
|
10
|
+
| `packages/provider-videos` | `@hypit/seedance@1#seedance-2-mini` | one video, references and frames | asynchronous |
|
|
11
|
+
|
|
12
|
+
Both service protocols are **illustrative**, not live vendor APIs. Replace the mapping and transport
|
|
13
|
+
with the selected service's documented operations. The lifecycle tests exercise the examples without
|
|
14
|
+
a paid request. Implement the capabilities needed by the production; a service's entire catalogue is
|
|
15
|
+
not required.
|
|
16
|
+
|
|
17
|
+
The video example is the one to read when the service renders **video**. A video request carries the
|
|
18
|
+
whole reference vocabulary — images, videos, audio and first/last frames — and the result is a large
|
|
19
|
+
remote task rather than a returned file. This is the shape most third-party video services use, and
|
|
20
|
+
the shape the official Distribution cannot supply for a service it does not know.
|
|
8
21
|
|
|
9
22
|
## Follow one request
|
|
10
23
|
|
|
@@ -46,25 +59,84 @@ does not claim remote cancellation.
|
|
|
46
59
|
source and preserves the response with a concise description. It reads no graph and invents no
|
|
47
60
|
future media duration. Rates and credentials do not supply spending permission.
|
|
48
61
|
|
|
62
|
+
## Follow a video request
|
|
63
|
+
|
|
64
|
+
A Seedance request reaches `@hypit/seedance@1#seedance-2-mini` and returns a generated video-set.
|
|
65
|
+
The Model declares the request; this Provider decides how the service receives it. Four things
|
|
66
|
+
differ from the image example, and each is the reason the video example exists.
|
|
67
|
+
|
|
68
|
+
**The reference vocabulary is wider, and each role maps to its own field.** The Model declares
|
|
69
|
+
`referenceImage`, `referenceVideo` and `referenceAudio` as separate ports precisely so each can map
|
|
70
|
+
to one wire field — a service that mixes them into one array cannot tell the roles apart. An image
|
|
71
|
+
reference also carries an optional `personReference` classification, which is why those ports use
|
|
72
|
+
`itemObject` rather than `urlArray`: the item field travels with the URL it belongs to.
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{ "references": [{ "url": "https://…/reference.png", "person": true }] }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`firstFrame` and `lastFrame` accept at most one item each, so their body fields are URLs.
|
|
79
|
+
This illustrative service accepts their optional person classification on `POST /uploads` through
|
|
80
|
+
`x-person-reference: true` or `false`. Their mappings declare `resourceFields: ["personReference"]`,
|
|
81
|
+
and the URL resolver sends that header when supplied. Replace this with the real service’s
|
|
82
|
+
documented transport; a field that the service cannot carry must be refused, never discarded.
|
|
83
|
+
|
|
84
|
+
**The service's range is narrower than the Model's, and the Provider states that.** Seedance 2 Mini
|
|
85
|
+
admits 480p/720p and 4–15 seconds; this illustrative service renders 720p up to 10 seconds. The
|
|
86
|
+
Model is not edited for that. `supports` reports the difference before submission, so `plan` refuses
|
|
87
|
+
the request with a reason instead of failing a paid job downstream:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
This service renders at most 10 seconds, not 12
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Do not widen the Model to fit one service. Do not silently clamp the author's number either — the
|
|
94
|
+
author wrote 12 seconds, and a 10-second result is a different video.
|
|
95
|
+
|
|
96
|
+
**Submission is an enqueue, not the render.** `start` returns as soon as the service acknowledges a
|
|
97
|
+
task, and the HTTP bound covers the API call, not the minutes the render may take. `poll` returns
|
|
98
|
+
`pending` while the task is `queued` or `running`, and the task id is checkpointed before `start`
|
|
99
|
+
returns, so an interrupted Build still names the remote work it began.
|
|
100
|
+
|
|
101
|
+
**The result is fetched in a separate `collect` step.** A finished task carries a signed URL; the
|
|
102
|
+
account key goes only to the service's own API. `collect` downloads the media, stores it through
|
|
103
|
+
`context.resources` and returns the Model's declared value. Because `poll` and `collect` are
|
|
104
|
+
separate actions, download capacity is configured independently of task capacity.
|
|
105
|
+
|
|
106
|
+
A task that reports `succeeded` without an output URL is a service contract violation, not a pending
|
|
107
|
+
job, and fails loudly. A `failed` task keeps the service's own error code and the received task id
|
|
108
|
+
as evidence, and redacts URLs from the message it republishes.
|
|
109
|
+
|
|
110
|
+
`assertMappingCoversPorts` checks declared ports and required item fields. Optional fields need
|
|
111
|
+
request-level checks and transport tests as well:
|
|
112
|
+
a mapping that forgets a reference role or an item field fails at load rather than after a paid
|
|
113
|
+
generation returns the wrong video. Where a Model port is genuinely optional, `whenAbsent` states
|
|
114
|
+
what the service should receive when the author omits it — the service then gets the value the
|
|
115
|
+
author's request implies, not a field left to its own default. The lifecycle test asserts the
|
|
116
|
+
mapping against the Model's own port table.
|
|
117
|
+
|
|
49
118
|
## Make it a package in the production
|
|
50
119
|
|
|
51
|
-
Copy
|
|
52
|
-
`
|
|
53
|
-
|
|
54
|
-
|
|
120
|
+
Copy the package matching your service's request shape — `packages/provider-images` or
|
|
121
|
+
`packages/provider-videos` — into the video's `packages/`. Choose your own package name and change
|
|
122
|
+
`providerModule.name` with it. A service that renders both images and video can implement both
|
|
123
|
+
capabilities in one package; `defineEndpointPackage` accepts several. Implement the actual service
|
|
124
|
+
protocol, including its request limits, upload/download limits and any OAuth or cancellation
|
|
125
|
+
behavior it really offers. Configure the selected service address; `images.example` and
|
|
126
|
+
`videos.example` are placeholders that cannot generate media.
|
|
55
127
|
|
|
56
128
|
Use the active `@hypit/hypit` version as a development dependency. Build and install with the
|
|
57
|
-
project's package manager. In this repository the
|
|
58
|
-
replace it with your selected release when copying it out. The `@hypit/driver-node` and
|
|
59
|
-
`@hypit/gpt-image`
|
|
60
|
-
the copied package. For example:
|
|
129
|
+
project's package manager. In this repository the examples use `workspace:*` for that dependency;
|
|
130
|
+
replace it with your selected release when copying it out. The `@hypit/driver-node` and model
|
|
131
|
+
development dependencies (`@hypit/gpt-image`, `@hypit/seedance`) serve the repository tests only and
|
|
132
|
+
can be removed from the copied package. For example:
|
|
61
133
|
|
|
62
134
|
```bash
|
|
63
|
-
cd packages/provider-
|
|
135
|
+
cd packages/provider-videos
|
|
64
136
|
npm install
|
|
65
137
|
npm run build
|
|
66
138
|
cd ../..
|
|
67
|
-
npm install ./packages/provider-
|
|
139
|
+
npm install ./packages/provider-videos
|
|
68
140
|
```
|
|
69
141
|
|
|
70
142
|
The package ships JavaScript. Its `hypit.activation` exports a Profile-selected Runtime facet;
|
|
@@ -73,10 +145,14 @@ loading project packages. Keep the chosen package version and lockfile with the
|
|
|
73
145
|
|
|
74
146
|
Merge the endpoint, credential store and binding from `hypit.runtime.json` into the project's
|
|
75
147
|
chosen Profile. Preserve its other services and resolve bindings explicitly. Once the user chooses
|
|
76
|
-
that account, `hypit auth login
|
|
148
|
+
that account, `hypit auth login videos.personal` securely enters the key into its declared store.
|
|
77
149
|
`plan` checks the requested parameters; `pricing` supplies the service's rates. Submitting a real
|
|
78
150
|
generation follows the agreed production scope and spending authority.
|
|
79
151
|
|
|
152
|
+
The service decides whether an Endpoint needs its own credential slot at all. A service that
|
|
153
|
+
authenticates through the platform it runs on, or one reached at a private address, may declare no
|
|
154
|
+
credentials or a non-secret account name; `defineEndpointPackage` takes whichever the service has.
|
|
155
|
+
|
|
80
156
|
## Owners
|
|
81
157
|
|
|
82
158
|
- `src/provider.ts`: exact capability, mapping, service support, task lifecycle, pricing and capacity.
|
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
"images.personal": {
|
|
7
7
|
"use": "@example/provider-images",
|
|
8
8
|
"config": { "baseUrl": "https://images.example", "apiKey": { "store": "platform", "key": "images.personal" } }
|
|
9
|
+
},
|
|
10
|
+
"videos.personal": {
|
|
11
|
+
"use": "@example/provider-videos",
|
|
12
|
+
"config": { "baseUrl": "https://videos.example", "apiKey": { "store": "platform", "key": "videos.personal" } }
|
|
9
13
|
}
|
|
10
14
|
},
|
|
11
|
-
"bindings": {
|
|
15
|
+
"bindings": {
|
|
16
|
+
"@hypit/gpt-image@1#gpt-image-2": "images.personal",
|
|
17
|
+
"@hypit/seedance@1#seedance-2-mini": "videos.personal"
|
|
18
|
+
}
|
|
12
19
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@example/provider-videos",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"hypit": { "activation": "./dist/activation.js" },
|
|
7
|
+
"exports": { ".": "./dist/provider.js" },
|
|
8
|
+
"files": ["dist"],
|
|
9
|
+
"scripts": { "build": "tsc -p tsconfig.json" },
|
|
10
|
+
"devDependencies": { "@hypit/hypit": "workspace:*", "typescript": "5.9.3", "@hypit/driver-node": "workspace:*", "@hypit/seedance": "workspace:*" }
|
|
11
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRuntimeEndpointAdapterFacet, runtimeConfigCredentialRef, runtimeConfigExact,
|
|
3
|
+
runtimeConfigObject, runtimeConfigPositiveInteger, runtimeConfigString,
|
|
4
|
+
} from "@hypit/hypit/runtime-kit";
|
|
5
|
+
import { createVideoProvider, providerModule } from "./provider.js";
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
format: "hypit.node-package@1" as const,
|
|
9
|
+
hostFacets: [createRuntimeEndpointAdapterFacet({
|
|
10
|
+
use: providerModule.name,
|
|
11
|
+
activate(context) {
|
|
12
|
+
const config = runtimeConfigObject(context.config, "Video service");
|
|
13
|
+
runtimeConfigExact(config, ["baseUrl", "apiKey", "concurrency", "pollIntervalMs"], "Video service");
|
|
14
|
+
const baseUrl = runtimeConfigString(config.baseUrl, "Video service baseUrl");
|
|
15
|
+
const apiKey = runtimeConfigCredentialRef(config.apiKey, "Video service apiKey");
|
|
16
|
+
if (!baseUrl || !apiKey || !context.pool) throw new Error("Video service requires baseUrl, apiKey and pool");
|
|
17
|
+
return { endpoint: createVideoProvider({
|
|
18
|
+
instance: context.instance, pool: context.pool, baseUrl, apiKey,
|
|
19
|
+
concurrency: runtimeConfigPositiveInteger(config.concurrency, "concurrency") ?? 1,
|
|
20
|
+
pollIntervalMs: runtimeConfigPositiveInteger(config.pollIntervalMs, "pollIntervalMs") ?? 5_000,
|
|
21
|
+
}) };
|
|
22
|
+
},
|
|
23
|
+
})],
|
|
24
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { canonicalize, defineEndpointPackage, wakeAfter } from "@hypit/hypit/endpoint-kit";
|
|
2
|
+
import type { AsyncEndpoint, CredentialRef, EndpointRequest } from "@hypit/hypit/endpoint-kit";
|
|
3
|
+
import {
|
|
4
|
+
compileWireRequest, generationTypes, mappingSupportsRequest,
|
|
5
|
+
sealGeneratedVideoSet, selectWireModelForRequest,
|
|
6
|
+
} from "@hypit/hypit/generation";
|
|
7
|
+
import type { GenerationRequest, GenerationWireMapping } from "@hypit/hypit/generation";
|
|
8
|
+
|
|
9
|
+
export const providerModule = { name: "@example/provider-videos", version: "1" } as const;
|
|
10
|
+
export const capability = { module: { name: "@hypit/seedance", version: "1" }, name: "seedance-2-mini" } as const;
|
|
11
|
+
|
|
12
|
+
// This example service implements only this subset of the model. Its API is described in README.
|
|
13
|
+
export const mapping: GenerationWireMapping = {
|
|
14
|
+
capability, result: "video", routes: [{ model: "seedance-2-mini" }],
|
|
15
|
+
fields: {
|
|
16
|
+
prompt: { as: "value", field: "prompt" },
|
|
17
|
+
duration: { as: "value", field: "duration" },
|
|
18
|
+
resolution: { as: "value", field: "resolution" },
|
|
19
|
+
aspectRatio: { as: "value", field: "ratio" },
|
|
20
|
+
generateAudio: { as: "value", field: "audio" },
|
|
21
|
+
webSearch: { as: "value", field: "search" },
|
|
22
|
+
referenceImage: { as: "itemObject", field: "references", urlKey: "url", fieldKeys: { personReference: "person" } },
|
|
23
|
+
referenceVideo: { as: "itemObject", field: "videoReferences", urlKey: "url", fieldKeys: { personReference: "person" } },
|
|
24
|
+
referenceAudio: { as: "urlArray", field: "audioReferences" },
|
|
25
|
+
firstFrame: { as: "url", field: "firstFrame", resourceFields: ["personReference"] },
|
|
26
|
+
lastFrame: { as: "url", field: "lastFrame", resourceFields: ["personReference"] },
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// This service renders a narrower band than the model vocabulary allows, which it reports rather
|
|
31
|
+
// than widening: Seedance 2 Mini declares 480p/720p and 4–15 seconds, and this service renders 720p
|
|
32
|
+
// up to 10 seconds. The Model is unchanged; the difference belongs to this Endpoint.
|
|
33
|
+
const SUPPORTED_RESOLUTION = "720p";
|
|
34
|
+
const SUPPORTED_SECONDS = 10;
|
|
35
|
+
|
|
36
|
+
function object(value: unknown): Record<string, unknown> {
|
|
37
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected service object");
|
|
38
|
+
return value as Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
function text(value: unknown): string {
|
|
41
|
+
if (typeof value !== "string" || value.length === 0) throw new Error("Expected nonempty service text");
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
// This illustrative service documents these fields as its public failure evidence.
|
|
45
|
+
function publicFailure(value: unknown): { code: string; message: string } | undefined {
|
|
46
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
47
|
+
const error = value as Record<string, unknown>;
|
|
48
|
+
if (typeof error.code !== "string" || typeof error.message !== "string") return undefined;
|
|
49
|
+
return { code: error.code, message: error.message.replace(/https?:\/\/\S+/giu, "[redacted-url]") };
|
|
50
|
+
}
|
|
51
|
+
function address(value: string): string {
|
|
52
|
+
const url = new URL(value);
|
|
53
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname))) {
|
|
54
|
+
throw new Error("Service URLs require HTTPS or loopback HTTP");
|
|
55
|
+
}
|
|
56
|
+
return url.href;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function serviceSupport(request: EndpointRequest) {
|
|
60
|
+
const ports = (request.constraints as unknown as GenerationRequest).ports;
|
|
61
|
+
const duration = ports.duration?.[0];
|
|
62
|
+
if (typeof duration === "number" && duration > SUPPORTED_SECONDS) {
|
|
63
|
+
return { status: "unsupported" as const,
|
|
64
|
+
reason: `This service renders at most ${SUPPORTED_SECONDS} seconds, not ${duration}` };
|
|
65
|
+
}
|
|
66
|
+
const resolution = ports.resolution?.[0];
|
|
67
|
+
if (resolution !== undefined && String(resolution) !== SUPPORTED_RESOLUTION) {
|
|
68
|
+
return { status: "unsupported" as const,
|
|
69
|
+
reason: `This service renders at ${SUPPORTED_RESOLUTION}, not ${String(resolution)}` };
|
|
70
|
+
}
|
|
71
|
+
return mappingSupportsRequest(mapping, request.constraints)
|
|
72
|
+
? { status: "supported" as const }
|
|
73
|
+
: { status: "unsupported" as const, reason: "This service does not accept one of the requested inputs" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createVideoProvider(options: {
|
|
77
|
+
instance: string; pool: string; baseUrl: string; apiKey: CredentialRef;
|
|
78
|
+
concurrency?: number; pollIntervalMs?: number; fetch?: typeof globalThis.fetch;
|
|
79
|
+
}) {
|
|
80
|
+
const base = address(options.baseUrl).replace(/\/$/u, "");
|
|
81
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
82
|
+
const interval = options.pollIntervalMs ?? 5_000;
|
|
83
|
+
const key = (credentials: Readonly<Record<string, { secret: string }>>) => text(credentials.apiKey?.secret);
|
|
84
|
+
async function json(path: string, secret: string, init: RequestInit = {}) {
|
|
85
|
+
const response = await fetcher(`${base}${path}`, {
|
|
86
|
+
...init, headers: { ...init.headers, authorization: `Bearer ${secret}` },
|
|
87
|
+
// Submission enqueues a remote job and answers immediately; the bound is for the API, not the render.
|
|
88
|
+
signal: AbortSignal.timeout(120_000),
|
|
89
|
+
});
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
let error: ReturnType<typeof publicFailure>;
|
|
92
|
+
try { error = publicFailure(object(await response.json()).error); }
|
|
93
|
+
catch { /* A missing public error body leaves the HTTP evidence intact. */ }
|
|
94
|
+
const requestId = response.headers.get("x-request-id");
|
|
95
|
+
throw Object.assign(new Error(`Video service ${init.method ?? "GET"} ${path} returned HTTP ${response.status}`
|
|
96
|
+
+ (requestId === null ? "" : `; request=${requestId}`)
|
|
97
|
+
+ (error === undefined ? "" : `; ${error.code}: ${error.message}`)),
|
|
98
|
+
error === undefined ? {} : { code: error.code });
|
|
99
|
+
}
|
|
100
|
+
return object(await response.json());
|
|
101
|
+
}
|
|
102
|
+
const endpoint: AsyncEndpoint = {
|
|
103
|
+
async start(context) {
|
|
104
|
+
const supported = serviceSupport(context.need);
|
|
105
|
+
if (supported.status === "unsupported") throw new Error(supported.reason);
|
|
106
|
+
const secret = key(context.credentials);
|
|
107
|
+
const authored = context.need.constraints as unknown as GenerationRequest;
|
|
108
|
+
const model = selectWireModelForRequest(mapping, authored);
|
|
109
|
+
// This service has no catalogue query; its known request limits were checked above.
|
|
110
|
+
await context.reportProgress?.({ phase: `Preparing video request: ${model}` });
|
|
111
|
+
const request = await compileWireRequest(mapping, authored,
|
|
112
|
+
async (artifact, fields) => {
|
|
113
|
+
const bytes = await context.resources.get(artifact.resource);
|
|
114
|
+
if (bytes === undefined) throw new Error("Reference media is unavailable");
|
|
115
|
+
const upload = await fetcher(`${base}/uploads`, {
|
|
116
|
+
method: "POST", headers: { "content-type": artifact.mediaType, authorization: `Bearer ${secret}`,
|
|
117
|
+
...(fields?.personReference === undefined ? {} : { "x-person-reference": String(fields.personReference) }),
|
|
118
|
+
},
|
|
119
|
+
body: new Blob([new Uint8Array(bytes)]), signal: AbortSignal.timeout(120_000),
|
|
120
|
+
});
|
|
121
|
+
if (!upload.ok) throw new Error(`Video service POST /uploads returned HTTP ${upload.status}`);
|
|
122
|
+
return address(text(object(await upload.json()).url));
|
|
123
|
+
});
|
|
124
|
+
await context.reportProgress?.({ phase: `Submitting video request: ${model}` });
|
|
125
|
+
const task = await json("/videos", secret, {
|
|
126
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(request),
|
|
127
|
+
});
|
|
128
|
+
const id = text(task.id), handle = { id }, receipt = { id };
|
|
129
|
+
await context.checkpoint?.({ handle, receipt });
|
|
130
|
+
return { ...wakeAfter(handle, interval), receipt };
|
|
131
|
+
},
|
|
132
|
+
async poll(context) {
|
|
133
|
+
const id = text(object(context.handle).id);
|
|
134
|
+
const task = await json(`/videos/${encodeURIComponent(id)}`, key(context.credentials));
|
|
135
|
+
const status = text(task.status);
|
|
136
|
+
if (status === "queued" || status === "running") {
|
|
137
|
+
return wakeAfter({ id }, interval, Date.now(), { phase: status });
|
|
138
|
+
}
|
|
139
|
+
if (status === "failed") {
|
|
140
|
+
const error = publicFailure(task.error);
|
|
141
|
+
return { status: "failed", receipt: { id }, failure: {
|
|
142
|
+
code: error?.code ?? "VIDEO_SERVICE_FAILED",
|
|
143
|
+
message: `Video service task ${id} failed${error === undefined ? "" : `: ${error.message}`}`,
|
|
144
|
+
} };
|
|
145
|
+
}
|
|
146
|
+
if (status !== "succeeded") throw new Error("Video service returned an unknown task state");
|
|
147
|
+
// A completed task without a result URL is a service contract violation, not a pending job.
|
|
148
|
+
return { status: "ready", handle: { id, url: address(text(task.output)) } };
|
|
149
|
+
},
|
|
150
|
+
async collect(context) {
|
|
151
|
+
// The service returns a signed asset URL; account credentials go only to its API.
|
|
152
|
+
const url = address(text(object(context.handle).url));
|
|
153
|
+
await context.reportProgress?.({ phase: "Receiving generated video" });
|
|
154
|
+
// One delivered video is bounded here; a larger file belongs with a streaming resource port.
|
|
155
|
+
const response = await fetcher(url, { signal: AbortSignal.timeout(600_000) });
|
|
156
|
+
if (!response.ok) throw new Error(`Video download returned HTTP ${response.status}`);
|
|
157
|
+
const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim();
|
|
158
|
+
if (!mediaType?.startsWith("video/")) throw new Error("Video service returned a non-video result");
|
|
159
|
+
const artifact = await context.resources.put(new Uint8Array(await response.arrayBuffer()), mediaType);
|
|
160
|
+
return { status: "completed", result: { value: {
|
|
161
|
+
kind: "inline", value: canonicalize(sealGeneratedVideoSet({ videos: [artifact] })),
|
|
162
|
+
} } };
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
return defineEndpointPackage({
|
|
166
|
+
module: providerModule, facet: "videos", instance: options.instance, pool: options.pool,
|
|
167
|
+
credentials: { apiKey: options.apiKey }, credentialInputs: { apiKey: { label: "Video service API key" } },
|
|
168
|
+
defaultConcurrency: options.concurrency ?? 1,
|
|
169
|
+
actionLimits: { submit: { concurrency: 1 }, poll: { concurrency: 4 }, collect: { concurrency: 2 } },
|
|
170
|
+
pricing: { kind: "page", url: `${base}/pricing` },
|
|
171
|
+
async readPricing(context) {
|
|
172
|
+
const model = selectWireModelForRequest(mapping, context.request.constraints as unknown as GenerationRequest,
|
|
173
|
+
context.request.pendingInputs?.map((input) => input.input));
|
|
174
|
+
const path = `/rates?model=${encodeURIComponent(model)}`;
|
|
175
|
+
const source = `${base}${path}`;
|
|
176
|
+
const rates = await json(path, key(await context.credentials()));
|
|
177
|
+
return [{ source, data: canonicalize(rates), summary: text(rates.description) }];
|
|
178
|
+
},
|
|
179
|
+
capabilities: [{ capability, returns: generationTypes.videoSet, lifecycle: "asynchronous", supports: serviceSupport, endpoint }],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext",
|
|
4
|
+
"lib": ["ES2023", "DOM"], "strict": true, "noUncheckedIndexedAccess": true,
|
|
5
|
+
"exactOptionalPropertyTypes": true, "verbatimModuleSyntax": true,
|
|
6
|
+
"declaration": true, "rootDir": "src", "outDir": "dist", "skipLibCheck": true
|
|
7
|
+
},
|
|
8
|
+
"include": ["src/**/*.ts"]
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
encodeBuildResultManifest,
|
|
44
44
|
} from "./decode.js";
|
|
45
45
|
import { syncBuildResultOutputs } from "./writer.js";
|
|
46
|
-
import { replaceFile } from "
|
|
46
|
+
import { replaceFile } from "@hypit/file-io-node";
|
|
47
47
|
|
|
48
48
|
const manifestName = "result.json";
|
|
49
49
|
const writerStateName = ".writer.json";
|
|
@@ -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.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
1
2
|
import {
|
|
2
3
|
createRuntimeCredentialStoreAdapterFacet,
|
|
3
4
|
runtimeConfigExact,
|
|
4
5
|
runtimeConfigObject,
|
|
5
6
|
runtimeConfigString,
|
|
6
7
|
} from "@hypit/runtime-kit";
|
|
7
|
-
import { resolveCredentialDirectory } from "./paths.js";
|
|
8
8
|
import { FileCredentialStore } from "./store.js";
|
|
9
9
|
|
|
10
10
|
const fileCredentialStoreAdapter = createRuntimeCredentialStoreAdapterFacet({
|
|
@@ -18,7 +18,7 @@ const fileCredentialStoreAdapter = createRuntimeCredentialStoreAdapterFacet({
|
|
|
18
18
|
const config = runtimeConfigObject(context.config, "file CredentialStore");
|
|
19
19
|
const path = runtimeConfigString(config.path, "file credential path");
|
|
20
20
|
return { value: new FileCredentialStore(
|
|
21
|
-
|
|
21
|
+
path === undefined ? join(context.hostStateRoot, "credentials") : resolve(context.hostStateRoot, path),
|
|
22
22
|
) };
|
|
23
23
|
},
|
|
24
24
|
});
|
|
@@ -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";
|
|
@@ -8,26 +9,6 @@ function missing(error: unknown): boolean {
|
|
|
8
9
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
-
* One process reads a credential for one Endpoint action while another action's refresh replaces
|
|
13
|
-
* it, so a read handle and a replacement can reach the same document at the same instant. Windows
|
|
14
|
-
* refuses to replace a file any handle still holds. Each document's work takes its turn instead,
|
|
15
|
-
* keyed by path so separate Store instances over one directory share the same order. Keys are
|
|
16
|
-
* independent: a turn on one document never delays another.
|
|
17
|
-
*/
|
|
18
|
-
const documentTurns = new Map<string, Promise<unknown>>();
|
|
19
|
-
|
|
20
|
-
function onDocument<T>(path: string, work: () => Promise<T>): Promise<T> {
|
|
21
|
-
const turn = (documentTurns.get(path) ?? Promise.resolve()).then(work, work);
|
|
22
|
-
// The successor waits for this turn to settle, not to succeed, and the last turn clears the path.
|
|
23
|
-
const settled = turn.then(() => undefined, () => undefined);
|
|
24
|
-
documentTurns.set(path, settled);
|
|
25
|
-
void settled.then(() => {
|
|
26
|
-
if (documentTurns.get(path) === settled) documentTurns.delete(path);
|
|
27
|
-
});
|
|
28
|
-
return turn;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
12
|
function credentialValue(value: unknown): CredentialValue {
|
|
32
13
|
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid credential value");
|
|
33
14
|
const record = value as Record<string, unknown>;
|
|
@@ -64,10 +45,6 @@ export class FileCredentialStore implements WritableCredentialStore {
|
|
|
64
45
|
async resolve(ref: CredentialRef): Promise<CredentialValue | undefined> {
|
|
65
46
|
if (!this.owns(ref)) return undefined;
|
|
66
47
|
const path = this.#path(ref);
|
|
67
|
-
return await onDocument(path, async () => await this.#read(path));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async #read(path: string): Promise<CredentialValue | undefined> {
|
|
71
48
|
try { await this.#privateDirectory(false); } catch (error) {
|
|
72
49
|
if (missing(error)) return undefined;
|
|
73
50
|
throw error;
|
|
@@ -93,25 +70,21 @@ export class FileCredentialStore implements WritableCredentialStore {
|
|
|
93
70
|
async put(ref: CredentialRef, value: CredentialValue): Promise<void> {
|
|
94
71
|
const path = this.#path(ref);
|
|
95
72
|
const contents = JSON.stringify(credentialValue(value));
|
|
96
|
-
await
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
try {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
} finally { await rm(temporary, { force: true }); }
|
|
104
|
-
});
|
|
73
|
+
await this.#privateDirectory(true);
|
|
74
|
+
const temporary = join(this.directory, `.write-${randomUUID()}.tmp`);
|
|
75
|
+
const file = await open(temporary, "wx", 0o600);
|
|
76
|
+
try {
|
|
77
|
+
try { await file.writeFile(`${contents}\n`, "utf8"); } finally { await file.close(); }
|
|
78
|
+
await replaceFile(temporary, path);
|
|
79
|
+
} finally { await rm(temporary, { force: true }); }
|
|
105
80
|
}
|
|
106
81
|
|
|
107
82
|
async delete(ref: CredentialRef): Promise<boolean> {
|
|
108
83
|
const path = this.#path(ref);
|
|
109
84
|
// Deletion never opens or decodes the old document.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
});
|
|
85
|
+
try { await unlink(path); return true; } catch (error) {
|
|
86
|
+
if (missing(error)) return false;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
116
89
|
}
|
|
117
90
|
}
|
|
@@ -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,72 +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
|
-
const windowsCredentialTimeoutMs = 10_000;
|
|
71
|
-
|
|
72
|
-
function windowsCredential(
|
|
73
|
-
operation: "read" | "write" | "delete",
|
|
74
|
-
service: string,
|
|
75
|
-
account: string,
|
|
76
|
-
secret?: string,
|
|
77
|
-
): Promise<WindowsCredentialResult> {
|
|
78
|
-
return new Promise((resolve, reject) => {
|
|
79
|
-
const child = spawn("powershell.exe", [
|
|
80
|
-
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
|
|
81
|
-
"-File", windowsScript, "-Operation", operation,
|
|
82
|
-
], { shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
83
|
-
let stdout = "";
|
|
84
|
-
let stderr = "";
|
|
85
|
-
let settled = false;
|
|
86
|
-
const fail = (error: Error): void => {
|
|
87
|
-
if (settled) return;
|
|
88
|
-
settled = true;
|
|
89
|
-
clearTimeout(timer);
|
|
90
|
-
reject(error);
|
|
91
|
-
};
|
|
92
|
-
const timer = setTimeout(() => {
|
|
93
|
-
fail(new Error(`Windows credential ${operation} for ${account} timed out`));
|
|
94
|
-
child.kill("SIGKILL");
|
|
95
|
-
}, windowsCredentialTimeoutMs);
|
|
96
|
-
child.on("error", fail);
|
|
97
|
-
child.stdout.on("data", (chunk: Buffer) => {
|
|
98
|
-
stdout += chunk.toString("utf8");
|
|
99
|
-
if (stdout.length > 4 * 1024 * 1024) fail(new Error("Windows credential response is too large"));
|
|
100
|
-
});
|
|
101
|
-
child.stderr.on("data", (chunk: Buffer) => {
|
|
102
|
-
stderr += chunk.toString("utf8");
|
|
103
|
-
if (stderr.length > 64 * 1024) fail(new Error("Windows credential error is too large"));
|
|
104
|
-
});
|
|
105
|
-
child.on("close", (code) => {
|
|
106
|
-
if (settled) return;
|
|
107
|
-
clearTimeout(timer);
|
|
108
|
-
if (code !== 0) {
|
|
109
|
-
fail(new Error(`Windows credential ${operation} for ${account} failed${stderr.trim().length === 0 ? "" : `: ${stderr.trim()}`}`));
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
try {
|
|
113
|
-
const result = JSON.parse(stdout.replace(/^\uFEFF/u, "").trim()) as WindowsCredentialResult;
|
|
114
|
-
settled = true;
|
|
115
|
-
resolve(result);
|
|
116
|
-
} catch {
|
|
117
|
-
fail(new Error(`Windows credential ${operation} returned an invalid response`));
|
|
118
|
-
}
|
|
119
|
-
});
|
|
120
|
-
child.stdin.end(JSON.stringify({
|
|
121
|
-
service,
|
|
122
|
-
account,
|
|
123
|
-
...(secret === undefined ? {} : { secret: Buffer.from(secret, "utf8").toString("base64") }),
|
|
124
|
-
}));
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
62
|
function windowsBackend(): {
|
|
129
63
|
readonly read: OsCredentialReader;
|
|
130
64
|
readonly write: OsCredentialWriter;
|
|
@@ -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
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
1
2
|
import {
|
|
2
3
|
createRuntimeCredentialStoreAdapterFacet,
|
|
3
4
|
runtimeConfigExact,
|
|
4
5
|
runtimeConfigObject,
|
|
5
6
|
runtimeConfigString,
|
|
6
7
|
} from "@hypit/runtime-kit";
|
|
7
|
-
import { resolveCredentialDirectory } from "@hypit/credential-store-file";
|
|
8
8
|
import { PlatformCredentialStore } from "./store.js";
|
|
9
9
|
|
|
10
10
|
const platformCredentialStoreAdapter = createRuntimeCredentialStoreAdapterFacet({
|
|
@@ -23,7 +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:
|
|
26
|
+
directory: path === undefined ? join(context.hostStateRoot, "credentials") : resolve(context.hostStateRoot, path),
|
|
27
27
|
...(service === undefined ? {} : { service }),
|
|
28
28
|
}),
|
|
29
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,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" }
|
|
@@ -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") {
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
2
|
-
|
|
3
|
-
/** Profile `path` is Host-state-relative; it must not write secrets outside that root. */
|
|
4
|
-
export function resolveCredentialDirectory(
|
|
5
|
-
hostStateRoot: string,
|
|
6
|
-
path: string | undefined,
|
|
7
|
-
label: string,
|
|
8
|
-
): string {
|
|
9
|
-
const root = resolve(hostStateRoot);
|
|
10
|
-
const directory = path === undefined ? join(root, "credentials") : resolve(root, path);
|
|
11
|
-
const relation = relative(root, directory);
|
|
12
|
-
if (relation === ".." || relation.startsWith(`..${sep}`) || isAbsolute(relation)) {
|
|
13
|
-
throw new Error(`${label} path must stay inside the Host state root`);
|
|
14
|
-
}
|
|
15
|
-
return directory;
|
|
16
|
-
}
|
|
File without changes
|