diff --git a/AGENTS.md b/AGENTS.md index 09587f6..99d2d56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,7 @@ The install flow's "Skip configuration" auth choice routes Configure through `ba The `deleted` vs `kept-live` split turns on `isCodevAuthored(kind)` in `configure.ts`, and that gate carries the whole safety argument. Restore **consumes** the backup (`renameSync`), so "no backup + live file" is ambiguous: it can mean CoDev wrote the file from scratch, but equally that this is a *second* restore and the live file is the pristine original the first run just reinstated, or that the user hand-wrote a config for a tool CoDev never configured (both `remove` and the bare `restore` sweep visit every tool). Only the first case is ours to delete. `isCodevAuthored` reuses the same per-kind marker detectors `codevhub model` uses (`isCodevClaudeConfig`, `isCodevCodexConfig`, `isCodevOpenCodeConfig`, `isCodevContinueConfig`); each returns `false` for a missing or unparseable file, so anything we can't attribute is kept. The two auth files have no marker key of their own: `claude-json` is matched by whole-file *shape* (`isCodevClaudeJsonStub` — exactly `{hasCompletedOnboarding: true}`, since a real `~/.claude.json` accumulates projects/history/mcpServers), and `claude-credentials` always returns `true` because CoDev never writes that file, only removes it, so a live one can only postdate CoDev. Deliberately no cross-kind inference (e.g. reading settings.json to decide the credentials' fate): `restoreTool` restores `claude-settings` first, which erases that marker, so the answer would depend on iteration order. -`restoreKind`/`restoreTool`/`runRestore`/`runRestoreAll`/`runRemove` all take a trailing `force = false`. `codevhub restore [agent] --force` and `codevhub remove --force` set it, and it **bypasses the authorship gate only**: a backup-less live file is deleted whoever wrote it, so `kept-live` never happens. It deliberately does *not* override the backup branch — a `*.backup` still wins and is still restored, because that file is the user's pre-CoDev original and reinstating it is the whole point of the command. The flag is **intentionally absent from `help.ts`, the README, and all UI copy**; it's an escape hatch, discoverable only from source, and nothing invokes it on the user's behalf. Keep it that way, and keep it long-form only (no `-f` alias) so the reflex `-f` from `upload`/`login` can't unconditionally delete configs by accident. +`restoreKind`/`restoreTool`/`runRestore`/`runRestoreAll`/`runRemove` all take a trailing `force = false`. `codevhub restore [agent] --force` and `codevhub remove --force` set it, and it **bypasses the authorship gate only**: a backup-less live file is deleted whoever wrote it, so `kept-live` never happens. It deliberately does *not* override the backup branch — a `*.backup` still wins and is still restored, because that file is the user's pre-CoDev original and reinstating it is the whole point of the command. The flag is **intentionally absent from `help.ts`, the README, and all UI copy**; it's an escape hatch, discoverable only from source, and nothing invokes it on the user's behalf. Keep it that way, and keep it long-form only (no `-f` alias) so the reflex `-f` from `upload`/`login` can't unconditionally delete configs by accident. That last rule is **general, not a restore detail**: `-f` is reserved for flags that cost nothing when fired by reflex, so any flag that destroys a user's files stays long-form. `codevhub skill pull --force` follows it for the same reason — it `rm -rf`s the skill directory, which may hold local edits, while `-f` on `login`/`upload`/`doctor` only forces a fresh sign-in. What enforces it there is `parsePullArgs`'s `PULL_FLAGS` allowlist: an unrecognized `-`-prefixed token is an error, so `-f` is rejected loudly rather than silently ignored. Reporting stays honest under force: `restoreKind` evaluates `isCodevAuthored` even when forcing and sets `RestoreResult.forced = !authored`, so a file taken *despite* not being CoDev's is reported as forced rather than under the default message (which asserts "CoDev wrote it" and would be a false claim about the user's own file). `remove`'s aggregate detail counts them separately (`deleted 2 files (no backup, 1 forced)`), and the NDJSON log carries `forced` on every `restore.kind` document. diff --git a/src/SkillPullApp.tsx b/src/SkillPullApp.tsx index e54935f..c2c00f5 100644 --- a/src/SkillPullApp.tsx +++ b/src/SkillPullApp.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { Banner } from "@/components/Banner.js"; import { Frame } from "@/components/Frame.js"; import { Step } from "@/components/Step.js"; +import { useCanType } from "@/components/useCanType.js"; import { stripControlChars } from "@/lib/sanitize.js"; import { formatInstallResult, @@ -37,6 +38,7 @@ export function SkillPullApp({ onDone, }: SkillPullAppProps) { const { exit } = useApp(); + const canType = useCanType(); const [phase, setPhase] = useState("resolving"); const [meta, setMeta] = useState(null); const [index, setIndex] = useState(0); @@ -96,6 +98,20 @@ export function SkillPullApp({ [meta, force, finish], ); + // The dispatcher already routes a keyboard-less terminal to the plain runner, + // so reaching "select" without one means Ink's stdin isn't the process's own. + // Say so and exit rather than mounting a picker that can never be answered: + // unlike the ungated case this is a silent hang, not a throw. + useEffect(() => { + if (phase !== "select" || canType) return; + setError( + "This terminal cannot supply keystrokes, so the location prompt cannot be shown.\n" + + "Pass --here, --global, or --dir to choose a location without it.", + ); + setPhase("error"); + finish(false); + }, [phase, canType, finish]); + useInput( (_input, key) => { if (phase !== "select") return; @@ -107,7 +123,7 @@ export function SkillPullApp({ void start(LOCATIONS[index]?.key ?? "current"); } }, - { isActive: phase === "select" }, + { isActive: canType && phase === "select" }, ); // Sanitize the hub-sourced name before rendering it to the terminal. diff --git a/src/SkillPushApp.tsx b/src/SkillPushApp.tsx index 1d0da55..e64129f 100644 --- a/src/SkillPushApp.tsx +++ b/src/SkillPushApp.tsx @@ -5,6 +5,7 @@ import { Banner } from "@/components/Banner.js"; import { Frame } from "@/components/Frame.js"; import { Login, loginTitle } from "@/components/Login.js"; import { Step } from "@/components/Step.js"; +import { useCanType } from "@/components/useCanType.js"; import { type AuthData, loadAuth } from "@/lib/auth.js"; import { formatPublishResult, @@ -85,6 +86,7 @@ export function SkillPushApp({ onDone, }: SkillPushAppProps) { const { exit } = useApp(); + const canType = useCanType(); const [phase, setPhase] = useState("preparing"); const [archive, setArchive] = useState(null); const [result, setResult] = useState(null); @@ -176,6 +178,21 @@ export function SkillPushApp({ [start], ); + // The dispatcher already routes a keyboard-less terminal to the plain runner, + // so reaching "confirm" without one means Ink's stdin isn't the process's own. + // Bail rather than mount a confirmation nobody can answer — and never assume + // consent from the silence: this step is the last thing standing between the + // user and an upload. + useEffect(() => { + if (phase !== "confirm" || canType) return; + setError( + "This terminal cannot supply keystrokes, so the confirmation prompt cannot be shown.\n" + + "Re-run with --json to publish without confirming.", + ); + setPhase("error"); + finish(false); + }, [phase, canType, finish]); + useInput( (input, key) => { if (phase !== "confirm") return; @@ -188,7 +205,7 @@ export function SkillPushApp({ finish(false); } }, - { isActive: phase === "confirm" }, + { isActive: canType && phase === "confirm" }, ); // After confirmation: if a credential is already available, record the diff --git a/src/index.tsx b/src/index.tsx index 4f43821..700d744 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -39,10 +39,18 @@ import { type ShimAgent, uninstallShims, } from "@/lib/shims.js"; -import { parsePullArgs, runSkillInstall } from "@/lib/skill-install.js"; +import { + PULL_USAGE, + parsePullArgs, + runSkillInstall, +} from "@/lib/skill-install.js"; import { parsePublishArgs, runSkillPublish } from "@/lib/skill-publish.js"; import { runSkillSearch } from "@/lib/skill-search.js"; -import { interactiveTerminalBlocker, stdinKind } from "@/lib/tty.js"; +import { + interactiveTerminalBlocker, + rawModeSupported, + stdinKind, +} from "@/lib/tty.js"; import { runUploadDaemon, spawnUploadDaemon } from "@/lib/upload.js"; import { ModelApp } from "@/ModelApp.js"; import { RemoveApp } from "@/RemoveApp.js"; @@ -94,6 +102,19 @@ function flagValue(argv: string[], name: string): string | undefined { return undefined; } +// Whether a skill subcommand should mount its Ink prompt rather than fall back +// to its non-interactive runner. Two independent conditions: +// +// - the keyboard, asked via `rawModeSupported()` — Ink gates raw mode on +// `stdin.isTTY` alone, and lib/tty.ts exists so that gate is stated in one +// place; re-deriving it here is how the two silently drift apart. +// - stdout being a terminal, which is not about the keyboard at all: it keeps +// `codevhub skill pull … | tee log` on the plain runner instead of piping a +// rendered Ink frame full of ANSI into a file. +function skillPromptUsable(): boolean { + return rawModeSupported() && Boolean(process.stdout.isTTY); +} + // Refuse, with an explanation, when a command is about to mount a keyboard // prompt in a terminal that cannot supply one. Ink gates raw mode on // `stdin.isTTY` alone, so in Git Bash (an MSYS pipe, not a Windows console) @@ -382,7 +403,7 @@ switch (command) { } // Interactive TTY (and not --json): preview + confirm before uploading // (Ink). Otherwise (piped/CI, or --json) go the plain runner. - const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY); + const interactive = skillPromptUsable(); if (interactive && !parsed.json) { let ok = true; const { waitUntilExit } = render( @@ -412,15 +433,15 @@ switch (command) { process.exit(1); } if (!parsed.target) { - console.error( - "Usage: codevhub skill pull [--dir ] [--force] [--json]", - ); + console.error(PULL_USAGE); process.exit(1); } - // Interactive + no explicit --dir: prompt for the location (Ink). - // Otherwise (--dir given, or piped/CI) go the plain non-interactive path. - const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY); - if (parsed.dir === undefined && interactive) { + // Interactive + no explicit location: prompt for one (Ink). Otherwise + // (--here/--global/--dir given, or piped/CI) go the plain + // non-interactive path. + const interactive = skillPromptUsable(); + const located = parsed.dir !== undefined || parsed.location !== undefined; + if (!located && interactive) { let ok = true; const { waitUntilExit } = render( to cap results, default 20) skill pull Download and install a skill by name or id - (prompts for location; --dir to set it explicitly, - --force to overwrite; --json for machine-readable output) + (prompts for location; --here or --global to skip the + prompt, --dir for an exact path, --force to + overwrite; --json for machine-readable output) skill push Publish a skill (a directory with SKILL.md, or a .zip) (previews and confirms before upload; --draft-only to stop at DRAFT, --auto-approve for admins, --json for output) diff --git a/src/lib/skill-install.ts b/src/lib/skill-install.ts index 0dc67b0..1041f82 100644 --- a/src/lib/skill-install.ts +++ b/src/lib/skill-install.ts @@ -32,6 +32,11 @@ function safeSkillDir(rootDir: string, name: string): string { export type InstallLocation = "current" | "global"; +// One usage string for both entry points (dispatcher and non-interactive +// runner), so the flag list can't drift between them. +export const PULL_USAGE = + "Usage: codevhub skill pull [--here|--global|--dir ] [--force] [--json]"; + // Root dir for each prompt choice. "current" is cwd-relative (created if // missing on install); "global" is the home skills dir. Claude Agent Skills // live under `.claude/skills` in both cases. @@ -43,39 +48,83 @@ export function skillsDirFor(location: InstallLocation): string { export interface ParsedPull { target?: string; + // Exact install root, verbatim from --dir: the skill lands in /, + // with no `.claude/skills` segment added. Mutually exclusive with `location`. dir?: string; + // The picker's choice expressed as a flag (--here / --global), resolved + // through skillsDirFor. Mutually exclusive with `dir`. + location?: InstallLocation; force: boolean; json: boolean; error?: string; } +// Every flag `pull` accepts. Anything else starting with "-" is a typo, and is +// rejected rather than ignored — a silently dropped `--forse` looks like a +// successful run that just didn't do what was asked. +// +// Deliberately no `-f` alias, for the same reason `restore --force` has none: +// `-f` elsewhere in this CLI (`login`, `upload`, `doctor`) forces a fresh login, +// which costs nothing, while here the identical keystroke is an `rm -rf` of a +// skill directory that may hold local edits. The reflex must not reach it. +const PULL_FLAGS = new Set([ + "--dir", + "--force", + "--json", + "--here", + "--global", +]); + // Parse `pull` args: first non-flag token is the target (name|id); flags are -// --dir , --force/-f, --json. Shared by the interactive (index → app) and -// non-interactive (runSkillInstall) paths so parsing lives in one place. +// --here/--global, --dir , --force, --json. Shared by the interactive +// (index → app) and non-interactive (runSkillInstall) paths so parsing lives in +// one place. export function parsePullArgs(args: string[]): ParsedPull { - const force = args.includes("--force") || args.includes("-f"); + const force = args.includes("--force"); const json = args.includes("--json"); - let dir: string | undefined; - const dirIdx = args.indexOf("--dir"); - if (dirIdx !== -1) { - const value = args[dirIdx + 1]; - if (!value || value.startsWith("-")) { - return { force, json, error: "Missing value for --dir." }; - } - dir = value; + const here = args.includes("--here"); + const global = args.includes("--global"); + if (here && global) { + return { force, json, error: "Pass either --here or --global, not both." }; } + const location: InstallLocation | undefined = here + ? "current" + : global + ? "global" + : undefined; + let dir: string | undefined; const positionals: string[] = []; for (let i = 0; i < args.length; i++) { - if (args[i] === "--dir") { - i++; // skip its value + const arg = args[i] as string; + if (arg === "--dir") { + // Consume the value here so it is never mistaken for a flag or a target, + // whatever it contains. + const value = args[++i]; + if (!value || value.startsWith("-")) { + return { force, json, error: "Missing value for --dir." }; + } + dir = value; continue; } - if (args[i]?.startsWith("-")) continue; - positionals.push(args[i] as string); + if (arg.startsWith("-")) { + if (!PULL_FLAGS.has(arg)) { + return { force, json, error: `Unknown flag: ${arg}` }; + } + continue; + } + positionals.push(arg); + } + + if (dir !== undefined && location !== undefined) { + return { + force, + json, + error: "Pass either --dir or --here/--global, not both.", + }; } - return { target: positionals[0], dir, force, json }; + return { target: positionals[0], dir, location, force, json }; } export interface InstallResult { @@ -155,9 +204,9 @@ export function formatInstallResult(r: InstallResult, json: boolean): string { return `Installed ${name}${versionSuffix} -> ${r.dir}`; } -// Non-interactive path (`--dir` given, or piped/CI). The interactive location -// prompt lives in SkillPullApp; here a location MUST be explicit, so a missing -// --dir is an error rather than a silent default. Returns the exit code. +// Non-interactive path (a location flag given, or piped/CI). The interactive +// prompt lives in SkillPullApp; here a location MUST be explicit, so no flag at +// all is an error rather than a silent default. Returns the exit code. export async function runSkillInstall(args: string[]): Promise { const parsed = parsePullArgs(args); if (parsed.error) { @@ -165,21 +214,27 @@ export async function runSkillInstall(args: string[]): Promise { return 1; } if (!parsed.target) { - console.error( - "Usage: codevhub skill pull [--dir ] [--force] [--json]", - ); + console.error(PULL_USAGE); return 1; } - if (!parsed.dir) { + // --here/--global reproduce the picker's two choices (and so append + // `.claude/skills`); --dir is the escape hatch and is used verbatim. + const rootDir = + parsed.dir !== undefined + ? resolve(parsed.dir) + : parsed.location !== undefined + ? skillsDirFor(parsed.location) + : null; + if (rootDir === null) { console.error( - "Not a terminal — pass --dir to choose an install location.", + "Not a terminal — pass --here, --global, or --dir to choose an install location.", ); return 1; } try { const result = await installSkill(parsed.target, { - rootDir: resolve(parsed.dir), + rootDir, force: parsed.force, }); console.log(formatInstallResult(result, parsed.json)); diff --git a/tests/SkillPullApp.test.tsx b/tests/SkillPullApp.test.tsx index f1a205d..495b7c9 100644 --- a/tests/SkillPullApp.test.tsx +++ b/tests/SkillPullApp.test.tsx @@ -142,6 +142,37 @@ describe("SkillPullApp", () => { expect(spy.mock.calls[0]?.[1]).toMatchObject({ force: true }); }); + // A terminal with no raw mode (Git Bash on Windows — see lib/tty.ts). The + // dispatcher normally routes those to the plain runner, so this covers the + // case where Ink's stdin isn't the process's own. Unlike an ungated useInput + // (which throws), an unanswerable picker would just hang forever. + // ink-testing-library's stdin reports isTTY true and takes no options, so the + // flag is flipped and the tree re-rendered — Ink recomputes + // `isRawModeSupported` every render. + test("without raw mode: explains the missing keyboard instead of prompting", async () => { + mockResolve(); + const spy = vi.spyOn(install, "installResolvedSkill"); + const onDone = vi.fn(); + const node = ( + + ); + + const instance = render(node); + instance.stdin.isTTY = false; + instance.rerender(node); + + await waitFor(() => + frameText(instance.lastFrame).includes("cannot supply keystrokes"), + ); + const frame = frameText(instance.lastFrame); + expect(frame).toContain("--here, --global, or --dir"); + // Never falls back to a location the user didn't choose. + expect(frame).not.toContain("❯ "); + expect(spy).not.toHaveBeenCalled(); + await waitFor(() => onDone.mock.calls.length > 0); + expect(onDone).toHaveBeenCalledWith(false); + }); + test("shows an error when the skill can't be resolved", async () => { vi.spyOn(skillhub, "getSkillMeta").mockRejectedValue( new Error('Skill "bad-id" not found or not public.'), diff --git a/tests/SkillPushApp.test.tsx b/tests/SkillPushApp.test.tsx index c8b5ad2..d61a49a 100644 --- a/tests/SkillPushApp.test.tsx +++ b/tests/SkillPushApp.test.tsx @@ -188,4 +188,40 @@ describe("SkillPushApp login gate", () => { expect(authSpy).not.toHaveBeenCalled(); expect(pub).not.toHaveBeenCalled(); }); + + // A terminal with no raw mode (Git Bash on Windows — see lib/tty.ts). The + // confirm step is the last thing between the user and an upload, so silence + // must never be read as consent. ink-testing-library's stdin reports isTTY + // true and takes no options, so the flag is flipped and the tree re-rendered + // — Ink recomputes `isRawModeSupported` every render. + test("without raw mode: refuses rather than publishing unconfirmed", async () => { + const authSpy = vi + .spyOn(skillhub, "hasSkillhubAuth") + .mockResolvedValue(true); + const pub = vi.spyOn(publish, "publishSkill").mockResolvedValue(RESULT); + vi.spyOn(publish, "preparePublishArchive").mockResolvedValue(ARCHIVE); + const onDone = vi.fn(); + + const node = ( + + ); + const instance = render(node); + instance.stdin.isTTY = false; + instance.rerender(node); + + await waitFor(() => + frameText(instance.lastFrame).includes("cannot supply keystrokes"), + ); + expect(frameText(instance.lastFrame)).toContain("--json"); + expect(authSpy).not.toHaveBeenCalled(); + expect(pub).not.toHaveBeenCalled(); + await waitFor(() => onDone.mock.calls.length > 0); + expect(onDone).toHaveBeenCalledWith(false); + }); }); diff --git a/tests/lib/skill-install.test.ts b/tests/lib/skill-install.test.ts index f714dc4..dc89e49 100644 --- a/tests/lib/skill-install.test.ts +++ b/tests/lib/skill-install.test.ts @@ -5,11 +5,15 @@ import { readFileSync, rmSync, } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import AdmZip from "adm-zip"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { runSkillInstall } from "@/lib/skill-install.js"; +import { + parsePullArgs, + runSkillInstall, + skillsDirFor, +} from "@/lib/skill-install.js"; import * as skillhub from "@/lib/skillhub.js"; let tempDir: string; @@ -167,15 +171,100 @@ describe("runSkillInstall", () => { expect(errs.join("\n")).toMatch(/Usage: codevhub skill pull/); }); - test("requires --dir on the non-interactive path (no prompt available)", async () => { + test("requires a location flag on the non-interactive path (no prompt available)", async () => { + const getMeta = vi.spyOn(skillhub, "getSkillMeta"); + const errs = captureErr(); + + const code = await runSkillInstall(["pg-tuner"]); // no location flag + + expect(code).toBe(1); + expect(getMeta).not.toHaveBeenCalled(); + expect(errs.join("\n")).toMatch(/--here, --global, or --dir/i); + }); + + // --dir is an exact path (`/`), while --here reproduces the + // picker's "Current directory" choice — which is the layout agents read. + test("--dir installs verbatim; --here adds the .claude/skills segment", async () => { + mockMeta(); + mockDownload(); + vi.spyOn(process, "cwd").mockReturnValue(tempDir); + + expect(await runSkillInstall(["pg-tuner", "--dir", tempDir])).toBe(0); + expect(existsSync(join(tempDir, "pg-tuner", "SKILL.md"))).toBe(true); + + expect(await runSkillInstall(["pg-tuner", "--here"])).toBe(0); + expect( + existsSync(join(tempDir, ".claude", "skills", "pg-tuner", "SKILL.md")), + ).toBe(true); + }); + + test("--global resolves to the home skills dir", () => { + // Asserted through the pure helpers so the test never writes to a real home. + expect(parsePullArgs(["pg-tuner", "--global"]).location).toBe("global"); + expect(skillsDirFor("global")).toBe(join(homedir(), ".claude", "skills")); + expect(skillsDirFor("current")).toBe( + join(process.cwd(), ".claude", "skills"), + ); + }); + + // A mistyped flag must fail loudly. Silently ignoring `--forse` would look + // like a successful run that quietly refused to overwrite. + test("rejects an unknown flag instead of ignoring it", async () => { const getMeta = vi.spyOn(skillhub, "getSkillMeta"); const errs = captureErr(); - const code = await runSkillInstall(["pg-tuner"]); // no --dir + const code = await runSkillInstall([ + "pg-tuner", + "--dir", + tempDir, + "--forse", + ]); expect(code).toBe(1); expect(getMeta).not.toHaveBeenCalled(); - expect(errs.join("\n")).toMatch(/pass --dir/i); + expect(errs.join("\n")).toContain("Unknown flag: --forse"); + }); + + // No `-f` alias: elsewhere in this CLI `-f` forces a fresh login, which costs + // nothing, while here it would rm -rf a skill directory. The reflex must miss. + test("does not accept -f as a --force alias", async () => { + mockMeta(); + mockDownload(); + mkdirSync(join(tempDir, "pg-tuner"), { recursive: true }); + const errs = captureErr(); + + const code = await runSkillInstall(["pg-tuner", "--dir", tempDir, "-f"]); + + expect(code).toBe(1); + expect(errs.join("\n")).toContain("Unknown flag: -f"); + // The pre-existing directory is still there — nothing was overwritten. + expect(existsSync(join(tempDir, "pg-tuner"))).toBe(true); + expect(existsSync(join(tempDir, "pg-tuner", "SKILL.md"))).toBe(false); + }); + + test("rejects conflicting location flags", async () => { + const errs = captureErr(); + + expect(await runSkillInstall(["pg-tuner", "--here", "--global"])).toBe(1); + expect( + await runSkillInstall(["pg-tuner", "--here", "--dir", tempDir]), + ).toBe(1); + expect(errs.join("\n")).toMatch(/not both/i); + }); + + test("a --dir value is never read as a flag or a target", () => { + // The value is consumed positionally, so even a flag-shaped-looking name + // stays the directory, and the target is still the first real positional. + const parsed = parsePullArgs(["--dir", "--here", "pg-tuner"]); + expect(parsed.error).toBe("Missing value for --dir."); + + const ok = parsePullArgs(["--dir", "build/skills", "pg-tuner", "--force"]); + expect(ok).toMatchObject({ + target: "pg-tuner", + dir: "build/skills", + force: true, + }); + expect(ok.location).toBeUndefined(); }); test("rejects a server name that escapes the target dir (no download, no write)", async () => {