@petercjl/topazlabscli 0.2.0 → 0.2.2

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 CHANGED
@@ -15,7 +15,9 @@ npm install --global @petercjl/topazlabscli
15
15
  topazlabscli skill install --agent all
16
16
  ```
17
17
 
18
- The CLI checks npm for a newer stable release before operational commands, at most once every six hours. When an update is available it upgrades itself, refreshes installed Agent Skills, and then resumes the original command. A temporary npm outage does not block video processing. `topazlabscli update` forces an immediate manual update.
18
+ The CLI checks npm for a newer stable release before operational commands, at most once every six hours. It first uses the registry already configured for npm and automatically tries `https://registry.npmmirror.com/` if that registry is unavailable. The successful registry is also used for installation, without changing the user's `.npmrc`. When an update is available the CLI upgrades itself, refreshes installed Agent Skills, and then resumes the original command. It discovers npm through the running Node installation, preserves SealSeek's managed global prefix/cache, and discovers Windows OpenSSH through the standard system location, so it also works in Agent runtimes with a restricted `PATH`. A temporary registry outage does not block video processing. `topazlabscli update` forces an immediate manual update.
19
+
20
+ On Windows, SealSeek Skills are installed into `%USERPROFILE%\.sealseek\workspace\skills` when that workspace is present. The CLI automatically uses a managed copy because SealSeek rejects junctions that resolve outside the workspace Skill root; subsequent CLI updates refresh the copy from the npm package. The copy includes a local runtime manifest so the Agent can invoke the canonical package even when its PATH is restricted. `SEALSEEK_SKILLS_HOME` remains available as an explicit override.
19
21
 
20
22
  ## Configure a target
21
23
 
@@ -82,4 +84,6 @@ Configuration is stored outside the package:
82
84
  - macOS/Linux: `${XDG_CONFIG_HOME:-~/.config}/topazlabscli/config.json`
83
85
  - Override for testing or automation: `TOPAZLABSCLI_CONFIG`
84
86
 
87
+ Update registry selection is CLI-local. `settings set update-registry <url>` sets a preferred registry, and `settings set update-registry auto` restores automatic selection. `TOPAZLABSCLI_UPDATE_REGISTRY` can provide one or more comma-separated preferred registries for managed environments.
88
+
85
89
  Do not publish configuration files, keys, internal addresses, media, Topaz model files, or authentication data.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@petercjl/topazlabscli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Cross-Agent CLI and portable Skill for queued remote Topaz Video AI processing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "scripts": {
21
21
  "test": "node --test",
22
- "check": "node --check bin/topazlabscli.mjs && node --check src/cli.mjs && node --check src/update.mjs && npm test"
22
+ "check": "node --check bin/topazlabscli.mjs && node --check src/cli.mjs && node --check src/runtime.mjs && node --check src/update.mjs && npm test"
23
23
  },
24
24
  "publishConfig": {
25
25
  "access": "public",
@@ -15,12 +15,13 @@ Use the CLI as the single execution surface. Do not reproduce SSH, SFTP, queue,
15
15
 
16
16
  ## Main Line
17
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. When the user does not name an output, let the CLI create `INPUT-BASENAME-topaz-1080p.EXT` beside the source.
21
- 4. Prefer `topazlabscli process INPUT --json` for submit, wait, and download as one operation. Add `--output OUTPUT` only when the user explicitly requests another destination. 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.
18
+ 1. Resolve the CLI invocation once. Prefer `topazlabscli`. In a Windows SealSeek managed-copy installation, if the npm command shim cannot find `node`, read `.topazlabscli-runtime.json` beside this file and execute its `bin` with its `node`; the CLI creates and refreshes that local manifest. SealSeek-managed npm installs must retain its managed global prefix rather than writing into a Node version directory. Reuse the invocation for the rest of the task; do not disable automatic updates.
19
+ 2. Run `topazlabscli capabilities --json` when the live contract is not already known.
20
+ 3. Run `topazlabscli doctor --json` before the first job in a conversation or after a connection/model failure, and wait for its terminal result before continuing.
21
+ 4. For a processing request, confirm the input exists. When the user does not name an output, let the CLI create `INPUT-BASENAME-topaz-1080p.EXT` beside the source.
22
+ 5. Prefer `topazlabscli process INPUT --json` for submit, wait, and download as one operation. Add `--output OUTPUT` only when the user explicitly requests another destination. Use separate `job` commands when the user wants asynchronous control.
23
+ 6. Inspect the final JSON. Completion requires `state=completed`, a successful download result, and a real local output file.
24
+ 7. Report the selected target/endpoint, job ID, output path, preset/model, dimensions, and any warning or failure.
24
25
 
25
26
  ## Preset Boundary
26
27
 
@@ -38,14 +39,14 @@ Use the CLI as the single execution surface. Do not reproduce SSH, SFTP, queue,
38
39
 
39
40
  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
 
41
- Before operational commands, the CLI performs a cached npm update check. A newer stable package is installed automatically, installed Agent Skills are refreshed, and the original command resumes under the new version. Registry failures produce a warning and continue with the installed version.
42
+ Before operational commands, the CLI performs a cached npm update check. It tries the user's current npm registry and then its built-in reachable-registry fallback without changing the user's global npm configuration. A newer stable package is installed from the same registry that answered the version check, installed Agent Skills are refreshed, and the original command resumes under the new version. The CLI resolves npm through the running Agent's Node installation when PATH is restricted. Registry, npm, and Skill-refresh failures produce a warning and continue with the installed version. Treat `doctor`'s `updates.registry` check as advisory; a failed update source does not make video processing unavailable.
42
43
 
43
44
  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.2.
44
45
 
45
46
  ## Skill Management
46
47
 
47
- 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.
48
+ 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. Windows SealSeek automatically receives a managed copy because its Skill loader rejects junctions that escape the workspace root; updates refresh that copy from the package. Do not edit installed links or copies as independent sources.
48
49
 
49
50
  ## QA and Evolution
50
51
 
51
- 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.
52
+ Use `doctor --json` plus the final job status as runtime evidence. Do not leave health checks or job commands running without collecting their terminal result. New models, presets, cleanup rules, or worker behavior require an authorized package update with CLI, worker, Skill, capability, and test changes together.
@@ -5,12 +5,20 @@
5
5
  "mappings": [
6
6
  {
7
7
  "capability_id": "command.execute",
8
- "implementation": { "kind": "cli", "command": "topazlabscli" },
8
+ "implementation": {
9
+ "kind": "cli",
10
+ "command": "topazlabscli",
11
+ "fallback": "Read .topazlabscli-runtime.json from the managed Skill copy and run its bin with its node"
12
+ },
9
13
  "features": ["local-process", "stdout-json", "exit-code"],
10
- "status": "implemented",
11
- "configuration": ["topazlabscli must resolve from PATH", "The bundled topazlabscli Skill must be installed through the CLI"],
14
+ "status": "tested",
15
+ "tested_date": "2026-09-21",
16
+ "evidence": [
17
+ "A clean Windows SealSeek conversation loaded the managed Skill copy, resolved the global package through its runtime manifest, ran version 0.2.1, capabilities, Skill status, and doctor to terminal results, and reported the bounded remote connection failure without disabling automatic updates."
18
+ ],
19
+ "configuration": ["Install the npm package globally", "Install the bundled Skill through the CLI into the SealSeek workspace"],
12
20
  "permissions": ["local process execution", "network access to configured SSH target"],
13
- "normalization": "Invoke with --json and consume {ok,data,error}."
21
+ "normalization": "Invoke with --json and consume {ok,data,error}. On Windows with a sanitized PATH, use the CLI-managed runtime manifest in the installed Skill copy."
14
22
  },
15
23
  {
16
24
  "capability_id": "filesystem.local",
package/src/cli.mjs CHANGED
@@ -8,7 +8,7 @@ import { CliError, requireValue } from "./errors.mjs";
8
8
  import { run } from "./process.mjs";
9
9
  import { psLiteral, runPowerShell, selectEndpoint, sftpGet, sftpPut, startPowerShellDetached } from "./ssh.mjs";
10
10
  import { skillInstall, skillSource, skillStatus } from "./skill.mjs";
11
- import { maybeAutoUpdate } from "./update.mjs";
11
+ import { installLatestPackage, maybeAutoUpdate, queryLatestVersion, updateRegistryWarning } from "./update.mjs";
12
12
 
13
13
  const require = createRequire(import.meta.url);
14
14
  const pkg = require("../package.json");
@@ -19,9 +19,9 @@ const CAPABILITIES = {
19
19
  package: pkg.name,
20
20
  version: pkg.version,
21
21
  commands: ["version", "capabilities", "doctor", "settings", "target", "connection", "worker", "model", "job", "process", "skill", "update"],
22
- automatic_updates: { enabled_by_default: true, registry_check_hours: 6, refreshes_installed_skills: true },
22
+ automatic_updates: { enabled_by_default: true, registry_check_hours: 6, registry_fallback: true, refreshes_installed_skills: true },
23
23
  presets: [{ id: PRESET, model: "prob-4", output: "aspect-preserving 1080p", fps: "source", concurrency: 1 }],
24
- agents: { codex: "tested", sealseek: "implemented" },
24
+ agents: { codex: "tested", sealseek_windows: "tested" },
25
25
  worker_os: ["windows"],
26
26
  transport: ["ssh", "sftp"]
27
27
  };
@@ -60,7 +60,7 @@ function help() {
60
60
  return `topazlabscli ${pkg.version}\n\n` +
61
61
  `Commands:\n` +
62
62
  ` version | capabilities | doctor\n` +
63
- ` settings show | set auto-update <on|off> | set update-check-hours <hours>\n` +
63
+ ` settings show | set auto-update <on|off> | set update-check-hours <hours> | set update-registry <auto|url>\n` +
64
64
  ` target add <name> --endpoint <label=host>... --user <user> [--identity <path>] [--workspace <windows-path>] [--default]\n` +
65
65
  ` target list\n` +
66
66
  ` connection check [--target <name>]\n` +
@@ -152,16 +152,33 @@ export function defaultOutputPath(inputPath) {
152
152
  return path.join(path.dirname(resolved), `${path.basename(resolved, path.extname(resolved))}-topaz-1080p${extension}`);
153
153
  }
154
154
 
155
- async function doctor(requestedTarget) {
155
+ async function doctor(requestedTarget, updateInfo = null) {
156
156
  const checks = [];
157
157
  for (const command of ["ssh", "sftp", "node", "npm"]) {
158
158
  const args = command === "node" || command === "npm" ? ["--version"] : command === "sftp" ? ["-h"] : ["-V"];
159
159
  const result = await run(command, args);
160
160
  const detail = (result.stdout || result.stderr).trim().split("\n")[0];
161
161
  const ok = result.code === 0 || (command === "sftp" && /usage:\s*sftp/i.test(result.stderr));
162
- checks.push({ id: `local.${command}`, ok, detail });
162
+ checks.push({
163
+ id: `local.${command}`,
164
+ ok,
165
+ detail,
166
+ resolved_command: result.resolvedCommand,
167
+ resolution: result.resolution,
168
+ timed_out: Boolean(result.timedOut)
169
+ });
163
170
  }
164
171
  checks.push({ id: "config", ok: fs.existsSync(configPath()), detail: configPath() });
172
+ if (updateInfo) {
173
+ checks.push({
174
+ id: "updates.registry",
175
+ ok: Boolean(updateInfo.registry),
176
+ required: false,
177
+ detail: updateInfo.registry
178
+ ? { registry: updateInfo.registry, latest: updateInfo.latest || null, checked: updateInfo.checked }
179
+ : { warning: updateInfo.warning || "Update registry was not checked.", attempts: updateInfo.attempts || [] }
180
+ });
181
+ }
165
182
  if (requestedTarget || fs.existsSync(configPath())) {
166
183
  try {
167
184
  const { target, endpoint } = await getConnectedTarget(requestedTarget);
@@ -176,7 +193,7 @@ async function doctor(requestedTarget) {
176
193
  checks.push({ id: "connection", ok: false, detail: error.details || error.message });
177
194
  }
178
195
  }
179
- return { ok: checks.every((item) => item.ok), checks };
196
+ return { ok: checks.filter((item) => item.required !== false).every((item) => item.ok), checks };
180
197
  }
181
198
 
182
199
  export async function main(rawArgs) {
@@ -187,11 +204,12 @@ export async function main(rawArgs) {
187
204
  if (command === "version" || command === "--version" || command === "-V") return output(pkg.version, json);
188
205
  if (command === "capabilities") return output(CAPABILITIES, json);
189
206
 
207
+ let updateInfo = null;
190
208
  if (!["update", "settings"].includes(command)) {
191
- const update = await maybeAutoUpdate(rawArgs, pkg);
192
- if (update.warning) process.stderr.write(`[AUTO_UPDATE_WARNING] ${update.warning}\n`);
193
- if (update.reexecuted) {
194
- process.exitCode = update.exitCode;
209
+ updateInfo = await maybeAutoUpdate(rawArgs, pkg);
210
+ if (updateInfo.warning) process.stderr.write(`[AUTO_UPDATE_WARNING] ${updateInfo.warning}\n`);
211
+ if (updateInfo.reexecuted) {
212
+ process.exitCode = updateInfo.exitCode;
195
213
  return;
196
214
  }
197
215
  }
@@ -210,6 +228,9 @@ export async function main(rawArgs) {
210
228
  const hours = Number(value);
211
229
  if (!Number.isFinite(hours) || hours < 0) throw new CliError("SETTING_INVALID", "update-check-hours must be zero or a positive number.");
212
230
  config.settings.update_check_hours = hours;
231
+ } else if (name === "update-registry") {
232
+ if (value !== "auto" && !/^https?:\/\//i.test(value)) throw new CliError("SETTING_INVALID", "update-registry must be auto or an http(s) URL.");
233
+ config.settings.update_registry = value;
213
234
  } else throw new CliError("SETTING_UNKNOWN", `Unknown setting: ${name}`);
214
235
  const saved = saveConfig(config);
215
236
  return output({ path: saved, settings: config.settings }, json);
@@ -217,7 +238,7 @@ export async function main(rawArgs) {
217
238
  throw new CliError("COMMAND_UNKNOWN", `Unknown settings action: ${action}`);
218
239
  }
219
240
 
220
- if (command === "doctor") return output(await doctor(option(args, "--target")), json);
241
+ if (command === "doctor") return output(await doctor(option(args, "--target"), updateInfo), json);
221
242
 
222
243
  if (command === "target") {
223
244
  const action = args.shift();
@@ -312,14 +333,17 @@ export async function main(rawArgs) {
312
333
  }
313
334
 
314
335
  if (command === "update") {
336
+ const config = loadConfig();
337
+ const query = await queryLatestVersion(pkg, config);
338
+ if (!query.ok) throw new CliError("UPDATE_FAILED", updateRegistryWarning(query.attempts), { attempts: query.attempts });
315
339
  const previousSkills = skillStatus("all");
316
- const result = await run("npm", ["install", "-g", `${pkg.name}@latest`]);
340
+ const result = await installLatestPackage(pkg, query.registry);
317
341
  if (result.code !== 0) throw new CliError("UPDATE_FAILED", result.stderr.trim() || "npm update failed.");
318
342
  const skills = [];
319
343
  for (const existing of previousSkills.filter((item) => item.installed)) {
320
344
  skills.push(...skillInstall(existing.agent, existing.mode || "link", true));
321
345
  }
322
- return output({ package: pkg.name, updated: true, skills, detail: result.stdout.trim() }, json);
346
+ return output({ package: pkg.name, updated: true, registry: query.registry, latest: query.latest, skills, detail: result.stdout.trim() }, json);
323
347
  }
324
348
  throw new CliError("COMMAND_UNKNOWN", `Unknown command: ${command}`);
325
349
  }
package/src/config.mjs CHANGED
@@ -8,7 +8,7 @@ export function emptyConfig() {
8
8
  schema_version: 1,
9
9
  default_target: null,
10
10
  targets: {},
11
- settings: { auto_update: true, update_check_hours: 6 }
11
+ settings: { auto_update: true, update_check_hours: 6, update_registry: "auto" }
12
12
  };
13
13
  }
14
14
 
@@ -29,7 +29,10 @@ export function loadConfig({ required = false } = {}) {
29
29
  auto_update: parsed.settings?.auto_update !== false,
30
30
  update_check_hours: Number.isFinite(parsed.settings?.update_check_hours)
31
31
  ? parsed.settings.update_check_hours
32
- : 6
32
+ : 6,
33
+ update_registry: typeof parsed.settings?.update_registry === "string"
34
+ ? parsed.settings.update_registry
35
+ : "auto"
33
36
  }
34
37
  };
35
38
  } catch (error) {
package/src/paths.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import os from "node:os";
2
+ import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
 
@@ -21,13 +22,22 @@ export function updateStatePath() {
21
22
  return path.join(path.dirname(configPath()), "update-state.json");
22
23
  }
23
24
 
24
- export function skillTarget(agent) {
25
- const home = os.homedir();
25
+ export function skillTarget(agent, options = {}) {
26
+ const home = options.home || os.homedir();
27
+ const env = options.env || process.env;
28
+ const platform = options.platform || process.platform;
29
+ const exists = options.exists || fs.existsSync;
30
+ const pathApi = platform === "win32" ? path.win32 : path;
26
31
  if (agent === "codex") {
27
- return path.join(process.env.CODEX_HOME || path.join(home, ".codex"), "skills", "topazlabscli");
32
+ return pathApi.join(env.CODEX_HOME || pathApi.join(home, ".codex"), "skills", "topazlabscli");
28
33
  }
29
34
  if (agent === "sealseek") {
30
- return path.join(process.env.SEALSEEK_SKILLS_HOME || path.join(home, ".agents", "skills"), "topazlabscli");
35
+ if (env.SEALSEEK_SKILLS_HOME) return pathApi.join(env.SEALSEEK_SKILLS_HOME, "topazlabscli");
36
+ const workspace = pathApi.join(home, ".sealseek", "workspace");
37
+ const root = platform === "win32" && (exists(workspace) || exists(pathApi.dirname(workspace)))
38
+ ? pathApi.join(workspace, "skills")
39
+ : pathApi.join(home, ".agents", "skills");
40
+ return pathApi.join(root, "topazlabscli");
31
41
  }
32
42
  throw new Error(`Unknown Agent: ${agent}`);
33
43
  }
package/src/process.mjs CHANGED
@@ -1,19 +1,34 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { resolveExecutable } from "./runtime.mjs";
2
3
 
3
4
  export function run(command, args, options = {}) {
4
- return new Promise((resolve, reject) => {
5
- const child = spawn(command, args, {
5
+ return new Promise((resolve) => {
6
+ const executable = resolveExecutable(command, { env: options.env || process.env });
7
+ const child = spawn(executable.command, [...executable.argsPrefix, ...args], {
6
8
  cwd: options.cwd,
7
- env: options.env || process.env,
9
+ env: { ...(options.env || process.env), ...(executable.envPatch || {}) },
8
10
  stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
9
11
  windowsHide: true
10
12
  });
11
13
  let stdout = "";
12
14
  let stderr = "";
15
+ let timedOut = false;
16
+ let timer = null;
17
+ const finish = (value) => {
18
+ if (timer) clearTimeout(timer);
19
+ resolve({ ...value, resolvedCommand: executable.command, resolution: executable.resolution, timedOut });
20
+ };
13
21
  child.stdout.on("data", (chunk) => { stdout += chunk; });
14
22
  child.stderr.on("data", (chunk) => { stderr += chunk; });
15
- child.on("error", reject);
16
- child.on("close", (code, signal) => resolve({ code, signal, stdout, stderr }));
23
+ child.on("error", (error) => finish({ code: 127, signal: null, stdout, stderr: stderr || error.message, error: error.message }));
24
+ child.on("close", (code, signal) => finish({ code, signal, stdout, stderr }));
25
+ if (Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) {
26
+ timer = setTimeout(() => {
27
+ timedOut = true;
28
+ child.kill();
29
+ }, options.timeoutMs);
30
+ timer.unref?.();
31
+ }
17
32
  if (options.input !== undefined) {
18
33
  child.stdin.end(options.input);
19
34
  }
@@ -21,14 +36,15 @@ export function run(command, args, options = {}) {
21
36
  }
22
37
 
23
38
  export function runInherited(command, args, options = {}) {
24
- return new Promise((resolve, reject) => {
25
- const child = spawn(command, args, {
39
+ return new Promise((resolve) => {
40
+ const executable = resolveExecutable(command, { env: options.env || process.env });
41
+ const child = spawn(executable.command, [...executable.argsPrefix, ...args], {
26
42
  cwd: options.cwd,
27
- env: options.env || process.env,
43
+ env: { ...(options.env || process.env), ...(executable.envPatch || {}) },
28
44
  stdio: "inherit",
29
45
  windowsHide: true
30
46
  });
31
- child.on("error", reject);
47
+ child.on("error", (error) => resolve({ code: 127, signal: null, error: error.message }));
32
48
  child.on("close", (code, signal) => resolve({ code, signal }));
33
49
  });
34
50
  }
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ function firstExisting(candidates, exists) {
5
+ return candidates.find((candidate) => candidate && exists(candidate)) || null;
6
+ }
7
+
8
+ export function resolveExecutable(name, options = {}) {
9
+ const platform = options.platform || process.platform;
10
+ const env = options.env || process.env;
11
+ const execPath = options.execPath || process.execPath;
12
+ const exists = options.exists || fs.existsSync;
13
+ const pathApi = platform === "win32" ? path.win32 : path;
14
+
15
+ if (name === "node") {
16
+ return { command: execPath, argsPrefix: [], resolution: "process.execPath" };
17
+ }
18
+
19
+ if (name === "npm") {
20
+ if (env.TOPAZLABSCLI_NPM) {
21
+ return { command: env.TOPAZLABSCLI_NPM, argsPrefix: [], resolution: "TOPAZLABSCLI_NPM" };
22
+ }
23
+ const npmExecPath = env.npm_execpath && pathApi.resolve(env.npm_execpath);
24
+ const managedNpm = firstExisting([
25
+ npmExecPath,
26
+ pathApi.join(pathApi.dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js")
27
+ ], exists);
28
+ if (managedNpm) {
29
+ const versionDirectory = pathApi.dirname(execPath);
30
+ const versionsDirectory = pathApi.dirname(versionDirectory);
31
+ const managedRoot = pathApi.basename(versionsDirectory).toLowerCase() === "versions"
32
+ ? pathApi.dirname(versionsDirectory)
33
+ : null;
34
+ const managedPrefix = managedRoot && pathApi.join(managedRoot, "global");
35
+ const managedCache = managedRoot && pathApi.join(managedRoot, "cache");
36
+ const envPatch = managedPrefix && exists(managedPrefix)
37
+ ? {
38
+ npm_config_prefix: env.npm_config_prefix || managedPrefix,
39
+ ...(managedCache && exists(managedCache) ? { npm_config_cache: env.npm_config_cache || managedCache } : {})
40
+ }
41
+ : {};
42
+ return { command: execPath, argsPrefix: [managedNpm], envPatch, resolution: "managed-node-npm-cli" };
43
+ }
44
+ }
45
+
46
+ if (platform === "win32" && ["ssh", "sftp"].includes(name)) {
47
+ const override = env[`TOPAZLABSCLI_${name.toUpperCase()}`];
48
+ if (override) return { command: override, argsPrefix: [], resolution: `TOPAZLABSCLI_${name.toUpperCase()}` };
49
+ const windowsRoot = env.SystemRoot || env.WINDIR || "C:\\Windows";
50
+ const openSsh = pathApi.join(windowsRoot, "System32", "OpenSSH", `${name}.exe`);
51
+ if (exists(openSsh)) return { command: openSsh, argsPrefix: [], resolution: "windows-openssh" };
52
+ }
53
+
54
+ return { command: name, argsPrefix: [], resolution: "PATH" };
55
+ }
package/src/skill.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { bundledSkill, skillTarget } from "./paths.mjs";
3
+ import { binScript, bundledSkill, packageRoot, skillTarget } from "./paths.mjs";
4
4
  import { CliError } from "./errors.mjs";
5
5
 
6
6
  const agents = ["codex", "sealseek"];
@@ -23,7 +23,24 @@ export function skillStatus(selected = "all") {
23
23
  });
24
24
  }
25
25
 
26
+ export function effectiveSkillMode(agent, requestedMode, platform = process.platform) {
27
+ if (platform === "win32" && agent === "sealseek" && requestedMode === "link") return "copy";
28
+ return requestedMode;
29
+ }
30
+
31
+ function writeRuntimeManifest(target) {
32
+ const manifest = {
33
+ schema: "topazlabscli-skill-runtime",
34
+ schema_version: 1,
35
+ node: process.execPath,
36
+ bin: binScript,
37
+ package_root: packageRoot
38
+ };
39
+ fs.writeFileSync(path.join(target, ".topazlabscli-runtime.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
40
+ }
41
+
26
42
  function installOne(agent, mode, update) {
43
+ const effectiveMode = effectiveSkillMode(agent, mode);
27
44
  const target = skillTarget(agent);
28
45
  fs.mkdirSync(path.dirname(target), { recursive: true });
29
46
  if (fs.existsSync(target)) {
@@ -32,9 +49,11 @@ function installOne(agent, mode, update) {
32
49
  if (stat.isSymbolicLink()) fs.unlinkSync(target);
33
50
  else fs.rmSync(target, { recursive: true, force: true });
34
51
  }
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 };
52
+ if (effectiveMode === "copy") {
53
+ fs.cpSync(bundledSkill, target, { recursive: true });
54
+ writeRuntimeManifest(target);
55
+ } else fs.symlinkSync(bundledSkill, target, process.platform === "win32" ? "junction" : "dir");
56
+ return { agent, target, mode: effectiveMode, source: bundledSkill };
38
57
  }
39
58
 
40
59
  export function skillInstall(selected = "all", mode = "link", update = false) {
package/src/ssh.mjs CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { run } from "./process.mjs";
5
+ import { resolveExecutable } from "./runtime.mjs";
5
6
  import { CliError } from "./errors.mjs";
6
7
 
7
8
  function destination(target, endpoint) {
@@ -22,13 +23,15 @@ export function encodePowerShell(script) {
22
23
  export async function runPowerShell(target, endpoint, script, { timeout = 7 } = {}) {
23
24
  const args = [...commonArgs(target, endpoint, { timeout }), destination(target, endpoint),
24
25
  "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(script)];
25
- return run("ssh", args);
26
+ return run("ssh", args, { timeoutMs: (timeout + 3) * 1000 });
26
27
  }
27
28
 
28
29
  export function startPowerShellDetached(target, endpoint, script) {
29
30
  const args = [...commonArgs(target, endpoint, { timeout: 10 }), destination(target, endpoint),
30
31
  "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(script)];
31
- const child = spawn("ssh", args, { detached: true, stdio: "ignore", windowsHide: true });
32
+ const executable = resolveExecutable("ssh");
33
+ const child = spawn(executable.command, [...executable.argsPrefix, ...args], { detached: true, stdio: "ignore", windowsHide: true });
34
+ child.on("error", () => {});
32
35
  child.unref();
33
36
  return child.pid;
34
37
  }
@@ -38,7 +41,13 @@ export async function selectEndpoint(target) {
38
41
  for (const endpoint of target.endpoints) {
39
42
  const result = await runPowerShell(target, endpoint, "[Console]::Out.Write('TOPAZLABSCLI_OK')");
40
43
  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() });
44
+ attempts.push({
45
+ name: endpoint.name,
46
+ host: endpoint.host,
47
+ ok,
48
+ timed_out: Boolean(result.timedOut),
49
+ error: ok ? null : result.stderr.trim()
50
+ });
42
51
  if (ok) return { endpoint, attempts };
43
52
  }
44
53
  throw new CliError("CONNECTION_FAILED", `No endpoint is reachable for target ${target.name}.`, { attempts });
@@ -61,7 +70,7 @@ export async function sftpPut(target, endpoint, localPath, remotePath) {
61
70
  if (endpoint.port) args.push("-P", String(endpoint.port));
62
71
  args.push(destination(target, endpoint));
63
72
  const input = `put ${quoteSftp(path.resolve(localPath))} ${quoteSftp(windowsToSftp(remotePath))}\n`;
64
- const result = await run("sftp", args, { input });
73
+ const result = await run("sftp", args, { input, timeoutMs: 15000 });
65
74
  if (result.code !== 0) throw new CliError("UPLOAD_FAILED", result.stderr.trim() || "SFTP upload failed.");
66
75
  return result;
67
76
  }
@@ -73,7 +82,7 @@ export async function sftpGet(target, endpoint, remotePath, localPath) {
73
82
  if (endpoint.port) args.push("-P", String(endpoint.port));
74
83
  args.push(destination(target, endpoint));
75
84
  const input = `get ${quoteSftp(windowsToSftp(remotePath))} ${quoteSftp(path.resolve(localPath))}\n`;
76
- const result = await run("sftp", args, { input });
85
+ const result = await run("sftp", args, { input, timeoutMs: 15000 });
77
86
  if (result.code !== 0) throw new CliError("DOWNLOAD_FAILED", result.stderr.trim() || "SFTP download failed.");
78
87
  return result;
79
88
  }
package/src/update.mjs CHANGED
@@ -6,6 +6,8 @@ import { run, runInherited } from "./process.mjs";
6
6
  import { skillInstall, skillStatus } from "./skill.mjs";
7
7
 
8
8
  const DEFAULT_INTERVAL_HOURS = 6;
9
+ const DEFAULT_MIRROR_REGISTRY = "https://registry.npmmirror.com/";
10
+ const UPDATE_TIMEOUT_MS = 8000;
9
11
  const UPDATE_GUARD = "TOPAZLABSCLI_AUTO_UPDATE_GUARD";
10
12
 
11
13
  function numericParts(version) {
@@ -41,6 +43,87 @@ function automaticUpdateEnabled(config, env) {
41
43
  return config.settings?.auto_update !== false;
42
44
  }
43
45
 
46
+ function normalizeRegistry(value) {
47
+ const text = String(value || "").trim();
48
+ if (!/^https?:\/\//i.test(text)) return null;
49
+ return text.endsWith("/") ? text : `${text}/`;
50
+ }
51
+
52
+ function uniqueRegistries(values) {
53
+ return [...new Set(values.map(normalizeRegistry).filter(Boolean))];
54
+ }
55
+
56
+ export async function resolveUpdateRegistries(config, dependencies = {}) {
57
+ const env = dependencies.env || process.env;
58
+ const execute = dependencies.run || run;
59
+ const configured = config.settings?.update_registry;
60
+ const overrides = String(env.TOPAZLABSCLI_UPDATE_REGISTRY || "")
61
+ .split(",")
62
+ .map((item) => item.trim())
63
+ .filter(Boolean);
64
+ const preferred = configured && configured !== "auto" ? [configured] : [];
65
+ let npmRegistry = null;
66
+ try {
67
+ const result = await execute("npm", ["config", "get", "registry"], {
68
+ env,
69
+ timeoutMs: dependencies.timeoutMs || UPDATE_TIMEOUT_MS
70
+ });
71
+ if (result.code === 0) npmRegistry = result.stdout.trim();
72
+ } catch {}
73
+ return uniqueRegistries([...overrides, ...preferred, npmRegistry, DEFAULT_MIRROR_REGISTRY]);
74
+ }
75
+
76
+ export async function queryLatestVersion(pkg, config, dependencies = {}) {
77
+ const env = dependencies.env || process.env;
78
+ const execute = dependencies.run || run;
79
+ const registries = dependencies.registries || await resolveUpdateRegistries(config, dependencies);
80
+ const attempts = [];
81
+ for (const registry of registries) {
82
+ let result;
83
+ try {
84
+ result = await execute("npm", ["view", pkg.name, "version", "--json", "--registry", registry], {
85
+ env: {
86
+ ...env,
87
+ npm_config_fetch_timeout: env.npm_config_fetch_timeout || "5000",
88
+ npm_config_fetch_retries: "0"
89
+ },
90
+ timeoutMs: dependencies.timeoutMs || UPDATE_TIMEOUT_MS
91
+ });
92
+ } catch (error) {
93
+ attempts.push({ registry, ok: false, detail: error.message });
94
+ continue;
95
+ }
96
+ if (result.code !== 0) {
97
+ attempts.push({
98
+ registry,
99
+ ok: false,
100
+ detail: result.timedOut ? "timed out" : (result.stderr.trim() || "registry query failed")
101
+ });
102
+ continue;
103
+ }
104
+ let latest;
105
+ try { latest = JSON.parse(result.stdout.trim()); }
106
+ catch { latest = result.stdout.trim().replace(/^"|"$/g, ""); }
107
+ attempts.push({ registry, ok: true, latest });
108
+ return { ok: true, latest, registry, attempts };
109
+ }
110
+ return { ok: false, attempts };
111
+ }
112
+
113
+ function registryFailureMessage(attempts) {
114
+ if (!attempts.length) return "No valid npm update registry is configured.";
115
+ return `Unable to check npm for updates: ${attempts.map((item) => `${item.registry} (${item.detail})`).join("; ")}`;
116
+ }
117
+
118
+ export async function installLatestPackage(pkg, registry, dependencies = {}) {
119
+ const env = dependencies.env || process.env;
120
+ const execute = dependencies.run || run;
121
+ return execute("npm", ["install", "--global", `${pkg.name}@latest`, "--registry", registry], {
122
+ env,
123
+ timeoutMs: dependencies.installTimeoutMs
124
+ });
125
+ }
126
+
44
127
  export async function maybeAutoUpdate(rawArgs, pkg, dependencies = {}) {
45
128
  const env = dependencies.env || process.env;
46
129
  if (env[UPDATE_GUARD] === "1") return { checked: false, reason: "guard" };
@@ -54,35 +137,46 @@ export async function maybeAutoUpdate(rawArgs, pkg, dependencies = {}) {
54
137
  const intervalMs = Math.max(0, intervalHours) * 60 * 60 * 1000;
55
138
  const state = readState(stateFile);
56
139
  if (intervalMs > 0 && Number.isFinite(state.last_checked_at) && now - state.last_checked_at < intervalMs) {
57
- return { checked: false, reason: "fresh", latest: state.latest || null };
140
+ return { checked: false, reason: "fresh", latest: state.latest || null, registry: state.registry || null };
58
141
  }
59
142
 
60
- const execute = dependencies.run || run;
61
- const query = await execute("npm", ["view", pkg.name, "version", "--json"], {
62
- env: { ...env, npm_config_fetch_timeout: env.npm_config_fetch_timeout || "5000", npm_config_fetch_retries: "0" }
63
- });
64
- if (query.code !== 0) {
65
- return { checked: true, warning: query.stderr.trim() || "Unable to check npm for updates." };
66
- }
143
+ const query = await queryLatestVersion(pkg, config, dependencies);
144
+ if (!query.ok) return { checked: true, warning: registryFailureMessage(query.attempts), attempts: query.attempts };
67
145
 
68
- let latest;
69
- try { latest = JSON.parse(query.stdout.trim()); }
70
- catch { latest = query.stdout.trim().replace(/^"|"$/g, ""); }
71
- writeState(stateFile, { last_checked_at: now, latest, current: pkg.version });
72
- if (!isNewerVersion(latest, pkg.version)) return { checked: true, updated: false, latest };
146
+ const { latest, registry } = query;
147
+ writeState(stateFile, { last_checked_at: now, latest, current: pkg.version, registry });
148
+ if (!isNewerVersion(latest, pkg.version)) return { checked: true, updated: false, latest, registry, attempts: query.attempts };
73
149
 
74
150
  const getSkillStatus = dependencies.skillStatus || skillStatus;
75
151
  const installSkill = dependencies.skillInstall || skillInstall;
76
152
  const installedSkills = getSkillStatus("all").filter((item) => item.installed);
77
- const install = await execute("npm", ["install", "--global", `${pkg.name}@latest`], { env });
153
+ let install;
154
+ try {
155
+ install = await installLatestPackage(pkg, registry, dependencies);
156
+ } catch (error) {
157
+ return { checked: true, warning: `Automatic npm update failed: ${error.message}`, latest, registry };
158
+ }
78
159
  if (install.code !== 0) {
79
- return { checked: true, warning: install.stderr.trim() || "Automatic npm update failed.", latest };
160
+ return { checked: true, warning: install.stderr.trim() || "Automatic npm update failed.", latest, registry };
161
+ }
162
+ const warnings = [];
163
+ for (const item of installedSkills) {
164
+ try { installSkill(item.agent, item.mode || "link", true); }
165
+ catch (error) { warnings.push(`Skill refresh failed for ${item.agent}: ${error.message}`); }
80
166
  }
81
- for (const item of installedSkills) installSkill(item.agent, item.mode || "link", true);
82
167
 
83
168
  const reexecute = dependencies.runInherited || runInherited;
84
- const child = await reexecute(process.execPath, [binScript, ...rawArgs], {
85
- env: { ...env, [UPDATE_GUARD]: "1" }
86
- });
87
- return { checked: true, updated: true, latest, reexecuted: true, exitCode: child.code ?? 1 };
169
+ try {
170
+ const child = await reexecute(process.execPath, [binScript, ...rawArgs], {
171
+ env: { ...env, [UPDATE_GUARD]: "1" }
172
+ });
173
+ return { checked: true, updated: true, latest, registry, reexecuted: true, exitCode: child.code ?? 1, warnings };
174
+ } catch (error) {
175
+ warnings.push(`Updated package could not restart the command: ${error.message}`);
176
+ return { checked: true, updated: true, latest, registry, warning: warnings.join(" ") };
177
+ }
178
+ }
179
+
180
+ export function updateRegistryWarning(attempts) {
181
+ return registryFailureMessage(attempts);
88
182
  }