What’s New
Release notes for acs2md, newest first. Conversion behaviour comes from the shared Climakers ADF engine, so most output changes apply to acp2md as well.
v1.0.12
Restore from a backup
acs2md can now put content back into Confluence, not only take it out. space restore compares a backup with the space, shows the plan, asks, and then restores page by page, parents first: the pages and folders Confluence no longer has (--mode missing, the default), pages edited since (--mode changed), or both (--mode all). --create-space rebuilds a lost space, or a copy under a new key. Progress is saved after every page, so an interrupted restore resumes where it stopped, a page already in the space under its title is adopted rather than created twice, and links between restored pages are pointed at their new IDs once every page exists. page restore does the same for one page, from a space backup or from a file written by page get.
space restore and page restore are the only commands that write to Confluence. They never delete, trash, archive or move anything, never overwrite a content property an app keeps state in, and --dry-run shows the plan without writing. The version a restore writes names the backup’s date, never a local path. See Restore a Space and Restore Pages.
Check a space against its backup
space check reports, page by page, what changed since a backup: pages edited, renamed or moved; pages in the trash, archived, moved to another space or gone; pages that are new; and the same for folders and the space’s name and homepage. --deep compares page bodies, -f json gives a machine-readable report, and --fail-on-drift makes a scheduled job fail when the space drifted. Backups taken before manifests existed are compared as far as they allow. Nothing changes in Confluence or in the backup. See Check Against a Backup.
After a space restore, a restored page or folder has a new ID; space check matches it to its original through the restore’s progress file and reports it as ok. page check does the same comparison for one backed-up page: its state, version and body, and the attachments and labels it no longer has.
space get takes a restorable backup
Next to the page bodies, space get now writes .backup-manifest.json — every page’s ID, title, status, parent, position, version and body hash, the space’s name, type, description and homepage, and every Confluence folder the pages sit under — and saves attachments, labels and content properties by default. Each costs extra API calls; --include-attachments=false, --include-labels=false, --include-properties=false and --manifest=false turn them off. Unchanged attachments are not downloaded again on the next run. See Restorable backups.
space get now saves storage by default (it was atlas_doc_format),
because storage refers to attachments and pages by name, which survives a
restore that gives them new IDs. Pass --format atlas_doc_format for the
old behaviour. A directory that already holds a backup keeps its format
unless you pass --format, so an existing scheduled backup never mixes
.html and .json files. The first run of v1.0.12 on an existing backup
downloads every page once more to fill in the manifest; later runs skip
unchanged pages again. page get keeps atlas_doc_format as its default.
page get --attachments-dir <dir> downloads a page’s attachments into <dir>/<page-id>/ and describes them, so a single page can be backed up and restored with its files.
Every request has a deadline
The new http.client.request_timeout (default 10m, 0 disables it) bounds one request from sending it to reading the whole response, including its retries and Retry-After waits. Before, a connection that stalled hung the run until Ctrl-C. Existing config files do not have the key and get the default; doctor shows the value in a new HTTP request timeout row. The “Timed out waiting for the Confluence API” hint now names this setting instead of http.client.max_timeout, which caps the retry backoff and does not end a slow request. See Every request has a deadline.
Fixes
- Commands no longer hang when stdin is left open. Every command that takes a page ID, title, URL or space key read standard input to the end whenever it was not a terminal, even when the value was on the command line. From a CI runner, an ssh session or a tool that keeps stdin open, the command waited forever. Stdin is now read only when the argument is missing, so
echo MYSPACE | acs2md space get by-keystill works. - Re-activating with a renewed key works. A renewed subscription keeps its key, but
license activate --license-key <the same key>refused it with “license has expired” once the expiry had been seen locally. The command now asks the store and refreshes the installed license when the store reports the key active again. --forcedoes what its help promised with a key.license activate --license-key <new-key> --forcereplaces an active license, releasing its seat first; before, the flag only applied to license files. See Runningactivateagain.- The command trees are regenerated from the binaries. The acp2md reference had fallen behind and was missing the flags of
page convert,page countandpage properties; thetree --shortlisting in this manual now showspage check,page restore,space checkandspace restoretoo.
Under the hood
Writes go through a separate HTTP client that honours Retry-After on 429 only and never retries blindly, so a page create whose answer was lost is looked up instead of sent twice. The sync state, the manifest and attachment downloads are published through a temporary file and a rename, so a download that fails part way leaves nothing behind. Built with Go 1.27.3.
v1.0.10
Knowing when there is something to install
acs2md doctor reports the newest release available to you in a new Latest version row, alongside the command that installs it; the check never fails the diagnosis and says “could not check” when offline. After an ordinary command, acs2md also asks the update service at most once a day, waits at most two seconds, and prints a single line to stderr when a newer release exists. The notice never appears in CI, under sudo, when stderr is not a terminal, or after update, license, doctor and config. Turn it off with acs2md config set update.notice false. See The daily new-version notice.
acs2md update also keeps working when the store’s daily download limit is reached — each file may be downloaded ten times a day — and beyond that fetches the same release from Climakers’ own release storage, once a day per installation.
Activation is steadier on Linux
License files are encrypted against a machine identifier, and on Linux that identifier now survives kernel and microcode updates and VM resizes. Licenses activated before this release keep working: a subscription license moves to the new identifier the first time it is opened, and a signed offline license keeps the identifier it was issued for. Nothing needs re-activating. macOS is unaffected.
Fixes
acs2md updatesees a new release right away. Just after a release,updatecould report the previous version for several minutes because the update service answered from a cached copy. Installing, andupdate --check --force, now ask the service directly, so a release is offered within a minute of being published; a plainupdate --checkmay take up to two minutes.
v1.0.9
Fixes
license activate --license-key <file>says which flag takes a file. Passing a license file to--license-keyanswered “invalid license key format: expected UUID v4”; it now explains that a license file is activated with--license-fileand prints the exact command.
v1.0.8
Nothing you use changes: the update engine moved to a shared Climakers module, which ajat now uses too, and update behaves exactly as before.
v1.0.7
Offline licenses update too
A local license issued by licensegen with a signing key carries a signature the update service verifies on its own, so offline customers get acs2md update like everyone else; their downloads come from Climakers’ release storage rather than the store. Local licenses issued before signing existed are refused with “this local license is not signed” and a hint to activate a reissued file. See Your subscription and older versions.
v1.0.6
Updates from inside the tool
acs2md update installs the newest release your license includes: it checks Climakers for a newer release, downloads it, verifies its SHA-256 checksum and replaces the running binary. On macOS it installs the signed, notarized package and runs as sudo acs2md update; on Linux it replaces the binary in place and never uses sudo. --check reports without changing anything, --version v1.1.0 installs a specific release (which is also how you go back), --yes skips the prompt and is required in CI, and --format json is for scripts. After a subscription ends, releases published before the end date stay installable. The command needs no Confluence credentials and does not count toward the license’s command limit. See Updating acs2md.
--format json output changed in two ways
JSON output is now produced by Go’s encoding/json/v2. Two visible differences, both deliberate:
- an empty list or map prints as
[]/{}instead ofnull, so a page with no labels is"labels": []rather than"labels": null; <,>and&in strings are no longer escaped, so a title likeQ&A <draft>reads as written.
Key order, indentation and the trailing newline are unchanged, and sync state files remain readable by earlier versions. Scripts that test for null should test for an empty list instead.
Platform notes
- macOS 13 (Ventura) or later is required. The Go toolchain this release is built with dropped support for earlier macOS releases.
SSL_CERT_FILEandSSL_CERT_DIRare honoured on macOS. When either is set, certificate roots are loaded from those paths instead of the platform store — the behaviour Linux always had. Corporate-proxy setups that set these variables for other tools will see them apply here too;GODEBUG=x509sslcertoverrideplatform=0restores the previous behaviour.
v1.0.5
This release changed default Markdown output for pages containing panels, and
for anyone using --include-metadata. Both actions below are no-ops if you
never wrote the corresponding setting.
Action: check image.max_size_bytes
The default dropped to 2 MB, but a configuration written before this release almost certainly pins the old 10485760 — permissive enough to inline a page background and produce a file no editor will lay out. Configuration beats defaults, so it keeps winning:
acs2md config get image.max_size_bytes # 10485760 means the old value is pinned
acs2md config set image.max_size_bytes 2097152acp2md reads its own configuration, so run the same pair there if you use it.
Action: --resolve-users and --link-mentions now default on
Front matter carries display names and mentions link to people profiles. Where an export is published outside the organisation, --resolve-users=false keeps opaque account IDs instead.
Space conversion runs concurrently
space convert now converts 4 pages at once by default. Conversion is network-bound, so overlapping a few pages hides most of the per-page API latency. --concurrency 1 restores strictly sequential conversion.
Output is identical at any setting — pages are collected in input order regardless of which worker finishes first, so the directory layout, --rewrite-links and the summary counts do not change. See Throughput.
Retry-After is honoured
When Confluence answers a throttled request with 429 or 503 and names a delay, the client now waits exactly that long instead of guessing. This is what makes raising --concurrency safe: the server sets the pace rather than the client discovering it by failing.
A delay longer than 60 seconds is surfaced rather than slept, so a command never looks hung, and waiting is interruptible.
The retry backoff defaults changed, and upgrading does not reach an existing
install. They were raised from 10ms/100ms to 500ms/30s, but a config
file materialises every default when it is created and configuration beats
defaults. An older install keeps a 70 ms total budget across three attempts,
which cannot outlast a real throttling window. Run acs2md doctor to check,
then see Retries and rate limiting.
Images can be written as sidecar files
--image-sidecars saves each image to attachments/<page-id>/ beside the Markdown and links it relatively, instead of embedding a base64 data URI. The export stays self-contained either way — what changes is line length.
On one real page: 1,440,451 B and a 244,311-character line as base64, against 12,100 B and 379 characters as sidecars. Across a space it also deduplicates — an image reused on ten pages is stored once.
--obsidian turns this on, because base64 was the last thing in that preset that did not render in Obsidian.
Obsidian output
A full preset — callouts, heading anchors Obsidian resolves, and image sidecars — plus --wikilinks, --inline-tags, --panel-style and --media-dimensions. See Obsidian Vaults.
Panels render as GitHub alerts
> [!NOTE], > [!TIP], > [!WARNING], > [!CAUTION] replace the previous emoji-labelled blockquote. Alerts render on GitHub and Obsidian. Panel types with no alert equivalent keep the emoji form. --panel-style=emoji restores the old output.
Front matter is proper YAML
--include-metadata now emits deterministic, correctly quoted YAML that Obsidian reads as Properties and Jekyll, Hugo and Pandoc parse:
| Before | Now |
|---|---|
Title, AuthorID, CreatedAt, ID, LastOwnerID, OwnerID, ParentID, ParentType, Position, SpaceID, Status, SubType, VersionAuthorID, VersionCreatedAt, VersionMessage, VersionMinorEdit, VersionNumber | title, tags, author, created, updated, source, space, status, version, id, parent_id, owner_id |
Dates are YYYY-MM-DD, tags comes from the page’s Confluence labels, source is the browser URL, and empty fields are omitted. The dropped fields were Confluence bookkeeping no Markdown consumer used.
Labels are fetched automatically
With front matter enabled, tags is populated without passing --include-labels. On space convert, labels are not part of the bulk page listing, so --include-labels (default true) controls one extra request per page — set it to false to skip that cost.
Heading links resolve in the renderer you export for
The table of contents previously emitted GitHub-style slug anchors regardless of target. --panel-style=obsidian (and therefore --obsidian) now emits anchors Obsidian resolves.
Every command reports how long it took
✅ Sync complete!
✅ Converted: 835
⏱️ Took: 2m 14sThe figure covers the whole command, API calls and image downloads included. Page-level commands print the timing to stderr, never stdout, so redirects and pipes stay clean.
A second Ctrl+C force-quits
The first interrupt cancels cleanly and lets pages already in flight unwind, which is not instant when several are converting at once. Previously any further Ctrl+C was swallowed and a run that looked stuck could not be stopped.
Fixes
--debugproduced no output at all. It set the log level but left the destination at its default, which discards everything. It now logs to stderr unless--log-filesays otherwise.- A broken
log.level,log.formator unwritablelog.fileblocked every command — including theconfigcommands that repair it. Those now fall back to working settings for the run and name the key to change. - A global flag placed before an exempt command made it demand a licence or validate the configuration.
acs2md --log-file x.log doctortreated--log-fileas the command name, sodoctor— the command you run to diagnose licensing — refused to start on an unlicensed machine. space get --synccould delete files outside the output directory.- Two concurrent saves could corrupt the sync state, because every save wrote through the same fixed temporary filename.
- An invalid
http.client.proxyis now an error rather than a warning-and-continue. Silently sending traffic direct when asked to route it is, on a corporate network, worth stopping for.
See also
- Convert Spaces — the full flag reference
- Restore a Space — the backup, check and restore cycle
- Updating acs2md — installing a release from inside the tool
- Configuration — retries, timeouts, proxy and credentials