@petercjl/topazlabscli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/bin/topazlabscli.mjs +17 -0
- package/package.json +44 -0
- package/skill/topazlabscli/SKILL.md +49 -0
- package/skill/topazlabscli/adapters/codex.json +29 -0
- package/skill/topazlabscli/adapters/sealseek.json +25 -0
- package/skill/topazlabscli/agents/openai.yaml +4 -0
- package/skill/topazlabscli/capabilities.json +36 -0
- package/src/cli.mjs +285 -0
- package/src/config.mjs +47 -0
- package/src/errors.mjs +16 -0
- package/src/paths.mjs +27 -0
- package/src/process.mjs +21 -0
- package/src/skill.mjs +44 -0
- package/src/ssh.mjs +83 -0
- package/worker/windows/topazlabs-worker.ps1 +237 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peter Chen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# topazlabscli
|
|
2
|
+
|
|
3
|
+
`topazlabscli` is an npm-distributed CLI and portable Agent Skill for sending video-enhancement jobs to an authorized Windows workstation running Topaz Video AI. It uses the workstation's existing SSH service, Topaz installation, GPU, models, and license. Nothing in this package installs, redistributes, licenses, or unlocks Topaz software.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Client: Node.js 20+, OpenSSH `ssh` and `sftp`.
|
|
8
|
+
- Worker: Windows, OpenSSH Server, Topaz Video AI with its bundled FFmpeg/FFprobe, a logged-in licensed user, and downloaded model files.
|
|
9
|
+
- Network access is configured separately. Each endpoint is ordinary user configuration; the package does not contain or manage VPN settings.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```powershell
|
|
14
|
+
npm install --global @petercjl/topazlabscli
|
|
15
|
+
topazlabscli skill install --agent all
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Configure a target
|
|
19
|
+
|
|
20
|
+
Use one or more SSH endpoints in priority order. A LAN-only user configures only the LAN entry.
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
topazlabscli target add gpu-workstation `
|
|
24
|
+
--endpoint lan=gpu-workstation `
|
|
25
|
+
--user Administrator `
|
|
26
|
+
--identity "$HOME\.ssh\gpu_workstation_ed25519" `
|
|
27
|
+
--workspace "E:\topazlab_workspace" `
|
|
28
|
+
--default
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
An authorized roaming user can add a second endpoint that resolves through their own VPN configuration:
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
topazlabscli target add gpu-workstation `
|
|
35
|
+
--endpoint lan=gpu-workstation `
|
|
36
|
+
--endpoint vpn=gpu-workstation-vpn `
|
|
37
|
+
--user Administrator `
|
|
38
|
+
--identity "$HOME\.ssh\gpu_workstation_ed25519" `
|
|
39
|
+
--workspace "E:\topazlab_workspace" `
|
|
40
|
+
--default
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The CLI tries endpoints in the order provided. It never starts or changes a VPN.
|
|
44
|
+
|
|
45
|
+
## Set up and check the worker
|
|
46
|
+
|
|
47
|
+
```powershell
|
|
48
|
+
topazlabscli connection check --json
|
|
49
|
+
topazlabscli worker install --json
|
|
50
|
+
topazlabscli doctor --json
|
|
51
|
+
topazlabscli model status --json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The worker uses a global Windows mutex and one queue consumer, so jobs from multiple clients run serially.
|
|
55
|
+
|
|
56
|
+
## Process a video
|
|
57
|
+
|
|
58
|
+
```powershell
|
|
59
|
+
topazlabscli process .\input.mp4 --output .\output-1080p.mp4 --json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Asynchronous form:
|
|
63
|
+
|
|
64
|
+
```powershell
|
|
65
|
+
topazlabscli job submit .\input.mp4 --json
|
|
66
|
+
topazlabscli job status JOB_ID --json
|
|
67
|
+
topazlabscli job wait JOB_ID --json
|
|
68
|
+
topazlabscli job download JOB_ID --output .\output-1080p.mp4 --json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Version 0.1 includes one preset: `seedance-human-1080p`, using Proteus v4 (`prob-4`), source FPS, aspect-preserving 1080p output, and NVIDIA H.264 encoding.
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Configuration is stored outside the package:
|
|
76
|
+
|
|
77
|
+
- Windows: `%APPDATA%\topazlabscli\config.json`
|
|
78
|
+
- macOS/Linux: `${XDG_CONFIG_HOME:-~/.config}/topazlabscli/config.json`
|
|
79
|
+
- Override for testing or automation: `TOPAZLABSCLI_CONFIG`
|
|
80
|
+
|
|
81
|
+
Do not publish configuration files, keys, internal addresses, media, Topaz model files, or authentication data.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from "../src/cli.mjs";
|
|
3
|
+
|
|
4
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
5
|
+
const json = process.argv.includes("--json");
|
|
6
|
+
const payload = {
|
|
7
|
+
ok: false,
|
|
8
|
+
error: {
|
|
9
|
+
code: error.code || "UNEXPECTED_ERROR",
|
|
10
|
+
message: error.message || String(error),
|
|
11
|
+
details: error.details || null
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
if (json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
15
|
+
else process.stderr.write(`[${payload.error.code}] ${payload.error.message}\n`);
|
|
16
|
+
process.exitCode = Number.isInteger(error.exitCode) ? error.exitCode : 1;
|
|
17
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@petercjl/topazlabscli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cross-Agent CLI and portable Skill for queued remote Topaz Video AI processing",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"topazlabscli": "bin/topazlabscli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"worker",
|
|
13
|
+
"skill",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"check": "node --check bin/topazlabscli.mjs && node --check src/cli.mjs && npm test"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public",
|
|
26
|
+
"registry": "https://registry.npmjs.org/"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/petercjl/topazlabscli.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/petercjl/topazlabscli#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/petercjl/topazlabscli/issues"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"keywords": [
|
|
38
|
+
"topaz",
|
|
39
|
+
"video-ai",
|
|
40
|
+
"ssh",
|
|
41
|
+
"gpu",
|
|
42
|
+
"agent-skill"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: topazlabscli
|
|
3
|
+
description: Operate a configured remote Windows Topaz Video AI workstation through the stable topazlabscli CLI. Use for connection checks, Proteus model readiness, queued 480p-to-1080p enhancement jobs, status, download, cancellation, and installation diagnostics. Do not use for installing or licensing Topaz itself.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# TopazLabs CLI
|
|
7
|
+
|
|
8
|
+
Use the CLI as the single execution surface. Do not reproduce SSH, SFTP, queue, or Topaz FFmpeg commands manually when the CLI supports the operation.
|
|
9
|
+
|
|
10
|
+
## Input → Strategy → Output
|
|
11
|
+
|
|
12
|
+
- Input: a local video path or a job identifier, the user's requested action, and an already configured target.
|
|
13
|
+
- Strategy: inspect capabilities and health, select the configured reachable endpoint, submit one deterministic preset to the serialized Windows queue, observe terminal state, and download only when requested.
|
|
14
|
+
- Output: structured CLI evidence, a job identifier and state, and for completed processing an explicitly requested local output file.
|
|
15
|
+
|
|
16
|
+
## Main Line
|
|
17
|
+
|
|
18
|
+
1. Run `topazlabscli capabilities --json` when the live contract is not already known.
|
|
19
|
+
2. Run `topazlabscli doctor --json` before the first job in a conversation or after a connection/model failure.
|
|
20
|
+
3. For a processing request, confirm the input exists and the output path is authorized.
|
|
21
|
+
4. Prefer `topazlabscli process INPUT --output OUTPUT --json` for submit, wait, and download as one operation. Use separate `job` commands when the user wants asynchronous control.
|
|
22
|
+
5. Inspect the final JSON. Completion requires `state=completed`, a successful download result, and a real local output file.
|
|
23
|
+
6. Report the selected target/endpoint, job ID, output path, preset/model, dimensions, and any warning or failure.
|
|
24
|
+
|
|
25
|
+
## Preset Boundary
|
|
26
|
+
|
|
27
|
+
`seedance-human-1080p` is the only built-in preset in version 0.1. It uses Proteus v4 (`prob-4`), preserves source FPS and aspect ratio, targets a 1080-pixel short edge, and relies on the remote queue's single concurrency slot. Do not silently substitute another model, frame interpolation, stabilization, motion deblur, or 4K output.
|
|
28
|
+
|
|
29
|
+
## Branches
|
|
30
|
+
|
|
31
|
+
- Connection failure: report `CONNECTION_FAILED` and the endpoint attempts. The CLI does not start, reconfigure, or grant access to a VPN.
|
|
32
|
+
- Worker/model not ready: run `worker status` or `model status`; stop with the returned dependency error. Installing/licensing Topaz and downloading models remain GUI administration tasks.
|
|
33
|
+
- Long-running work: use `job submit`, return the job ID, then `job wait` when the user asks to remain attached. Do not resubmit merely because a wait timed out.
|
|
34
|
+
- Cancellation: queued work may cancel immediately. A running task records a cancellation request but is not forcibly killed in version 0.1.
|
|
35
|
+
- Missing capability: return `CAPABILITY_UNAVAILABLE` or the CLI's structured error. Do not invent a platform-specific workaround.
|
|
36
|
+
|
|
37
|
+
## Configuration and Safety
|
|
38
|
+
|
|
39
|
+
Configuration, hostnames, addresses, usernames, SSH identities, VPN details, media, Topaz binaries, models, and credentials are external to this Skill and npm package. Installation does not grant access to a workstation. Treat the configured server and Topaz license as user-managed resources.
|
|
40
|
+
|
|
41
|
+
Do not overwrite a local output unless the user has authorized that exact existing target. The remote worker retains job inputs, outputs, status, and logs for operator review; cleanup is an administrative action outside version 0.1.
|
|
42
|
+
|
|
43
|
+
## Skill Management
|
|
44
|
+
|
|
45
|
+
The npm package is the canonical source. Discover it with `topazlabscli skill source --json`; use `skill status`, `skill install`, and `skill update` for Codex and SealSeek targets. Do not edit installed links or copies as independent sources.
|
|
46
|
+
|
|
47
|
+
## QA and Evolution
|
|
48
|
+
|
|
49
|
+
Use `doctor --json` plus the final job status as runtime evidence. Treat Codex and SealSeek adapters as `implemented` until each has a recorded real runtime test. New models, presets, cleanup rules, or worker behavior require an authorized package update with CLI, worker, Skill, capability, and test changes together.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "portable-skill-adapter",
|
|
3
|
+
"schema_version": "1.0.0",
|
|
4
|
+
"platform": "codex",
|
|
5
|
+
"mappings": [
|
|
6
|
+
{
|
|
7
|
+
"capability_id": "command.execute",
|
|
8
|
+
"implementation": { "kind": "cli", "command": "topazlabscli" },
|
|
9
|
+
"features": ["local-process", "stdout-json", "exit-code"],
|
|
10
|
+
"status": "tested",
|
|
11
|
+
"tested_date": "2026-09-20",
|
|
12
|
+
"evidence": ["Codex invoked topazlabscli process --json through the local terminal and received a completed remote job contract."],
|
|
13
|
+
"configuration": ["topazlabscli must resolve from PATH"],
|
|
14
|
+
"permissions": ["local process execution", "network access to configured SSH target"],
|
|
15
|
+
"normalization": "Invoke with --json and consume {ok,data,error}."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"capability_id": "filesystem.local",
|
|
19
|
+
"implementation": { "kind": "agent-native", "name": "Codex filesystem tools" },
|
|
20
|
+
"features": ["read-file", "write-file", "inspect-metadata"],
|
|
21
|
+
"status": "tested",
|
|
22
|
+
"tested_date": "2026-09-20",
|
|
23
|
+
"evidence": ["Codex verified the downloaded MP4 with local ffprobe after the end-to-end job completed."],
|
|
24
|
+
"configuration": [],
|
|
25
|
+
"permissions": ["input and output paths must be within the user's authorized scope"],
|
|
26
|
+
"normalization": "Return absolute local paths and verified metadata."
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "portable-skill-adapter",
|
|
3
|
+
"schema_version": "1.0.0",
|
|
4
|
+
"platform": "sealseek",
|
|
5
|
+
"mappings": [
|
|
6
|
+
{
|
|
7
|
+
"capability_id": "command.execute",
|
|
8
|
+
"implementation": { "kind": "cli", "command": "topazlabscli" },
|
|
9
|
+
"features": ["local-process", "stdout-json", "exit-code"],
|
|
10
|
+
"status": "implemented",
|
|
11
|
+
"configuration": ["topazlabscli must resolve from PATH"],
|
|
12
|
+
"permissions": ["local process execution", "network access to configured SSH target"],
|
|
13
|
+
"normalization": "Invoke with --json and consume {ok,data,error}."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"capability_id": "filesystem.local",
|
|
17
|
+
"implementation": { "kind": "agent-native", "name": "SealSeek/OpenClaw filesystem capability" },
|
|
18
|
+
"features": ["read-file", "write-file", "inspect-metadata"],
|
|
19
|
+
"status": "implemented",
|
|
20
|
+
"configuration": [],
|
|
21
|
+
"permissions": ["input and output paths must be within the user's authorized scope"],
|
|
22
|
+
"normalization": "Return absolute local paths and verified metadata."
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "portable-skill-capabilities",
|
|
3
|
+
"schema_version": "1.0.0",
|
|
4
|
+
"skill": "topazlabscli",
|
|
5
|
+
"target_platforms": ["codex", "sealseek"],
|
|
6
|
+
"capabilities": [
|
|
7
|
+
{
|
|
8
|
+
"id": "command.execute",
|
|
9
|
+
"purpose": "Run the stable topazlabscli executable and parse JSON results.",
|
|
10
|
+
"required": true,
|
|
11
|
+
"required_features": ["local-process", "stdout-json", "exit-code"],
|
|
12
|
+
"input_fields": ["argv"],
|
|
13
|
+
"output_fields": ["ok", "data", "error"],
|
|
14
|
+
"side_effects": {
|
|
15
|
+
"external_mutation": "May upload videos and enqueue remote processing only when the user requests processing.",
|
|
16
|
+
"may_cost_money": false,
|
|
17
|
+
"authorization": "Requires the user's configured SSH access and an explicit processing request."
|
|
18
|
+
},
|
|
19
|
+
"standard_errors": ["CAPABILITY_UNAVAILABLE", "AUTH_REQUIRED", "PERMISSION_REQUIRED", "PROVIDER_FAILURE", "OUTPUT_CONTRACT_FAILED"]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "filesystem.local",
|
|
23
|
+
"purpose": "Read input videos and write downloaded outputs.",
|
|
24
|
+
"required": true,
|
|
25
|
+
"required_features": ["read-file", "write-file", "inspect-metadata"],
|
|
26
|
+
"input_fields": ["path"],
|
|
27
|
+
"output_fields": ["exists", "path", "size"],
|
|
28
|
+
"side_effects": {
|
|
29
|
+
"external_mutation": "Writes only to the output path requested by the user.",
|
|
30
|
+
"may_cost_money": false,
|
|
31
|
+
"authorization": "The path must be within the user's authorized filesystem scope."
|
|
32
|
+
},
|
|
33
|
+
"standard_errors": ["CAPABILITY_UNAVAILABLE", "PERMISSION_REQUIRED", "OUTPUT_CONTRACT_FAILED"]
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { loadConfig, saveConfig, resolveTarget } from "./config.mjs";
|
|
6
|
+
import { configPath, workerScript } from "./paths.mjs";
|
|
7
|
+
import { CliError, requireValue } from "./errors.mjs";
|
|
8
|
+
import { run } from "./process.mjs";
|
|
9
|
+
import { psLiteral, runPowerShell, selectEndpoint, sftpGet, sftpPut, startPowerShellDetached } from "./ssh.mjs";
|
|
10
|
+
import { skillInstall, skillSource, skillStatus } from "./skill.mjs";
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const pkg = require("../package.json");
|
|
14
|
+
const PRESET = "seedance-human-1080p";
|
|
15
|
+
|
|
16
|
+
const CAPABILITIES = {
|
|
17
|
+
schema_version: 1,
|
|
18
|
+
package: pkg.name,
|
|
19
|
+
version: pkg.version,
|
|
20
|
+
commands: ["version", "capabilities", "doctor", "target", "connection", "worker", "model", "job", "process", "skill", "update"],
|
|
21
|
+
presets: [{ id: PRESET, model: "prob-4", output: "aspect-preserving 1080p", fps: "source", concurrency: 1 }],
|
|
22
|
+
agents: { codex: "tested", sealseek: "implemented" },
|
|
23
|
+
worker_os: ["windows"],
|
|
24
|
+
transport: ["ssh", "sftp"]
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function option(args, name, { multiple = false } = {}) {
|
|
28
|
+
const values = [];
|
|
29
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
30
|
+
if (args[i] === name) {
|
|
31
|
+
if (i + 1 >= args.length) throw new CliError("ARGUMENT_REQUIRED", `${name} requires a value.`);
|
|
32
|
+
values.push(args[i + 1]);
|
|
33
|
+
args.splice(i, 2);
|
|
34
|
+
i -= 1;
|
|
35
|
+
} else if (args[i].startsWith(`${name}=`)) {
|
|
36
|
+
values.push(args[i].slice(name.length + 1));
|
|
37
|
+
args.splice(i, 1);
|
|
38
|
+
i -= 1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return multiple ? values : values.at(-1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function flag(args, name) {
|
|
45
|
+
const index = args.indexOf(name);
|
|
46
|
+
if (index < 0) return false;
|
|
47
|
+
args.splice(index, 1);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function output(value, json) {
|
|
52
|
+
if (json) process.stdout.write(`${JSON.stringify({ ok: true, data: value }, null, 2)}\n`);
|
|
53
|
+
else if (typeof value === "string") process.stdout.write(`${value}\n`);
|
|
54
|
+
else process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function help() {
|
|
58
|
+
return `topazlabscli ${pkg.version}\n\n` +
|
|
59
|
+
`Commands:\n` +
|
|
60
|
+
` version | capabilities | doctor\n` +
|
|
61
|
+
` target add <name> --endpoint <label=host>... --user <user> [--identity <path>] [--workspace <windows-path>] [--default]\n` +
|
|
62
|
+
` target list\n` +
|
|
63
|
+
` connection check [--target <name>]\n` +
|
|
64
|
+
` worker install|status [--target <name>]\n` +
|
|
65
|
+
` model status [--target <name>]\n` +
|
|
66
|
+
` job submit <video> [--target <name>] [--preset ${PRESET}]\n` +
|
|
67
|
+
` job list [--target <name>]\n` +
|
|
68
|
+
` job status|wait|download|cancel <job-id> [--target <name>] [--output <path>]\n` +
|
|
69
|
+
` process <video> --output <path> [--target <name>]\n` +
|
|
70
|
+
` skill source|status|install|update [--agent codex|sealseek|all] [--copy]\n` +
|
|
71
|
+
` update\n\nUse --json for machine-readable output.`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseEndpoint(value) {
|
|
75
|
+
const split = value.indexOf("=");
|
|
76
|
+
const name = split > 0 ? value.slice(0, split) : "default";
|
|
77
|
+
const address = split > 0 ? value.slice(split + 1) : value;
|
|
78
|
+
const match = address.match(/^(.+?)(?::(\d+))?$/);
|
|
79
|
+
if (!match || !match[1]) throw new CliError("ENDPOINT_INVALID", `Invalid endpoint: ${value}`);
|
|
80
|
+
return { name, host: match[1], ...(match[2] ? { port: Number(match[2]) } : {}) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function remoteRoot(target) {
|
|
84
|
+
return target.workspace || "E:\\topazlab_workspace";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function workerInvocation(root, action, extra = "") {
|
|
88
|
+
const script = `${root}\\.topazlabscli\\worker\\topazlabs-worker.ps1`;
|
|
89
|
+
return `& ${psLiteral(script)} -Action ${psLiteral(action)} -Root ${psLiteral(root)} ${extra}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseRemoteJson(result, code = "REMOTE_ERROR") {
|
|
93
|
+
if (result.code !== 0) throw new CliError(code, result.stderr.trim() || result.stdout.trim() || "Remote command failed.");
|
|
94
|
+
const text = result.stdout.trim();
|
|
95
|
+
try { return JSON.parse(text); }
|
|
96
|
+
catch { throw new CliError("REMOTE_OUTPUT_INVALID", "Remote worker did not return valid JSON.", { output: text }); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function remoteAction(target, endpoint, action, params = {}) {
|
|
100
|
+
const encoded = Buffer.from(JSON.stringify(params), "utf8").toString("base64");
|
|
101
|
+
const extra = Object.keys(params).length ? `-PayloadBase64 ${psLiteral(encoded)}` : "";
|
|
102
|
+
return parseRemoteJson(await runPowerShell(target, endpoint, workerInvocation(remoteRoot(target), action, extra)));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function getConnectedTarget(requested) {
|
|
106
|
+
const target = resolveTarget(loadConfig({ required: true }), requested);
|
|
107
|
+
const selected = await selectEndpoint(target);
|
|
108
|
+
return { target, ...selected };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function submitJob(inputPath, requestedTarget, preset = PRESET) {
|
|
112
|
+
const input = path.resolve(inputPath);
|
|
113
|
+
if (!fs.existsSync(input) || !fs.statSync(input).isFile()) throw new CliError("INPUT_NOT_FOUND", `Video not found: ${input}`);
|
|
114
|
+
if (preset !== PRESET) throw new CliError("PRESET_UNSUPPORTED", `Unsupported preset: ${preset}`);
|
|
115
|
+
const { target, endpoint, attempts } = await getConnectedTarget(requestedTarget);
|
|
116
|
+
const id = `${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${crypto.randomBytes(3).toString("hex")}`;
|
|
117
|
+
const safeName = path.basename(input).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
118
|
+
const prepared = await remoteAction(target, endpoint, "Prepare", { id, input_name: safeName });
|
|
119
|
+
await sftpPut(target, endpoint, input, prepared.input_path);
|
|
120
|
+
const job = await remoteAction(target, endpoint, "Enqueue", { id, input_name: safeName, preset });
|
|
121
|
+
const runnerPid = startPowerShellDetached(target, endpoint, workerInvocation(remoteRoot(target), "Run"));
|
|
122
|
+
return { ...job, target: target.name, endpoint: endpoint.name, runner_pid: runnerPid, connection_attempts: attempts };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function waitJob(id, requestedTarget, intervalSeconds = 10, timeoutSeconds = 86400) {
|
|
126
|
+
const { target, endpoint } = await getConnectedTarget(requestedTarget);
|
|
127
|
+
const started = Date.now();
|
|
128
|
+
while (true) {
|
|
129
|
+
const status = await remoteAction(target, endpoint, "JobStatus", { id });
|
|
130
|
+
if (["completed", "failed", "cancelled"].includes(status.state)) return { ...status, target: target.name, endpoint: endpoint.name };
|
|
131
|
+
if ((Date.now() - started) / 1000 > timeoutSeconds) throw new CliError("WAIT_TIMEOUT", `Timed out waiting for job ${id}.`);
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function downloadJob(id, requestedTarget, outputPath) {
|
|
137
|
+
const { target, endpoint } = await getConnectedTarget(requestedTarget);
|
|
138
|
+
const status = await remoteAction(target, endpoint, "JobStatus", { id });
|
|
139
|
+
if (status.state !== "completed") throw new CliError("JOB_NOT_COMPLETE", `Job ${id} is ${status.state}.`);
|
|
140
|
+
const destination = path.resolve(outputPath || status.output_name || `${id}.mp4`);
|
|
141
|
+
if (fs.existsSync(destination)) throw new CliError("OUTPUT_EXISTS", `Refusing to overwrite existing output: ${destination}`);
|
|
142
|
+
await sftpGet(target, endpoint, status.output_path, destination);
|
|
143
|
+
return { id, target: target.name, endpoint: endpoint.name, output: destination };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function doctor(requestedTarget) {
|
|
147
|
+
const checks = [];
|
|
148
|
+
for (const command of ["ssh", "sftp", "node", "npm"]) {
|
|
149
|
+
const args = command === "node" || command === "npm" ? ["--version"] : command === "sftp" ? ["-h"] : ["-V"];
|
|
150
|
+
const result = await run(command, args);
|
|
151
|
+
const detail = (result.stdout || result.stderr).trim().split("\n")[0];
|
|
152
|
+
const ok = result.code === 0 || (command === "sftp" && /usage:\s*sftp/i.test(result.stderr));
|
|
153
|
+
checks.push({ id: `local.${command}`, ok, detail });
|
|
154
|
+
}
|
|
155
|
+
checks.push({ id: "config", ok: fs.existsSync(configPath()), detail: configPath() });
|
|
156
|
+
if (requestedTarget || fs.existsSync(configPath())) {
|
|
157
|
+
try {
|
|
158
|
+
const { target, endpoint } = await getConnectedTarget(requestedTarget);
|
|
159
|
+
checks.push({ id: "connection", ok: true, detail: `${target.name}/${endpoint.name}` });
|
|
160
|
+
try {
|
|
161
|
+
const status = await remoteAction(target, endpoint, "Status");
|
|
162
|
+
checks.push({ id: "worker", ok: Boolean(status.installed), detail: status });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
checks.push({ id: "worker", ok: false, detail: error.message });
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
checks.push({ id: "connection", ok: false, detail: error.details || error.message });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { ok: checks.every((item) => item.ok), checks };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function main(rawArgs) {
|
|
174
|
+
const args = [...rawArgs];
|
|
175
|
+
const json = flag(args, "--json");
|
|
176
|
+
if (args.length === 0 || ["help", "--help", "-h"].includes(args[0])) return output(help(), false);
|
|
177
|
+
const command = args.shift();
|
|
178
|
+
if (command === "version" || command === "--version" || command === "-V") return output(pkg.version, json);
|
|
179
|
+
if (command === "capabilities") return output(CAPABILITIES, json);
|
|
180
|
+
if (command === "doctor") return output(await doctor(option(args, "--target")), json);
|
|
181
|
+
|
|
182
|
+
if (command === "target") {
|
|
183
|
+
const action = args.shift();
|
|
184
|
+
if (action === "list") return output({ path: configPath(), ...loadConfig() }, json);
|
|
185
|
+
if (action === "add") {
|
|
186
|
+
const name = requireValue(args.shift(), "TARGET_REQUIRED", "target add requires a name.");
|
|
187
|
+
const endpoints = option(args, "--endpoint", { multiple: true }).map(parseEndpoint);
|
|
188
|
+
if (endpoints.length === 0) throw new CliError("ENDPOINT_REQUIRED", "Provide at least one --endpoint label=host.");
|
|
189
|
+
const config = loadConfig();
|
|
190
|
+
config.targets[name] = {
|
|
191
|
+
user: option(args, "--user") || null,
|
|
192
|
+
identity_file: option(args, "--identity") || null,
|
|
193
|
+
workspace: option(args, "--workspace") || "E:\\topazlab_workspace",
|
|
194
|
+
endpoints
|
|
195
|
+
};
|
|
196
|
+
if (flag(args, "--default") || !config.default_target) config.default_target = name;
|
|
197
|
+
const saved = saveConfig(config);
|
|
198
|
+
return output({ name, path: saved, target: config.targets[name], default: config.default_target === name }, json);
|
|
199
|
+
}
|
|
200
|
+
throw new CliError("COMMAND_UNKNOWN", `Unknown target action: ${action || ""}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (command === "connection" && args.shift() === "check") {
|
|
204
|
+
const { target, endpoint, attempts } = await getConnectedTarget(option(args, "--target"));
|
|
205
|
+
return output({ target: target.name, selected: endpoint, attempts }, json);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (command === "worker") {
|
|
209
|
+
const action = args.shift();
|
|
210
|
+
const requested = option(args, "--target");
|
|
211
|
+
const { target, endpoint, attempts } = await getConnectedTarget(requested);
|
|
212
|
+
if (action === "install") {
|
|
213
|
+
const root = remoteRoot(target);
|
|
214
|
+
const remote = `${root}\\.topazlabscli\\worker\\topazlabs-worker.ps1`;
|
|
215
|
+
const workerDirectory = `${root}\\.topazlabscli\\worker`;
|
|
216
|
+
const prep = `$p=${psLiteral(workerDirectory)}; New-Item -ItemType Directory -Force -Path $p | Out-Null; [Console]::Out.Write('{"ok":true}')`;
|
|
217
|
+
parseRemoteJson(await runPowerShell(target, endpoint, prep));
|
|
218
|
+
await sftpPut(target, endpoint, workerScript, remote);
|
|
219
|
+
const installed = await remoteAction(target, endpoint, "Install");
|
|
220
|
+
return output({ ...installed, target: target.name, endpoint: endpoint.name, connection_attempts: attempts }, json);
|
|
221
|
+
}
|
|
222
|
+
if (action === "status") return output(await remoteAction(target, endpoint, "Status"), json);
|
|
223
|
+
throw new CliError("COMMAND_UNKNOWN", `Unknown worker action: ${action || ""}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (command === "model" && args.shift() === "status") {
|
|
227
|
+
const { target, endpoint } = await getConnectedTarget(option(args, "--target"));
|
|
228
|
+
const status = await remoteAction(target, endpoint, "Status");
|
|
229
|
+
return output({ model: "prob-4", ready: status.model_ready, definitions: status.model_definitions, weights: status.model_weights }, json);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (command === "job") {
|
|
233
|
+
const action = args.shift();
|
|
234
|
+
const requested = option(args, "--target");
|
|
235
|
+
if (action === "submit") return output(await submitJob(requireValue(args.shift(), "INPUT_REQUIRED", "job submit requires a video."), requested, option(args, "--preset") || PRESET), json);
|
|
236
|
+
if (action === "list") {
|
|
237
|
+
const { target, endpoint } = await getConnectedTarget(requested);
|
|
238
|
+
return output(await remoteAction(target, endpoint, "ListJobs"), json);
|
|
239
|
+
}
|
|
240
|
+
const id = requireValue(args.shift(), "JOB_ID_REQUIRED", `job ${action || ""} requires a job id.`);
|
|
241
|
+
if (action === "status") {
|
|
242
|
+
const { target, endpoint } = await getConnectedTarget(requested);
|
|
243
|
+
return output(await remoteAction(target, endpoint, "JobStatus", { id }), json);
|
|
244
|
+
}
|
|
245
|
+
if (action === "wait") return output(await waitJob(id, requested, Number(option(args, "--interval") || 10), Number(option(args, "--timeout") || 86400)), json);
|
|
246
|
+
if (action === "download") return output(await downloadJob(id, requested, option(args, "--output")), json);
|
|
247
|
+
if (action === "cancel") {
|
|
248
|
+
const { target, endpoint } = await getConnectedTarget(requested);
|
|
249
|
+
return output(await remoteAction(target, endpoint, "Cancel", { id }), json);
|
|
250
|
+
}
|
|
251
|
+
throw new CliError("COMMAND_UNKNOWN", `Unknown job action: ${action || ""}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (command === "process") {
|
|
255
|
+
const input = requireValue(args.shift(), "INPUT_REQUIRED", "process requires a video.");
|
|
256
|
+
const destination = requireValue(option(args, "--output"), "OUTPUT_REQUIRED", "process requires --output.");
|
|
257
|
+
const requested = option(args, "--target");
|
|
258
|
+
const submitted = await submitJob(input, requested, option(args, "--preset") || PRESET);
|
|
259
|
+
const finished = await waitJob(submitted.id, requested, Number(option(args, "--interval") || 10), Number(option(args, "--timeout") || 86400));
|
|
260
|
+
if (finished.state !== "completed") throw new CliError("JOB_FAILED", `Job ${submitted.id} ended as ${finished.state}.`, finished);
|
|
261
|
+
return output({ submitted, finished, downloaded: await downloadJob(submitted.id, requested, destination) }, json);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (command === "skill") {
|
|
265
|
+
const action = args.shift();
|
|
266
|
+
const agent = option(args, "--agent") || "all";
|
|
267
|
+
if (action === "source") return output(skillSource(), json);
|
|
268
|
+
if (action === "status") return output(skillStatus(agent), json);
|
|
269
|
+
if (action === "install") return output(skillInstall(agent, flag(args, "--copy") ? "copy" : "link", false), json);
|
|
270
|
+
if (action === "update") return output(skillInstall(agent, flag(args, "--copy") ? "copy" : "link", true), json);
|
|
271
|
+
throw new CliError("COMMAND_UNKNOWN", `Unknown skill action: ${action || ""}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (command === "update") {
|
|
275
|
+
const previousSkills = skillStatus("all");
|
|
276
|
+
const result = await run("npm", ["install", "-g", `${pkg.name}@latest`]);
|
|
277
|
+
if (result.code !== 0) throw new CliError("UPDATE_FAILED", result.stderr.trim() || "npm update failed.");
|
|
278
|
+
const skills = [];
|
|
279
|
+
for (const existing of previousSkills.filter((item) => item.installed)) {
|
|
280
|
+
skills.push(...skillInstall(existing.agent, existing.mode || "link", true));
|
|
281
|
+
}
|
|
282
|
+
return output({ package: pkg.name, updated: true, skills, detail: result.stdout.trim() }, json);
|
|
283
|
+
}
|
|
284
|
+
throw new CliError("COMMAND_UNKNOWN", `Unknown command: ${command}`);
|
|
285
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { configPath } from "./paths.mjs";
|
|
4
|
+
import { CliError } from "./errors.mjs";
|
|
5
|
+
|
|
6
|
+
export function emptyConfig() {
|
|
7
|
+
return { schema_version: 1, default_target: null, targets: {} };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function loadConfig({ required = false } = {}) {
|
|
11
|
+
const filename = configPath();
|
|
12
|
+
if (!fs.existsSync(filename)) {
|
|
13
|
+
if (required) throw new CliError("CONFIG_NOT_FOUND", `Configuration not found: ${filename}`);
|
|
14
|
+
return emptyConfig();
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(fs.readFileSync(filename, "utf8"));
|
|
18
|
+
if (parsed.schema_version !== 1 || typeof parsed.targets !== "object") {
|
|
19
|
+
throw new Error("unsupported configuration schema");
|
|
20
|
+
}
|
|
21
|
+
return parsed;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
throw new CliError("CONFIG_INVALID", `Cannot read configuration: ${error.message}`, { path: filename });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function saveConfig(config) {
|
|
28
|
+
const filename = configPath();
|
|
29
|
+
fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 });
|
|
30
|
+
if (fs.existsSync(filename)) fs.copyFileSync(filename, `${filename}.bak`);
|
|
31
|
+
const temporary = `${filename}.${process.pid}.tmp`;
|
|
32
|
+
fs.writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
33
|
+
fs.renameSync(temporary, filename);
|
|
34
|
+
try { fs.chmodSync(filename, 0o600); } catch {}
|
|
35
|
+
return filename;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveTarget(config, requested) {
|
|
39
|
+
const name = requested || config.default_target;
|
|
40
|
+
if (!name) throw new CliError("TARGET_REQUIRED", "Specify --target or configure a default target.");
|
|
41
|
+
const target = config.targets[name];
|
|
42
|
+
if (!target) throw new CliError("TARGET_NOT_FOUND", `Unknown target: ${name}`);
|
|
43
|
+
if (!Array.isArray(target.endpoints) || target.endpoints.length === 0) {
|
|
44
|
+
throw new CliError("TARGET_INVALID", `Target ${name} has no endpoints.`);
|
|
45
|
+
}
|
|
46
|
+
return { name, ...target };
|
|
47
|
+
}
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
constructor(code, message, details = null, exitCode = 1) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "CliError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.details = details;
|
|
7
|
+
this.exitCode = exitCode;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function requireValue(value, code, message) {
|
|
12
|
+
if (value === undefined || value === null || value === "") {
|
|
13
|
+
throw new CliError(code, message);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
package/src/paths.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
export const packageRoot = path.resolve(here, "..");
|
|
7
|
+
export const bundledSkill = path.join(packageRoot, "skill", "topazlabscli");
|
|
8
|
+
export const workerScript = path.join(packageRoot, "worker", "windows", "topazlabs-worker.ps1");
|
|
9
|
+
|
|
10
|
+
export function configPath() {
|
|
11
|
+
if (process.env.TOPAZLABSCLI_CONFIG) return path.resolve(process.env.TOPAZLABSCLI_CONFIG);
|
|
12
|
+
const base = process.platform === "win32"
|
|
13
|
+
? (process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"))
|
|
14
|
+
: (process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"));
|
|
15
|
+
return path.join(base, "topazlabscli", "config.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function skillTarget(agent) {
|
|
19
|
+
const home = os.homedir();
|
|
20
|
+
if (agent === "codex") {
|
|
21
|
+
return path.join(process.env.CODEX_HOME || path.join(home, ".codex"), "skills", "topazlabscli");
|
|
22
|
+
}
|
|
23
|
+
if (agent === "sealseek") {
|
|
24
|
+
return path.join(process.env.SEALSEEK_SKILLS_HOME || path.join(home, ".agents", "skills"), "topazlabscli");
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`Unknown Agent: ${agent}`);
|
|
27
|
+
}
|
package/src/process.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function run(command, args, options = {}) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const child = spawn(command, args, {
|
|
6
|
+
cwd: options.cwd,
|
|
7
|
+
env: options.env || process.env,
|
|
8
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
9
|
+
windowsHide: true
|
|
10
|
+
});
|
|
11
|
+
let stdout = "";
|
|
12
|
+
let stderr = "";
|
|
13
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
14
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
15
|
+
child.on("error", reject);
|
|
16
|
+
child.on("close", (code, signal) => resolve({ code, signal, stdout, stderr }));
|
|
17
|
+
if (options.input !== undefined) {
|
|
18
|
+
child.stdin.end(options.input);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
package/src/skill.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { bundledSkill, skillTarget } from "./paths.mjs";
|
|
4
|
+
import { CliError } from "./errors.mjs";
|
|
5
|
+
|
|
6
|
+
const agents = ["codex", "sealseek"];
|
|
7
|
+
|
|
8
|
+
export function skillSource() {
|
|
9
|
+
return { path: bundledSkill, exists: fs.existsSync(path.join(bundledSkill, "SKILL.md")) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function skillStatus(selected = "all") {
|
|
13
|
+
const names = selected === "all" ? agents : [selected];
|
|
14
|
+
return names.map((agent) => {
|
|
15
|
+
const target = skillTarget(agent);
|
|
16
|
+
let installed = false;
|
|
17
|
+
let mode = null;
|
|
18
|
+
if (fs.existsSync(target)) {
|
|
19
|
+
installed = true;
|
|
20
|
+
mode = fs.lstatSync(target).isSymbolicLink() ? "link" : "copy";
|
|
21
|
+
}
|
|
22
|
+
return { agent, target, installed, mode, source: bundledSkill };
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function installOne(agent, mode, update) {
|
|
27
|
+
const target = skillTarget(agent);
|
|
28
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
29
|
+
if (fs.existsSync(target)) {
|
|
30
|
+
if (!update) throw new CliError("SKILL_ALREADY_INSTALLED", `Skill target already exists: ${target}`);
|
|
31
|
+
const stat = fs.lstatSync(target);
|
|
32
|
+
if (stat.isSymbolicLink()) fs.unlinkSync(target);
|
|
33
|
+
else fs.rmSync(target, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
if (mode === "copy") fs.cpSync(bundledSkill, target, { recursive: true });
|
|
36
|
+
else fs.symlinkSync(bundledSkill, target, process.platform === "win32" ? "junction" : "dir");
|
|
37
|
+
return { agent, target, mode };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function skillInstall(selected = "all", mode = "link", update = false) {
|
|
41
|
+
const names = selected === "all" ? agents : [selected];
|
|
42
|
+
for (const name of names) if (!agents.includes(name)) throw new CliError("AGENT_UNSUPPORTED", `Unsupported Agent: ${name}`);
|
|
43
|
+
return names.map((name) => installOne(name, mode, update));
|
|
44
|
+
}
|
package/src/ssh.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { run } from "./process.mjs";
|
|
5
|
+
import { CliError } from "./errors.mjs";
|
|
6
|
+
|
|
7
|
+
function destination(target, endpoint) {
|
|
8
|
+
return target.user ? `${target.user}@${endpoint.host}` : endpoint.host;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function commonArgs(target, endpoint, { timeout = 7 } = {}) {
|
|
12
|
+
const args = ["-o", "BatchMode=yes", "-o", `ConnectTimeout=${timeout}`];
|
|
13
|
+
if (target.identity_file) args.push("-i", target.identity_file);
|
|
14
|
+
if (endpoint.port) args.push("-p", String(endpoint.port));
|
|
15
|
+
return args;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function encodePowerShell(script) {
|
|
19
|
+
return Buffer.from(script, "utf16le").toString("base64");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runPowerShell(target, endpoint, script, { timeout = 7 } = {}) {
|
|
23
|
+
const args = [...commonArgs(target, endpoint, { timeout }), destination(target, endpoint),
|
|
24
|
+
"powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(script)];
|
|
25
|
+
return run("ssh", args);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function startPowerShellDetached(target, endpoint, script) {
|
|
29
|
+
const args = [...commonArgs(target, endpoint, { timeout: 10 }), destination(target, endpoint),
|
|
30
|
+
"powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(script)];
|
|
31
|
+
const child = spawn("ssh", args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
32
|
+
child.unref();
|
|
33
|
+
return child.pid;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function selectEndpoint(target) {
|
|
37
|
+
const attempts = [];
|
|
38
|
+
for (const endpoint of target.endpoints) {
|
|
39
|
+
const result = await runPowerShell(target, endpoint, "[Console]::Out.Write('TOPAZLABSCLI_OK')");
|
|
40
|
+
const ok = result.code === 0 && result.stdout.includes("TOPAZLABSCLI_OK");
|
|
41
|
+
attempts.push({ name: endpoint.name, host: endpoint.host, ok, error: ok ? null : result.stderr.trim() });
|
|
42
|
+
if (ok) return { endpoint, attempts };
|
|
43
|
+
}
|
|
44
|
+
throw new CliError("CONNECTION_FAILED", `No endpoint is reachable for target ${target.name}.`, { attempts });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function quoteSftp(value) {
|
|
48
|
+
return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function windowsToSftp(remotePath) {
|
|
52
|
+
const normalized = String(remotePath).replaceAll("\\", "/");
|
|
53
|
+
if (/^[A-Za-z]:\//.test(normalized)) return `/${normalized}`;
|
|
54
|
+
return normalized;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function sftpPut(target, endpoint, localPath, remotePath) {
|
|
58
|
+
if (!fs.existsSync(localPath)) throw new CliError("INPUT_NOT_FOUND", `Local file not found: ${localPath}`);
|
|
59
|
+
const args = ["-b", "-", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10"];
|
|
60
|
+
if (target.identity_file) args.push("-i", target.identity_file);
|
|
61
|
+
if (endpoint.port) args.push("-P", String(endpoint.port));
|
|
62
|
+
args.push(destination(target, endpoint));
|
|
63
|
+
const input = `put ${quoteSftp(path.resolve(localPath))} ${quoteSftp(windowsToSftp(remotePath))}\n`;
|
|
64
|
+
const result = await run("sftp", args, { input });
|
|
65
|
+
if (result.code !== 0) throw new CliError("UPLOAD_FAILED", result.stderr.trim() || "SFTP upload failed.");
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function sftpGet(target, endpoint, remotePath, localPath) {
|
|
70
|
+
fs.mkdirSync(path.dirname(path.resolve(localPath)), { recursive: true });
|
|
71
|
+
const args = ["-b", "-", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10"];
|
|
72
|
+
if (target.identity_file) args.push("-i", target.identity_file);
|
|
73
|
+
if (endpoint.port) args.push("-P", String(endpoint.port));
|
|
74
|
+
args.push(destination(target, endpoint));
|
|
75
|
+
const input = `get ${quoteSftp(windowsToSftp(remotePath))} ${quoteSftp(path.resolve(localPath))}\n`;
|
|
76
|
+
const result = await run("sftp", args, { input });
|
|
77
|
+
if (result.code !== 0) throw new CliError("DOWNLOAD_FAILED", result.stderr.trim() || "SFTP download failed.");
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function psLiteral(value) {
|
|
82
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(Mandatory = $true)]
|
|
3
|
+
[ValidateSet('Install', 'Status', 'Prepare', 'Enqueue', 'Run', 'JobStatus', 'ListJobs', 'Cancel')]
|
|
4
|
+
[string]$Action,
|
|
5
|
+
[Parameter(Mandatory = $true)]
|
|
6
|
+
[string]$Root,
|
|
7
|
+
[string]$PayloadBase64
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
$ErrorActionPreference = 'Stop'
|
|
11
|
+
$WorkerVersion = '0.1.0'
|
|
12
|
+
$StateRoot = Join-Path $Root '.topazlabscli'
|
|
13
|
+
$QueueRoot = Join-Path $StateRoot 'queue'
|
|
14
|
+
$JobsRoot = Join-Path $StateRoot 'jobs'
|
|
15
|
+
$WorkerRoot = Join-Path $StateRoot 'worker'
|
|
16
|
+
$ConfigPath = Join-Path $StateRoot 'worker-config.json'
|
|
17
|
+
|
|
18
|
+
function Write-Json($Value) {
|
|
19
|
+
[Console]::Out.Write(($Value | ConvertTo-Json -Depth 8 -Compress))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function Read-Payload {
|
|
23
|
+
if ([string]::IsNullOrWhiteSpace($PayloadBase64)) { return @{} }
|
|
24
|
+
$json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($PayloadBase64))
|
|
25
|
+
return $json | ConvertFrom-Json
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Ensure-Directories {
|
|
29
|
+
@($StateRoot, $QueueRoot, $JobsRoot, $WorkerRoot) | ForEach-Object {
|
|
30
|
+
New-Item -ItemType Directory -Force -Path $_ | Out-Null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Assert-SafeName([string]$Value, [string]$Field) {
|
|
35
|
+
if ([string]::IsNullOrWhiteSpace($Value) -or $Value -notmatch '^[A-Za-z0-9._-]+$') {
|
|
36
|
+
throw "Invalid $Field."
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Read-WorkerConfig {
|
|
41
|
+
if (-not (Test-Path -LiteralPath $ConfigPath)) { throw 'Worker is not installed.' }
|
|
42
|
+
return Get-Content -Raw -LiteralPath $ConfigPath | ConvertFrom-Json
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Job-Directory([string]$Id) {
|
|
46
|
+
Assert-SafeName $Id 'job id'
|
|
47
|
+
return Join-Path $JobsRoot $Id
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function Status-Path([string]$Id) {
|
|
51
|
+
return Join-Path (Job-Directory $Id) 'status.json'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Set-JobStatus([string]$Id, [hashtable]$Values) {
|
|
55
|
+
$path = Status-Path $Id
|
|
56
|
+
$current = @{}
|
|
57
|
+
if (Test-Path -LiteralPath $path) {
|
|
58
|
+
$existing = Get-Content -Raw -LiteralPath $path | ConvertFrom-Json
|
|
59
|
+
$existing.PSObject.Properties | ForEach-Object { $current[$_.Name] = $_.Value }
|
|
60
|
+
}
|
|
61
|
+
$Values.Keys | ForEach-Object { $current[$_] = $Values[$_] }
|
|
62
|
+
$current['id'] = $Id
|
|
63
|
+
$current['updated_at'] = (Get-Date).ToUniversalTime().ToString('o')
|
|
64
|
+
$temporary = "$path.tmp"
|
|
65
|
+
$current | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $temporary -Encoding UTF8
|
|
66
|
+
Move-Item -Force -LiteralPath $temporary -Destination $path
|
|
67
|
+
return $current
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function Get-ModelStatus($Config) {
|
|
71
|
+
$definition = Join-Path $Config.model_dir 'prob-4.json'
|
|
72
|
+
$weights = @(Get-ChildItem -LiteralPath $Config.model_data_dir -Filter 'prob-v4-*.tz' -ErrorAction SilentlyContinue)
|
|
73
|
+
return @{
|
|
74
|
+
model_ready = ((Test-Path -LiteralPath $definition) -and $weights.Count -gt 0)
|
|
75
|
+
model_definitions = @{ path = $definition; exists = (Test-Path -LiteralPath $definition) }
|
|
76
|
+
model_weights = @{ path = $Config.model_data_dir; count = $weights.Count }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function Invoke-Job($Job, $Config) {
|
|
81
|
+
$id = [string]$Job.id
|
|
82
|
+
$jobDir = Job-Directory $id
|
|
83
|
+
$inputPath = Join-Path (Join-Path $jobDir 'input') ([string]$Job.input_name)
|
|
84
|
+
$outputDir = Join-Path $jobDir 'output'
|
|
85
|
+
$outputName = ([IO.Path]::GetFileNameWithoutExtension([string]$Job.input_name)) + '-topaz-1080p.mp4'
|
|
86
|
+
$outputPath = Join-Path $outputDir $outputName
|
|
87
|
+
$logPath = Join-Path (Join-Path $jobDir 'logs') 'topaz-ffmpeg.log'
|
|
88
|
+
if (-not (Test-Path -LiteralPath $inputPath)) { throw "Input file is missing: $inputPath" }
|
|
89
|
+
if (-not (Test-Path -LiteralPath $Config.ffmpeg)) { throw "Topaz ffmpeg is missing: $($Config.ffmpeg)" }
|
|
90
|
+
|
|
91
|
+
Set-JobStatus $id @{ state = 'running'; started_at = (Get-Date).ToUniversalTime().ToString('o'); input_path = $inputPath; output_path = $outputPath; output_name = $outputName; preset = $Job.preset } | Out-Null
|
|
92
|
+
$probeText = & $Config.ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of json -- $inputPath
|
|
93
|
+
if ($LASTEXITCODE -ne 0) { throw 'ffprobe failed.' }
|
|
94
|
+
$probe = $probeText | ConvertFrom-Json
|
|
95
|
+
$width = [int]$probe.streams[0].width
|
|
96
|
+
$height = [int]$probe.streams[0].height
|
|
97
|
+
if ($width -ge $height) {
|
|
98
|
+
$targetHeight = 1080
|
|
99
|
+
$targetWidth = [int](2 * [Math]::Round((1080.0 * $width / $height) / 2.0))
|
|
100
|
+
} else {
|
|
101
|
+
$targetWidth = 1080
|
|
102
|
+
$targetHeight = [int](2 * [Math]::Round((1080.0 * $height / $width) / 2.0))
|
|
103
|
+
}
|
|
104
|
+
$env:TVAI_MODEL_DIR = [string]$Config.model_dir
|
|
105
|
+
$env:TVAI_MODEL_DATA_DIR = [string]$Config.model_data_dir
|
|
106
|
+
$filter = "tvai_up=model=prob-4:scale=0:w=${targetWidth}:h=${targetHeight}:preblur=0:noise=0:details=0:halo=0:blur=0:compression=0:estimate=20:blend=0.2:device=0:vram=1:instances=1"
|
|
107
|
+
$arguments = @(
|
|
108
|
+
'-hide_banner', '-nostdin', '-y', '-strict', '2', '-i', $inputPath,
|
|
109
|
+
'-sws_flags', 'spline+accurate_rnd+full_chroma_int', '-vf', $filter,
|
|
110
|
+
'-map', '0:v:0', '-map', '0:a?', '-map_metadata', '0',
|
|
111
|
+
'-c:v', 'h264_nvenc', '-preset', 'p5', '-cq', '18', '-b:v', '0', '-pix_fmt', 'yuv420p',
|
|
112
|
+
'-c:a', 'aac', '-b:a', '192k', '-fps_mode', 'passthrough', '-movflags', '+faststart', $outputPath
|
|
113
|
+
)
|
|
114
|
+
$oldPreference = $ErrorActionPreference
|
|
115
|
+
$ErrorActionPreference = 'Continue'
|
|
116
|
+
& $Config.ffmpeg @arguments 2>&1 | Tee-Object -FilePath $logPath | Out-Null
|
|
117
|
+
$exit = $LASTEXITCODE
|
|
118
|
+
$ErrorActionPreference = $oldPreference
|
|
119
|
+
if ($exit -ne 0 -or -not (Test-Path -LiteralPath $outputPath)) { throw "Topaz ffmpeg failed with exit code $exit. See $logPath" }
|
|
120
|
+
$size = (Get-Item -LiteralPath $outputPath).Length
|
|
121
|
+
Set-JobStatus $id @{ state = 'completed'; completed_at = (Get-Date).ToUniversalTime().ToString('o'); output_path = $outputPath; output_name = $outputName; output_bytes = $size; width = $targetWidth; height = $targetHeight; model = 'prob-4' } | Out-Null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
Ensure-Directories
|
|
125
|
+
|
|
126
|
+
switch ($Action) {
|
|
127
|
+
'Install' {
|
|
128
|
+
if (-not (Test-Path -LiteralPath $ConfigPath)) {
|
|
129
|
+
$config = @{
|
|
130
|
+
schema_version = 1
|
|
131
|
+
worker_version = $WorkerVersion
|
|
132
|
+
ffmpeg = 'C:\Program Files\Topaz Labs LLC\Topaz Video AI\ffmpeg.exe'
|
|
133
|
+
ffprobe = 'C:\Program Files\Topaz Labs LLC\Topaz Video AI\ffprobe.exe'
|
|
134
|
+
model_dir = 'C:\ProgramData\Topaz Labs LLC\Topaz Video AI\models'
|
|
135
|
+
model_data_dir = 'E:\topazlabs_model'
|
|
136
|
+
concurrency = 1
|
|
137
|
+
}
|
|
138
|
+
$config | ConvertTo-Json | Set-Content -LiteralPath $ConfigPath -Encoding UTF8
|
|
139
|
+
}
|
|
140
|
+
$current = Read-WorkerConfig
|
|
141
|
+
$model = Get-ModelStatus $current
|
|
142
|
+
Write-Json @{ ok = $true; installed = $true; worker_version = $WorkerVersion; root = $Root; model_ready = $model.model_ready }
|
|
143
|
+
}
|
|
144
|
+
'Status' {
|
|
145
|
+
$installed = Test-Path -LiteralPath $ConfigPath
|
|
146
|
+
if (-not $installed) { Write-Json @{ ok = $true; installed = $false; root = $Root }; break }
|
|
147
|
+
$config = Read-WorkerConfig
|
|
148
|
+
$model = Get-ModelStatus $config
|
|
149
|
+
$queued = @(Get-ChildItem -LiteralPath $QueueRoot -Filter '*.json' -ErrorAction SilentlyContinue).Count
|
|
150
|
+
Write-Json @{ ok = $true; installed = $true; worker_version = $config.worker_version; root = $Root; concurrency = $config.concurrency; queued = $queued; ffmpeg = @{ path = $config.ffmpeg; exists = (Test-Path -LiteralPath $config.ffmpeg) }; ffprobe = @{ path = $config.ffprobe; exists = (Test-Path -LiteralPath $config.ffprobe) }; model_ready = $model.model_ready; model_definitions = $model.model_definitions; model_weights = $model.model_weights }
|
|
151
|
+
}
|
|
152
|
+
'Prepare' {
|
|
153
|
+
$payload = Read-Payload
|
|
154
|
+
$id = [string]$payload.id
|
|
155
|
+
$name = [string]$payload.input_name
|
|
156
|
+
Assert-SafeName $id 'job id'
|
|
157
|
+
Assert-SafeName $name 'input name'
|
|
158
|
+
$jobDir = Job-Directory $id
|
|
159
|
+
@('input', 'output', 'logs') | ForEach-Object { New-Item -ItemType Directory -Force -Path (Join-Path $jobDir $_) | Out-Null }
|
|
160
|
+
$inputPath = Join-Path (Join-Path $jobDir 'input') $name
|
|
161
|
+
Set-JobStatus $id @{ state = 'uploading'; input_name = $name; input_path = $inputPath } | Out-Null
|
|
162
|
+
Write-Json @{ ok = $true; id = $id; input_path = $inputPath }
|
|
163
|
+
}
|
|
164
|
+
'Enqueue' {
|
|
165
|
+
$payload = Read-Payload
|
|
166
|
+
$id = [string]$payload.id
|
|
167
|
+
Assert-SafeName $id 'job id'
|
|
168
|
+
Assert-SafeName ([string]$payload.input_name) 'input name'
|
|
169
|
+
if ($payload.preset -ne 'seedance-human-1080p') { throw 'Unsupported preset.' }
|
|
170
|
+
$queuePath = Join-Path $QueueRoot "$id.json"
|
|
171
|
+
$payload | ConvertTo-Json | Set-Content -LiteralPath "$queuePath.tmp" -Encoding UTF8
|
|
172
|
+
Move-Item -Force -LiteralPath "$queuePath.tmp" -Destination $queuePath
|
|
173
|
+
Set-JobStatus $id @{ state = 'queued'; queued_at = (Get-Date).ToUniversalTime().ToString('o'); preset = $payload.preset } | Out-Null
|
|
174
|
+
Write-Json @{ ok = $true; id = $id; state = 'queued' }
|
|
175
|
+
}
|
|
176
|
+
'Run' {
|
|
177
|
+
$created = $false
|
|
178
|
+
$mutex = New-Object Threading.Mutex($true, 'Global\TopazLabsCliQueue', [ref]$created)
|
|
179
|
+
if (-not $created) { exit 0 }
|
|
180
|
+
try {
|
|
181
|
+
$config = Read-WorkerConfig
|
|
182
|
+
while ($true) {
|
|
183
|
+
$next = Get-ChildItem -LiteralPath $QueueRoot -Filter '*.json' | Sort-Object CreationTimeUtc, Name | Select-Object -First 1
|
|
184
|
+
if ($null -eq $next) { break }
|
|
185
|
+
$job = Get-Content -Raw -LiteralPath $next.FullName | ConvertFrom-Json
|
|
186
|
+
Remove-Item -Force -LiteralPath $next.FullName
|
|
187
|
+
$cancelPath = Join-Path (Job-Directory ([string]$job.id)) 'cancel.requested'
|
|
188
|
+
if (Test-Path -LiteralPath $cancelPath) {
|
|
189
|
+
Set-JobStatus ([string]$job.id) @{ state = 'cancelled'; completed_at = (Get-Date).ToUniversalTime().ToString('o') } | Out-Null
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
try { Invoke-Job $job $config }
|
|
193
|
+
catch {
|
|
194
|
+
Set-JobStatus ([string]$job.id) @{ state = 'failed'; completed_at = (Get-Date).ToUniversalTime().ToString('o'); error = $_.Exception.Message } | Out-Null
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
$mutex.ReleaseMutex()
|
|
199
|
+
$mutex.Dispose()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
'JobStatus' {
|
|
203
|
+
$payload = Read-Payload
|
|
204
|
+
$path = Status-Path ([string]$payload.id)
|
|
205
|
+
if (-not (Test-Path -LiteralPath $path)) { throw 'Job not found.' }
|
|
206
|
+
[Console]::Out.Write((Get-Content -Raw -LiteralPath $path).Trim())
|
|
207
|
+
}
|
|
208
|
+
'ListJobs' {
|
|
209
|
+
$items = @()
|
|
210
|
+
Get-ChildItem -LiteralPath $JobsRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object {
|
|
211
|
+
$path = Join-Path $_.FullName 'status.json'
|
|
212
|
+
if (Test-Path -LiteralPath $path) {
|
|
213
|
+
$items += (Get-Content -Raw -LiteralPath $path | ConvertFrom-Json)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
$items = @($items | Sort-Object updated_at -Descending)
|
|
217
|
+
Write-Json @{ ok = $true; jobs = $items }
|
|
218
|
+
}
|
|
219
|
+
'Cancel' {
|
|
220
|
+
$payload = Read-Payload
|
|
221
|
+
$id = [string]$payload.id
|
|
222
|
+
$statusPath = Status-Path $id
|
|
223
|
+
if (-not (Test-Path -LiteralPath $statusPath)) { throw 'Job not found.' }
|
|
224
|
+
$status = Get-Content -Raw -LiteralPath $statusPath | ConvertFrom-Json
|
|
225
|
+
if ($status.state -eq 'running') {
|
|
226
|
+
New-Item -ItemType File -Force -Path (Join-Path (Job-Directory $id) 'cancel.requested') | Out-Null
|
|
227
|
+
Set-JobStatus $id @{ cancel_requested = $true } | Out-Null
|
|
228
|
+
Write-Json @{ ok = $true; id = $id; state = 'running'; cancel_requested = $true; note = 'The current ffmpeg process is not forcibly terminated.' }
|
|
229
|
+
} elseif ($status.state -eq 'queued' -or $status.state -eq 'uploading') {
|
|
230
|
+
Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $QueueRoot "$id.json")
|
|
231
|
+
Set-JobStatus $id @{ state = 'cancelled'; completed_at = (Get-Date).ToUniversalTime().ToString('o') } | Out-Null
|
|
232
|
+
Write-Json @{ ok = $true; id = $id; state = 'cancelled' }
|
|
233
|
+
} else {
|
|
234
|
+
Write-Json @{ ok = $true; id = $id; state = $status.state; changed = $false }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|