@asteriusit/ctcpy
v2.0.0
Published
Copy container images between registries (skopeo-copy-like) in pure Node.js, zero runtime dependencies. Supports Nexus path-based repositories.
Maintainers
Readme
ctcpy
Copy a container image from one registry to another — like skopeo copy — in pure Node.js (>= 18), with zero runtime dependencies. No Docker daemon, no Go binary: just the Docker Registry v2 / OCI HTTP API.
Designed for environments where only npm packages can be imported (e.g. through a Nexus npm proxy) and Go-based tooling is blocked.
What it does
- Copies tag or digest references between any v2-compatible registries (Nexus, Harbor, GitLab, ECR, Docker Hub with explicit host…).
- Handles multi-arch images (manifest lists / OCI indexes): all platform manifests and their blobs are copied, digests preserved byte-for-byte.
- Streams blobs (constant memory, works with multi-GB layers).
- Skips blobs already present in the destination (idempotent re-runs, cheap syncs).
- Auth: Basic auth per side, plus the Bearer token challenge flow if the registry requires it.
- Addresses registries that don't serve
/v2/at the root — notably Nexus path-based repositories (/repository/<name>/v2/…) via--src-prefix/--dest-prefix.
Install
Published as the scoped package @asteriusit/ctcpy. The installed command is ctcpy.
npm install -g @asteriusit/ctcpy
# or run without installing:
npx @asteriusit/ctcpy <src> <dest>Usage
export CTCPY_SRC_CREDS="user:password" # source registry credentials (optional)
export CTCPY_DEST_CREDS="user:password" # destination registry credentials (optional)
ctcpy nexus-dev.corp:8443/team/myapp:1.2.3 nexus-prod.corp:8443/team/myapp:1.2.3Reference format: registry-host[:port]/repo/path[:tag|@sha256:…] (tag defaults to latest). The destination must be a tag. Use --plain-http-src / --plain-http-dest for registries served over plain HTTP.
Credentials are read from environment variables only, so they never end up in shell history or ps output.
Options
| Option | Meaning |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| --plain-http-src, --plain-http-dest | Use http instead of https for that side. |
| --src-prefix <path>, --dest-prefix <path> | Base path inserted before /v2/ (e.g. /repository/docker.io). Also settable via CTCPY_SRC_PREFIX / CTCPY_DEST_PREFIX. |
| -h, --help | Show usage. |
Nexus path-based repositories
Nexus exposes each docker repository under /repository/<name>/v2/… rather than at the registry root. Point ctcpy at the Nexus host and name the repo with a prefix. To copy an image from a docker.io proxy repo into a docker-prod hosted repo on the same Nexus:
export CTCPY_SRC_CREDS="user:password"
export CTCPY_DEST_CREDS="user:password"
ctcpy \
localhost:8081/library/hello-world:latest \
localhost:8081/library/hello-world:latest \
--plain-http-src --plain-http-dest \
--src-prefix /repository/docker.io \
--dest-prefix /repository/docker-prodAlternatively, configure a Docker HTTP connector port on each Nexus repo (Repository → the repo → HTTP connector). That serves the repo at the root
/v2/, and ctcpy (likedockerandskopeo) can then targethost:port/...with no prefix.
Use as a library
import { RegistryClient, copyImage, parseImageRef } from '@asteriusit/ctcpy';
const src = parseImageRef('localhost:8081/library/hello-world:latest');
const dst = parseImageRef('localhost:8081/library/hello-world:latest');
await copyImage(
{
repository: src.repository,
reference: src.reference,
client: new RegistryClient({
host: src.host,
plainHttp: true,
pathPrefix: '/repository/docker.io',
credentials: 'user:password',
}),
},
{
repository: dst.repository,
reference: dst.reference,
client: new RegistryClient({
host: dst.host,
plainHttp: true,
pathPrefix: '/repository/docker-prod',
credentials: 'user:password',
}),
},
);Make npx @asteriusit/ctcpy work in your organization
Publish this package to your internal Nexus npm hosted repository. Scoped
packages need --access when published to public registries; for an internal
Nexus repo the registry flag is what matters:
npm publish --registry https://nexus.corp/repository/npm-internal/Point the @asteriusit scope at that repository so it resolves without a global
registry change:
npm config set @asteriusit:registry https://nexus.corp/repository/npm-group/Then anyone whose npm is configured against your Nexus npm group can run
npx @asteriusit/ctcpy <src> <dest>.
Development
npm install
npm run build # compile TypeScript (src/ -> dist/)
npm run typecheck # tsc --noEmit
npm run lint # eslint (type-aware)
npm run format # prettier --write
npm test # build, then run the test suiteThe test suite spins up in-memory mock registries (root-served and Nexus-style path-prefixed), seeds a multi-arch image, runs the built CLI, and asserts byte-identical manifests/blobs plus idempotent re-runs.
Releasing
Publishing uses npm trusted publishing (OIDC) — no NPM_TOKEN secret. Pushing a semver tag triggers .github/workflows/release.yml, which builds, tests, and publishes with automatic provenance.
One-time bootstrap (required once per package)
npm's OIDC trusted publishing cannot perform a package's first publish — the package must already exist before a trusted publisher can be attached (an anti-name-hijacking safeguard; see npm/cli#8544). This bootstrap needs no CI token; a tokenless interactive login is enough.
Create the package once, tokenless, from a machine (browser-based login with 2FA — not an automation token):
npm login # opens the browser, uses your 2FA npm publish --access public # creates @asteriusit/ctcpy on npmThis bootstrap publish carries no provenance — that's expected. Provenance is generated automatically for every subsequent CI release via trusted publishing, so
provenanceis intentionally not forced inpublishConfig(forcing it would break this tokenless publish, which has no CI OIDC provider).Prefer a throwaway placeholder so CI still owns every real version? Publish
0.0.0first, then let the tag workflow publish the real one:npm version 0.0.0 --no-git-tag-version && npm publish --access public npm deprecate @asteriusit/[email protected] "placeholder for trusted-publishing bootstrap" git checkout package.json # restore the real versionAttach the trusted publisher on npmjs.com → the package → Settings → Trusted Publisher → GitHub Actions:
| Field | Value | | ----------------- | ------------- | | Organization/user |
asteriusit| | Repository |ctcpy| | Workflow filename |release.yml| | Allowed action |npm publish|
Every release after that
npm version patch # bumps package.json and creates a matching tag
git push --follow-tagsThe workflow verifies the pushed tag matches package.json's version, then publishes via OIDC with no secrets. Requirements (already set in the workflow): a GitHub-hosted runner, id-token: write, and npm ≥ 11.5.1.
Project layout
src/
cli.ts argument parsing and the executable entry point
copy.ts single- and multi-arch copy orchestration
registry.ts Docker Registry v2 client (auth, manifests, blobs, prefix)
reference.ts image-reference and prefix parsing
types.ts shared data-model types
errors.ts UsageError / RegistryError
logger.ts stderr progress + size/digest formatting
index.ts public library APILimitations (by design, keep it simple)
- No
docker-daemon:/oci-archive:transports — registry-to-registry only. - Foreign/urlless layers (
foreign.diff.tar.gzipwithurls) are not fetched from external URLs. - No signature copying.
