Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Git on Windows runners checks text out as CRLF, and `docx upgrade` embeds install.sh
# at build time and pipes it to `sh -s`, so the checked-out bytes are what ships.
*.sh text eol=lf
30 changes: 29 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-15-intel, macos-latest]
os: [ubuntu-latest, macos-15-intel, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
Expand All @@ -107,8 +107,36 @@ jobs:
if: runner.os == 'macOS'
run: sh scripts/sign-macos-binary.sh ./dist/docx
- name: Verify binary runs
# Git Bash on Windows, the shell `docx upgrade` needs there; bash also stops at
# the first failing line, where the default PowerShell would carry on.
shell: bash
run: |
./dist/docx --version
./dist/docx --help > /dev/null
./dist/docx read tests/fixtures/markdown-import.docx > /dev/null
./dist/docx validate tests/fixtures/markdown-import.docx
- name: Verify the installer on a Windows checkout
if: runner.os == 'Windows'
shell: bash
run: bun test tests/cli/installer.test.ts
- name: Unit tests on Windows (informational)
# Known Windows-only failures remain, so this reports without failing the job.
if: runner.os == 'Windows'
continue-on-error: true
timeout-minutes: 15
shell: bash
run: bun run test:unit
- name: Upgrade the compiled binary against a real release
# Runs last: it replaces dist/docx. The version is pinned so that a new release
# can't change what this checks, and only the release's assets are downloaded.
shell: bash
run: |
./dist/docx upgrade --dry-run
./dist/docx upgrade --to v0.28.0
./dist/docx --version | grep -F 0.28.0
- name: Verify the Windows swap
if: runner.os == 'Windows'
shell: bash
run: |
test -f dist/docx.exe.old
test ! -e dist/.docx-upgrade
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ These invariants are NOT SUGGESTIONS. These MUST be followed.
- **Track-changes is doc-level** — see [src/cli/track-changes](src/cli/track-changes/CLAUDE.md).
- **`docx render` is the only command that needs an external APPLICATION.** Every other verb works purely against the .docx zip + XML; `render` shells out to Word (macOS/Windows) or LibreOffice (cross-platform) to produce a PDF, then rasterizes in-process via the bundled `@hyzyla/pdfium` WASM package (no system tools needed for the rasterizer). The lens lives at [`@core/render`](src/core/render/CLAUDE.md) (`renderDocxPages` is the entry point); [`src/cli/render`](src/cli/render/CLAUDE.md) is a thin arg-parse shell. Agents that consume PNGs (and you, when verifying a fixture) use this command; the rest of the CLI never invokes it.
- **Raw OOXML enters only through the gate pipeline.** `docx raw insert`/`replace` is the one surface where the user hands us arbitrary XML, and every fragment runs `Raw.prepareFragment`'s gates before it may touch the tree (see [src/core/raw](src/core/raw/CLAUDE.md)): well-formedness via `XMLValidator` (our own parser NEVER throws — it silently mangles bad XML, so it can't be the gate), addressable roots only (`w:p`/`w:tbl`, or one `w:sectPr` replacing an `sN` — anything else has no locator and would vanish from `read`, breaking the write-read loop), inter-element whitespace strip (pretty-printed fragments corrupt Word), ECMA child-order checks against the same order tables the emitters use, namespace auto-declare/reject, reference integrity (dangling rIds/note ids reject; drawing/bookmark id collisions re-mint), and a final full-document baseline-diff schema validation (bundled ECMA-376 transitional XSDs via libxml2-wasm; a mutation may not ADD schema errors; `--no-validate` skips only this gate; `docx validate` exposes the same engine standalone). **Nothing is written when any gate fails** — all mutation is in-memory until the post-gate save. Inserted roots are stamped `dcx:raw="1"` (the `dcx` prefix rides `mc:Ignorable`, so Word and the validator skip it) and surface as a `raw` token on the `docx:p`/`docx:table` note + `rawXml` in the AST. Raw changes are never tracked (`--track` rejects); under doc-level tracking they apply untracked + audit comment, per the hyperlinks/images precedent. **Relationships are raw-addressable too** (`raw get --at rels|rIdN`, insert a `<Relationship>` fragment routed by shape, Id-preserving replace) with their own proportional gate set (Id/Type/Target/TargetMode is the whole rels schema; internal Targets must resolve to an existing part; colliding/renamed Ids reject). **Headers/footers are raw-GET-readable** by their `hdrN`/`ftrN` id — a read-only serialize of the `<w:hdr>`/`<w:ftr>` part (a read needs no gate); the id is a first-class locator (`headers`/`footers list` reports it, `read` leads its `docx:header`/`docx:footer` hint with it, `headers`/`footers set/clear --at ftrN` author by it), and authoring stays on the modeled `docx headers`/`footers` verbs. **OPC parts are addressable too, but NOT as locators** — locators address document content; parts are the container, so they get their own noun: `raw part list|get|add|replace|edit FILE --name NAME` (`add`: XML gated, binary verbatim, content type required or extension-Default; whole-part replace/edit with root tag preserved — unmodeled parts via `Pkg`, styles/numbering/settings/marginals via a VIEW reparse so the swap survives the save, and notes/comments/document.xml/rels rejecting toward their own surfaces because their ids pair with body markers). **`raw edit` / `raw part edit` is the patch loop as one gated call**: literal `--find`/`--with` over the target's exact serialization, all occurrences, zero matches errors, result runs the full replace gate set. Part names and native ids are the ONLY sub-document handles — no positional addressing inside a part, ever. The full embedded-object loop is `raw part add` → insert `<Relationship>` → insert the body fragment referencing the rId.
- **Installers fetch release ASSETS, never remote code.** Every install path resolves a release **tag** or the `latest` release pointer — never a branch — then downloads that release's prebuilt binary plus its published `SHA256SUMS` and verifies the digest before the binary lands. (`install.sh` is itself covered by `SHA256SUMS`, but nothing self-verifies it: checking the installer is the operator's step, which is why the README fetches it to a file instead of piping it.) None of them downloads a script and runs it — that was a Snyk CRITICAL on the skills.sh listing, because the skill's bootstrap used to `sh` a fetched `install.sh`, and neither a scanner nor a reviewer can audit a file the script goes off and gets. There is exactly ONE implementation of the install logic, `skills/docx-cli/scripts/install.sh`, delivered THREE ways, chosen by who is asking: as a release asset (the operator, by hand), inside the skill folder where `bootstrap.sh` runs it as a local sibling (an agent at session start), and EMBEDDED into the binary at build time via `import … with { type: "text" }` for `docx upgrade` (an installed human). Because of the third route install.sh must stay POSIX `sh` with no sibling or `$0` dependencies — `upgrade` pipes it to `sh -s`. **Don't collapse these into one route**: `bootstrap.sh` can't call `docx upgrade` (it must work when `docx` is absent, must not downgrade a local build, and must not trust the possibly-stale installer inside whatever binary is present), and `upgrade` can't fetch the script (that's the forbidden pattern). Note `upgrade` runs the installer from the release you're ON, not the one you're going TO, so an installer bug in vN can't be fixed by upgrading from vN. The repo-root `install.sh` is a byte-identical DEPRECATION SHIM, not a second implementation — bootstraps already published to npm/skills.sh (≤ v0.22.0) fetch `raw.githubusercontent.com/…/<tag>/install.sh`, so a tag whose tree lacks that path breaks every deployed skill's session-start check. `tests/cli/installer.test.ts` enforces the copy, the platform-table↔release-matrix agreement, and the absence of fetch-then-execute; delete the shim and its test once those bootstraps age out. It lives under `skills/` precisely so its sibling `bootstrap.sh` can delegate to it as a LOCAL file — executing a shipped sibling is not the flagged pattern; fetching a script and running it is. Keep the split by question: **install.sh answers "put `docx` here"** (detect platform, download, verify, place) and **bootstrap.sh answers "keep `docx` current"** (compare versions, update only if behind, never downgrade a local build, degrade to exit 0 offline, confirm the PATH `docx` is the one just installed). Don't reintroduce install logic into bootstrap. Posture differences are PARAMETERS, not forks: `bootstrap.sh` passes `REQUIRE_CHECKSUM=1` so an agent gets a refusal where a human running install.sh by hand gets a warn-and-install. The admin controls are parameters too: `DOCX_CLI_VERSION` (validated against the same release-tag shape as `upgrade --to`, since it lands in a URL) pins what bootstrap installs and, like `DOCX_CLI_NO_UPDATE=1`, stops it from moving a binary that's already present — SECURITY.md's "Deploying in an organization" documents them. Order the checks cheapest-first: platform, checksum policy, `PREFIX` writability, the few-hundred-byte manifest, and only then the ~100 MB binary.
- **Installers fetch release ASSETS, never remote code.** Every install path resolves a release **tag** or the `latest` release pointer — never a branch — then downloads that release's prebuilt binary plus its published `SHA256SUMS` and verifies the digest before the binary lands. (`install.sh` is itself covered by `SHA256SUMS`, but nothing self-verifies it: checking the installer is the operator's step, which is why the README fetches it to a file instead of piping it.) None of them downloads a script and runs it — that was a Snyk CRITICAL on the skills.sh listing, because the skill's bootstrap used to `sh` a fetched `install.sh`, and neither a scanner nor a reviewer can audit a file the script goes off and gets. There is exactly ONE implementation of the install logic, `skills/docx-cli/scripts/install.sh`, delivered THREE ways, chosen by who is asking: as a release asset (the operator, by hand), inside the skill folder where `bootstrap.sh` runs it as a local sibling (an agent at session start), and EMBEDDED into the binary at build time via `import … with { type: "text" }` for `docx upgrade` (an installed human). Because of the third route install.sh must stay POSIX `sh` with no sibling or `$0` dependencies — `upgrade` pipes it to `sh -s` — and must stay LF even on a Windows checkout (`.gitattributes` pins `*.sh`; the Windows job in `ci.yml` runs the guard), since the checked-out bytes are what the binary embeds. Its checksum stays in shell for the same reason: install.sh runs where no `docx` or Bun exists yet. **Don't collapse these into one route**: `bootstrap.sh` can't call `docx upgrade` (it must work when `docx` is absent, must not downgrade a local build, and must not trust the possibly-stale installer inside whatever binary is present), and `upgrade` can't fetch the script (that's the forbidden pattern). Note `upgrade` runs the installer from the release you're ON, not the one you're going TO, so an installer bug in vN can't be fixed by upgrading from vN. The repo-root `install.sh` is a byte-identical DEPRECATION SHIM, not a second implementation — bootstraps already published to npm/skills.sh (≤ v0.22.0) fetch `raw.githubusercontent.com/…/<tag>/install.sh`, so a tag whose tree lacks that path breaks every deployed skill's session-start check. `tests/cli/installer.test.ts` enforces the copy, the platform-table↔release-matrix agreement, and the absence of fetch-then-execute; delete the shim and its test once those bootstraps age out. It lives under `skills/` precisely so its sibling `bootstrap.sh` can delegate to it as a LOCAL file — executing a shipped sibling is not the flagged pattern; fetching a script and running it is. Keep the split by question: **install.sh answers "put `docx` here"** (detect platform, download, verify, place) and **bootstrap.sh answers "keep `docx` current"** (compare versions, update only if behind, never downgrade a local build, degrade to exit 0 offline, confirm the PATH `docx` is the one just installed). Don't reintroduce install logic into bootstrap. Posture differences are PARAMETERS, not forks: `bootstrap.sh` passes `REQUIRE_CHECKSUM=1` so an agent gets a refusal where a human running install.sh by hand gets a warn-and-install. The admin controls are parameters too: `DOCX_CLI_VERSION` (validated against the same release-tag shape as `upgrade --to`, since it lands in a URL) pins what bootstrap installs and, like `DOCX_CLI_NO_UPDATE=1`, stops it from moving a binary that's already present — SECURITY.md's "Deploying in an organization" documents them. Order the checks cheapest-first: platform, checksum policy, `PREFIX` writability, the few-hundred-byte manifest, and only then the ~100 MB binary.
- **No undo, no journal.** Mutating commands overwrite `FILE` in place; git is the history. `-o/--output PATH` writes a parallel file; `--dry-run` previews (wins over `--output`). Track changes is the closest thing to a journal, but not fully reliable because some operations are irreversible in OOXML. Our system denotes these with comments.

## Commands
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ GitHub Actions (`.github/workflows/ci.yml`) runs these jobs on push to `main` an
| `unit-tests` | `bun run test:unit` and `bun run test:benchmark` (no model calls) |
| `macos-output` | Output contract and real-pipe regressions on macOS with Bun 1.3.10 and latest |
| `integration-tests` | Installs LibreOffice, runs `bun run test:integration` |
| `build-binary` | Builds on Linux and native Intel/ARM macOS; signs/verifies macOS binaries; runs version/help, read, and schema-validation smoke checks |
| `build-binary` | Builds on Linux, native Intel/ARM macOS, and Windows; signs/verifies macOS binaries; runs version/help, read, and schema-validation smoke checks, then a real `docx upgrade` to a pinned release; on Windows, under Git Bash, also runs the installer tests and an informational unit-test pass |

`.github/workflows/release.yml` triggers on `v*` tags, matrix-builds the five binaries, and uploads them to a GitHub Release via [`softprops/action-gh-release`](https://github.com/softprops/action-gh-release).

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ bunx bun-docx read doc.docx # run without installing

Every command works directly on the `.docx` file. Only `docx render` (page images, for checking layout) needs Word (macOS/Windows) or LibreOffice installed.

**Upgrading:** `docx upgrade` replaces a standalone binary in place, using the installer embedded in the binary at build time (never fetched), pinned to a release tag and SHA-256-verified. `docx upgrade --to v0.27.0` pins a version; `--dry-run` reports what would change. It won't touch an npm install (use your package manager), a renamed binary, or Windows, where a running `.exe` can't be replaced (download the new one by hand).
**Upgrading:** `docx upgrade` replaces a standalone binary in place, using the installer embedded in the binary at build time (never fetched), pinned to a release tag and SHA-256-verified. `docx upgrade --to v0.27.0` pins a version; `--dry-run` reports what would change. It won't touch an npm install (use your package manager) or a renamed binary. On Windows it needs Git Bash (for `sh`); a running `.exe` can be renamed but not overwritten, so the verified binary is swapped in and the old one is left as `docx.exe.old` until the next upgrade.

### Use it from your own code

Expand Down
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ bun add -g bun-docx
# or: npm install -g bun-docx
```

On a macOS/Linux standalone-binary install, run **`docx upgrade`** — it replaces the binary in place, pinned and checksum-verified. (Or re-run the installer by hand from the [latest release](https://github.com/kklimuk/docx-cli/releases/latest).) On Windows, download and verify the replacement binary from Releases. The skill's `scripts/bootstrap.sh` also updates a stale binary, but only when an agent runs it: the skill tells the agent to run it when `docx` isn't installed, and nothing runs it automatically.
On a standalone-binary install, run **`docx upgrade`** (on Windows, from Git Bash) — it replaces the binary in place, pinned and checksum-verified. (Or re-run the installer by hand from the [latest release](https://github.com/kklimuk/docx-cli/releases/latest).) The skill's `scripts/bootstrap.sh` also updates a stale binary, but only when an agent runs it: the skill tells the agent to run it when `docx` isn't installed, and nothing runs it automatically.

## Install integrity

Expand Down Expand Up @@ -56,7 +56,7 @@ docx-cli runs entirely **locally** against `.docx` files on disk. **No command s
Network access happens in three places in docx-cli itself (plus your package manager, if you install through npm), and none of them uploads anything:

- **`skills/docx-cli/scripts/install.sh`** (also published as the release asset) — fetches the prebuilt `docx` binary and its `SHA256SUMS` manifest from this repo's GitHub Releases over HTTPS. **`scripts/bootstrap.sh`** additionally reads the releases page to learn the latest tag.
- **`docx upgrade`** — downloads a release binary, and only when you run it. It carries `install.sh` **embedded at build time** (never fetched) and executes that copy with `REQUIRE_CHECKSUM=1`, so the new binary is pinned to a tag and SHA-256-verified before it replaces anything. It refuses to touch a package-manager-owned install. Note the installer it runs is the one embedded in the release you are _currently on_, not the target's — so an installer bug shipped in vN can't be fixed by upgrading from vN; fetch that release's `install.sh` by hand instead (see above).
- **`docx upgrade`** — downloads a release binary, and only when you run it. It carries `install.sh` **embedded at build time** (never fetched) and executes that copy with `REQUIRE_CHECKSUM=1`, so the new binary is pinned to a tag and SHA-256-verified before it replaces anything. On Windows the installer writes into a staging folder beside the binary and only the verified binary is swapped in; a failed or interrupted upgrade leaves `docx.exe` as it was. It refuses to touch a package-manager-owned install. Note the installer it runs is the one embedded in the release you are _currently on_, not the target's — so an installer bug shipped in vN can't be fixed by upgrading from vN; fetch that release's `install.sh` by hand instead (see above).
- **`docx images add --image https://…`** — the one verb that fetches _content_ you asked for, when and only when you pass an `http(s)` source (a local path or `--markdown` with a local ref never does). It is guarded against SSRF: private/link-local addresses are blocked, redirects are walked manually and re-checked, capped at 5 hops.

Your document content is never sent anywhere by any of them.
Expand Down Expand Up @@ -107,7 +107,7 @@ Nothing in docx-cli phones home after that.
- `docx-windows-x64.exe` is not Authenticode-signed, so SmartScreen, WDAC, or AppLocker may block it. Allowlist it by hash.
- Verify it with `Get-FileHash docx-windows-x64.exe -Algorithm SHA256` against `SHA256SUMS`.
- `install.sh` also works under Git Bash/MSYS.
- `docx upgrade` can't replace a running `.exe`, so it refuses on Windows. Install the new version the same way as the first.
- `docx upgrade` works from Git Bash. A running `.exe` can't be overwritten, so the installer writes to a staging folder beside it, and only the verified binary is swapped in; the old one is left as `docx.exe.old` until the next upgrade.

**macOS.** Release binaries are ad-hoc signed and signature-verified before checksumming, but not Developer ID-signed or notarized. Downloading with `curl` (as the installer does) doesn't set the quarantine flag. A browser download does, so Gatekeeper will ask.

Expand Down
Loading
Loading