Skip to content

CLI Reference

magicelk235 edited this page Aug 23, 2026 · 7 revisions

CLI Reference

viaduct is the command-line interface that converts a Chrome extension into a Safari Web Extension. This page documents every flag, its default, the standalone modes, exit codes, and copy-paste recipes.

All flags on this page are parsed in src/cli.ts via Node's node:util parseArgs. Where the README's ## Options block and cli.ts disagree, this page follows cli.ts and calls out the discrepancy.

Synopsis

viaduct <input> [options]
viaduct <in1> <in2># batch: several inputs in one run
viaduct <input> --analyze          # report only, no conversion
viaduct --doctor                   # check local toolchain
viaduct --list                     # list registered Safari Web Extensions
viaduct --uninstall <AppName>      # remove a previously installed app
viaduct --logs <name>              # dump a --debug build's persisted debug log

What <input> accepts

<input> is a positional argument. It may be any of:

Input Notes
.zip archive Extracted with macOS-native ditto (falls back to unzip).
.crx (Chrome) The CRX container is parsed (v2 and v3 headers) and the embedded ZIP is unpacked. The Chrome extension id is recovered from the CRX public key and injected as manifest.key (34d4724).
.xpi (zip-based) Treated as a ZIP.
Unpacked extension directory The folder holding manifest.json is passed through directly (no extraction; xattr -cr is not run on your source tree).
Chrome Web Store URL e.g. https://chromewebstore.google.com/detail/<name>/<id> (legacy chrome.google.com/webstore/detail/… also works). The 32-char extension id is pulled from the URL and the .crx is fetched from Google's clients2 CRX endpoint.
Direct .crx / .zip download URL http(s) link to the package itself; downloaded and detected by magic bytes.

Magic-byte detection. Archive type is sniffed from the file's leading bytes (Cr24 → CRX, PK\x03\x04 → ZIP), not from the file extension, so a CRX renamed to .zip, or a zip-based .xpi, still converts. The suffix is only a fallback when the bytes are inconclusive. (Verified in src/input/extract.ts sniffArchiveKind / extractExtension, and src/input/download.ts inferKind for downloads.)

For URLs, if the endpoint returns HTML (an error page or captcha) or an empty body, viaduct fails with an actionable message instead of writing a broken archive. Very large or policy-gated Web Store extensions that the on-demand CRX endpoint declines will report that explicitly, download the .crx manually and pass the local path.

Batch. Passing more than one input converts each independently; one bad input does not abort the rest, and the run exits non-zero if any input failed (33e9bce). See Batch mode and exit codes below.

Security: extraction rejects archives containing symlinks and any entry that escapes the extraction directory (zip-slip guard) in assertNoPathEscape.

Options

Grouped logically. Every flag below is parsed in cli.ts. Booleans default to false unless noted.

Input / Output

Flag Alias Argument Default Description
--output -o <dir> ./<AppName>_Safari Output directory. Default is <AppName>_Safari under the current working directory (src/convert.ts outputDir).
--bundle-id <id> com.viaduct.<app> Reverse-DNS bundle id for the host app. Default derived by defaultBundleId (com.viaduct.<slug>, leading digits stripped; a hash suffix when the name is all-symbol/non-Latin so distinct names stay distinct). Validated: letters/digits/hyphens, dot-separated, each segment starts with a letter, 2+ segments, invalid ids exit 2.
--app-name <name> extension's name Host app name. Sanitized (deriveAppName) to letters/digits/-/_.
--min-safari <ver> 15.4 Safari strict_min_version. Use 18.4 for world:MAIN content scripts. Validated as 1, 3 dot-separated integers (e.g. 15.4); invalid exits 2. Default is DEFAULT_MIN_SAFARI_VERSION in src/manifest/manifest.ts.
--platforms all | macos | ios macos Target platform(s). Anything else exits 2.

README discrepancy: the README's ## Options block lists the --min-safari default as 15.4 literally, which matches cli.ts (the help text interpolates DEFAULT_MIN_SAFARI_VERSION = "15.4"). No conflict, but the source of truth is the constant, not the hard-coded README string.

Build modes

Flag Argument Default Description
--ci off Clean-copy resources into the generated project instead of symlinking them. Use for CI / TestFlight. Default (off) symlinks resources so you can live-edit extension files and reload in Safari (src/convert.ts: copyResources: values.ci).
--temp-load off Stage the extension only, no Xcode project, no build. Produces a folder + instructions for Safari 18's Develop → Add Temporary Extension… (src/build/tempload.ts). Cannot be combined with --install (exits 2).
--zip off Also emit a distributable .zip of the staged extension (<AppName>_SafariExtension.zip in the output dir).
--clean off Wipe the output directory before staging, dropping stale leftovers (a40114e).
--no-build off (builds) Generate the .xcodeproj but do not run xcodebuild. Cannot be combined with --install (exits 2).
--open-xcode off Open the generated .xcodeproj in Xcode when done (a40114e).

Signing & Install

Flag Argument Default Description
--install off Install the built app to the install dir and register it with Safari. Requires a build (rejects --no-build / --temp-load, exits 2). Targets macOS: rejects --platforms ios (exits 2). Plain --install with no --team triggers Xcode team auto-detection (see --team).
--uninstall <name> , Standalone mode, remove the installed <name>.app and unregister it. Honors --install-dir. See Standalone modes.
--install-dir <dir> ~/Applications Install / uninstall target directory (src/build/installer.ts; ~ is expanded).
--team <id> ad-hoc / auto with --install Sign with a 10-char Apple Developer Team ID → real signing, so the extension persists across Safari quits (no unsigned toggle). --team auto (or plain --install) auto-detects the team via detectXcodeTeam: Xcode's cached team list (both keys, both preference domains) or the codesigning identity in the keychain, with the provisioning profiles on disk choosing between them (newest first). A team id that appears only in a profile is ignored, because a profile can name a team this Mac has no account for and every build then dies on error: No Account for Team (issue #15). When a team does reach xcodebuild the result is checked against the artifactcodesign -dvv on the built .appex (verifySigning, src/build/verify.ts) — and a bundle that came out ad-hoc anyway exits non-zero, not behind --verify, on the same footing as an install that didn't land. When no team is found at all, the run warns up front and the announced ad-hoc fallback is a warning, not a failure (issue #14); an auto-detected team that then fails the build for a signing reason is retried ad-hoc the same way, while a team you named with --team <id> fails the run instead of being silently downgraded. Omit --team entirely for ad-hoc signing, which is left unchecked. Free personal teams expire in ~7 days, re-run to re-sign. Validated: exactly 10 uppercase alphanumerics or the literal auto (else exit 2). (a40114e)
--verify off After --install, check that Safari registered the extension and that it is enabled (src/build/verify.ts). Requires --install (exits 2 otherwise). A registered-but-disabled or unregistered result makes the run exit non-zero; "enabled unknown" is best-effort and not a failure (33e9bce). Signing is not part of this flag — it is checked on every run that asks for a team (see --team).
--no-safari-restart off (restarts) With --install, do not quit/relaunch Safari and do not set the "Allow Unsigned Extensions" toggle.
--background-launch off (opens visibly) With --install, launch the host app with open -g -j (hidden, no activation). The launch still makes PlugInKit register the appex, but no window opens over whatever is on screen. Built for the Viaduct app, which finishes its converting animation and then opens the host app itself.

README discrepancy: the README shows --team [<id>] (bracketed optional argument). In cli.ts, team is { type: "string" }, the value is not optional at the parser level: --team must be followed by a value (auto or a team id). "Plain --install auto-detects" is achieved by omitting --team, not by a bare --team.

Conversion toggles

Flag Argument Default Description
--no-shim off (shim on) Do not generate/inject the compatibility shim (src/convert.ts: generateShim: !values["no-shim"]). See Runtime Shim.
--no-oauth-bridge off (bridge on) Do not wire the Safari OAuth / externally_connectable bridge (src/runtime/oauth-bridge.ts).
--keep-module off (strips it) Keep background.type: "module" instead of stripping it. Also affects --analyze (the preview honors this, so the previewed manifest matches an actual convert).
--debug off Emit the shim with debug tracing enabled: shimSource({ debug: true }) flips the compiled-in __C2S_DEBUG__ gate to true and splices the persistent ring-buffer logger (src/runtime/debug-ring.js) into the staged shim. Every gated trace then also lands in storage.local under __viaduct_debug_log__ (last 2000 entries, writes batched ~1 s), tagged with a timestamp and a background/content/page context label. Read it with --logs or the console one-liner in Testing and Debugging. A default conversion carries no ring-buffer write path at all. Rejects --no-shim (exits 2). Dev builds only.
--force off Convert despite blocking errors. Without it, a blocking issue count > 0 aborts with a non-zero exit (src/convert.ts).
--strict off Treat warnings as blocking too (CI gate). Changes countBlocking to also count warning-severity issues (src/analyze/report.ts countBlocking). With --analyze, exits 1 if any warning/error remains.

Analysis

Flag Argument Default Description
--analyze off Analyze and report only, no conversion. Prints issues and previews the exact manifest rewrites the converter would apply (side-effect-free transformManifest).
--json off With --analyze, print a machine-readable JSON report. Only valid with --analyze (else exit 2). In JSON mode, even a corrupt archive / missing manifest emits parseable {"error": …, "convertible": false} rather than a stack trace.
--report <file> , With --analyze, also write the report to <file>, JSON if --json, else Markdown. Only valid with --analyze (else exit 2).

Diagnostics

Flag Alias Default Description
--doctor , Standalone mode, verify local toolchain (xcrun, safari-web-extension-packager, xcodebuild, plutil, pluginkit, ditto, osascript, lsregister). See Standalone modes.
--logs <name> , Standalone mode, dump the persisted __viaduct_debug_log__ ring buffer of an installed --debug build. <name> matches the app name or bundle id (case/punctuation-insensitive) against Safari's on-disk extension storage at ~/Library/Containers/com.apple.Safari/Data/Library/WebKit/WebExtensions/Default/<bundle-id>.Extension (<team>)/LocalStorage.db; the WAL trio is snapshotted to a scratch dir and queried read-only, so Safari can stay open. See Standalone modes.
--quiet -q off Suppress progress messages. Warnings and errors still print. Ignored when -v is also present (setQuiet(quiet && !verbose)).
--verbose -v off Verbose output.

Meta

Flag Alias Default Description
--config ./viaduct.config.json if present Load defaults from a JSON file keyed by long-flag name. CLI flags override config; explicit path that doesn't exist exits 2. See Config file. (33e9bce)
--list , Standalone mode, list Safari Web Extensions registered with this user (via pluginkit). (33e9bce)
--help -h , Print help and exit 0.
--version , Print the viaduct version and exit 0.

Standalone modes

These modes run before the conversion path and do not take an input archive the usual way. They are checked in main() in this order: --version, --help, --doctor, --list, --uninstall, --logs. The first one present wins.

Mode What it does Exit behavior
--version Prints the package version (or unknown if unreadable). Always 0.
--help / -h Prints the full usage/help text. Always 0.
--doctor Runs the toolchain checks listed under Diagnostics; prints ok/fail per tool with an install hint. 0 if all checks pass, 1 if any fail.
--list Lists registered Safari Web Extensions (bundle id + path); prints a friendly note if none. Always 0.
--uninstall <name> Removes <name>.app from the install dir and unregisters it from Safari (uninstallFromSafari). Honors --install-dir. An empty name exits 1. 0 on success, 1 on failure.
--logs <name> Prints one line per persisted debug-log entry (ISO time [context] message) for the installed extension matching <name> (src/runtime/debug-logs.ts). An empty name, no match, an ambiguous match, or an extension without a recorded log exits 1 with the reason (the last one names --debug as the fix). 0 on success, 1 on failure.

Config file (--config)

viaduct reads defaults from ./viaduct.config.json automatically if present, or from an explicit --config <file> (33e9bce). The file is JSON keyed by long-flag name and reads exactly like the CLI:

{
  "bundle-id": "com.example.myext",
  "min-safari": "18.4",
  "team": "auto",
  "ci": true
}
  • Precedence: a flag the user typed on the CLI always wins; config fills the rest. (Provenance is detected by scanning argv, including the -o short alias for --output.)
  • Allowed keys (CONFIG_KEYS): output, bundle-id, app-name, min-safari, platforms, ci, zip, no-build, open-xcode, install, install-dir, no-safari-restart, background-launch, team, no-shim, no-oauth-bridge, keep-module, force, strict, verify, clean, debug. One-shot/meta flags (analyze, doctor, list, version, json, report, config, help, quiet, verbose, uninstall, logs, temp-load) are not persistable.
  • Type-checked: boolean keys must be a real true/false, a string like "false" is rejected (it would be truthy in JS and silently flip the flag on). Unknown keys warn and are skipped.
  • Config-supplied values are overlaid before validation, so a config bundle-id/team/min-safari is validated exactly like a CLI one.
  • Batch: per-extension keys (output, app-name, bundle-id) from config are dropped with a note for batch runs.

Batch mode

Passing multiple positionals converts each independently (33e9bce). Single-file / single-extension flags are rejected for batch and exit 2:

  • --output (one directory can't hold several extensions, omit it; each gets its default ./<App>_Safari)
  • --report (names a single file)
  • --json (emits one object per extension → not parseable as one stream; run one at a time)
  • --app-name / --bundle-id (apply to one extension)

One failing input does not abort the batch; the run exits 1 if any input failed, 0 if all succeeded. Ctrl-C mid-download is routed through cleanup so scratch dirs don't leak.

Exit codes

Code Meaning
0 Success. Conversion completed / analysis found nothing blocking / standalone mode succeeded.
1 Runtime failure or blocking result: conversion didn't complete, a blocking issue count > 0 without --force, --analyze found blocking issues, an install/verify the user requested didn't land, --doctor failed a check, --uninstall failed, or (in batch) any input failed.
2 Usage error: unknown/invalid flag, invalid --platforms/--bundle-id/--min-safari/--team, illegal flag combination (--install with --no-build/--temp-load/ios, --verify without --install, --json/--report without --analyze), missing <input>, a bad --config/batch flag. Help is printed alongside.
130 Interrupted (SIGINT/SIGTERM); routed through cleanup.

How --strict / --force affect exit

The blocking count comes from countBlocking(issues, strict), issues with severity === "error", plus warning-severity issues when --strict, excluding anything already autoFixed.

  • Convert path (src/convert.ts): if blocking > 0 and not --force, it prints "<n> blocking … Re-run with --force to convert anyway." and the run exits 1. --force proceeds regardless. --strict widens what counts as blocking.
  • Analyze path (--analyze): returns 1 when the blocking count is > 0, else 0. Because auto-fixed issues are excluded, --analyze and a real convert agree on the verdict. With --strict, --analyze exits 1 if any warning or error is present. The analyze exit code was fixed in 146b6ca (and process.exitCode is set rather than calling process.exit() so a piped --json payload isn't truncated).

Common recipes

Convert directly from a Chrome Web Store URL (the .crx is fetched automatically):

viaduct "https://chromewebstore.google.com/detail/ublock-origin-lite/ddkjiahejlhfcafbddmgiahcphecmpfh"

Analyze only, report issues and preview the manifest rewrites, no conversion:

viaduct ./my-extension.zip --analyze

Machine-readable analysis for CI (pipe to jq):

viaduct ./my-extension.zip --analyze --json | jq '.convertible'

Strict CI gate, fail the analysis if there is any warning or error:

viaduct ./my-extension.zip --analyze --strict

Stage for Safari 18 "Add Temporary Extension…" (no Xcode, no build):

viaduct ./my-extension.crx --temp-load
# then in Safari: Develop → Add Temporary Extension… → pick the staged folder

Generate the Xcode project but skip xcodebuild (open it yourself):

viaduct ./my-extension.zip --no-build --open-xcode

CI / TestFlight-safe build (clean-copy resources instead of symlinking):

viaduct ./my-extension.zip --ci

Build, sign with an auto-detected Apple team, and install to Safari (persists across restarts):

viaduct ./my-extension.zip --install --team auto
# (plain --install also auto-detects the team; use an explicit ID to pin it: --team A1B2C3D4E5)

Verify the install actually registered and is enabled:

viaduct ./my-extension.zip --install --team auto --verify

Batch-convert several extensions in one run:

viaduct ext-a.zip ext-b.crx ./ext-c-unpacked

Uninstall a previously installed app:

viaduct --uninstall "My Extension"
# add --install-dir if you installed somewhere other than ~/Applications

Check your local toolchain and list what's registered:

viaduct --doctor
viaduct --list

See also

  • Conversion Pipeline, how an input becomes a staged/built Safari extension.
  • Analyzer, the issue checks behind --analyze and the blocking-vs-warning model.
  • Build and Install, Xcode project generation, signing, and Safari registration (--install, --team, --verify).
  • Runtime Shim, the compatibility shim injected unless --no-shim.
  • Limitations and FAQ, Safari-specific constraints (e.g. the 4-shortcut commands cap d3fcb9a, stripped permission tokens 774d0a8).

Clone this wiki locally