Skip to content

feat(dist): the installer, the two channels, the plugin, and the socket (9.14, 10.1-10.6) - #16

Merged
protonspy merged 3 commits into
mainfrom
feat/distribution
Aug 1, 2026
Merged

feat(dist): the installer, the two channels, the plugin, and the socket (9.14, 10.1-10.6)#16
protonspy merged 3 commits into
mainfrom
feat/distribution

Conversation

@protonspy

@protonspy protonspy commented Aug 1, 2026

Copy link
Copy Markdown
Owner

The last group in plans/open-wiki.md. Every box in the plan is now ticked.

What changed

9.14 — cold start (TDD). The CLI is one bundled file, and when the desktop
application already has the project open, ow read and the validate path ask it
over a local socket instead of starting a process — which a PreToolUse hook
does on every page write. The socket carries read and validate and never
write:
a write over it would be a second writer into a project the application
has open, with no snapshot of its own, logged as something the application did.
Both directions are authenticated (a token in the app's data directory, an HMAC
over the client's nonce coming back), because the endpoint name is a hash of a
directory path — obscure, not secret — and whatever holds it decides what gets
printed into an agent's context. Absence is not failure: nothing listening means
the CLI runs standalone.

Red was observed first — seven assertions failing against signature-only stubs.

10.1 — the installer. NSIS, per-user, ffmpeg and recorder.exe embedded, and
an ow shim on PATH. With no arguments it opens the application scoped to the
shell's directory; with a subcommand it runs the CLI headless through
ELECTRON_RUN_AS_NODE=1, which is the only Node an installer promising no
external dependency can count on. The shim gets its own bin/ directory and only
that goes on PATH.

10.2 / 10.3 — releasing from a tag. One version across the installer and the
npm package, checked before anything is built and against both registries. The
reversible step (the GitHub release) runs before the permanent one (npm), a
prerelease publishes to next, and provenance is on with id-token: write.

10.4 — winget and Scoop manifests, generated from the tag and the hash of the
installer being released, attached to it. Scoop runs the NSIS setup rather than
pretending the download is portable.

10.5 — the README, with the recording notice, the responsibility to inform
participants, what SmartScreen on an unsigned build means, and what committing a
wiki puts in front of everyone with repository access.

10.6 — the Claude Code plugin. The hooks and /ow-init, never the skills
(adr:0015 — the convention has one home and it is the project) and never a
.mcp.json. HOOK_MATCHERS is one declaration the plugin is tested against, so
the two hook files cannot drift.

How it was verified

pnpm test — 1059 tests across five packages. pnpm test:coverage clears the
76% floor per package. pnpm run typecheck, pnpm lint, prettier --check and
scc validate are all clean.

The built artifacts were run, not just built: the bundled ow.mjs scaffolds
a project, checks it, and reads a page back. That is what found the duplicated
shebang and the require stub, neither of which any test would have.

What the reviews found, and what it cost

code-review and security-review both ran before this was pushed. The second
commit closes what they found — the packaged application could not start, the
npm package could not be installed, the shim pointed at a file that is never
packaged, the socket trusted whoever answered it, and the release could publish
half of itself. Details are in that commit's message.

What is not verified here

No packaging run happens in CI, so electron-builder --win and the NSIS
script are exercised only by a tag push. The installer script now uses nothing
beyond the headers NSIS itself ships, which was the largest of the unverifiable
risks; the rest is a first release's job to confirm.

vars.FFMPEG_SHA256 is on the release critical path and the workflow now fails
early and by name when it is unset.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added ow read <slug> to retrieve wiki pages from the command line.
    • Added the Open Wiki plugin with write safeguards and project initialization support.
    • Added Windows installation with PATH integration and bundled runtime resources.
    • Added distribution support for npm, Winget, and Scoop releases.
  • Documentation

    • Added setup, usage, privacy, consent, plugin, and installation guidance.
  • Quality

    • Added automated validation for plugins, releases, packaging, and runtime behavior.

protonspy and others added 2 commits August 1, 2026 16:49
Plan 9.14 and 10.1 to 10.6 — the last of the plan.

**9.14 is two halves and both are here.** The bundle, because an unbundled CLI
pays Node's module resolution on every invocation and a `PreToolUse` hook
fires it on *every page write* — so the cost is not paid once when a person
types a command, it is paid every time an agent touches a page. And the
socket: when the desktop application already has the project open, a query is
a round trip on a named pipe rather than a process start.

The socket carries read and validate and **never** write. That is not scope,
it is the safety of the idea: a write over the socket would be a second writer
into a project the application has open, with no snapshot of its own, and the
operation log would record it as something the application did rather than
something an agent did. Both paths call the same functions, so "both paths
produce the same answer" is true by construction rather than by two
implementations agreeing — and absence is not failure, because a CLI that
needed the application running would be the opposite of what `adr:0013` says.

Red observed first: 7 assertion failures against signature-only stubs.

The installer embeds ffmpeg and `recorder.exe` and puts `ow` on PATH, because
`adr:0013` makes `ow` in a directory the way a project is opened — an
application the user has to add to PATH by hand is one whose main entrance is
undocumented.

Two artifacts ship from one tag and are checked to agree *before* anything is
published. `adr:0014` named the cost it accepted — a skew "fails looking like
corrupted state rather than a bad install" — so the check is a tested module
rather than a shell snippet, and it names every artifact that disagrees rather
than the first.

The winget and Scoop manifests are generated from the tag and the hash that
was just published, and both quote it. A package manager that downloads
without verifying is the fetch-and-execute this product refuses to ship, which
is the same argument `fetch-ffmpeg.mjs` already makes about its own download.
Generated rather than hand-maintained because a manifest carrying last
release's hash fails as "the download is corrupt" rather than "somebody forgot
to update a file".

The plugin ships the hooks and the scaffolding command and deliberately not
the skills — `adr:0015` gives the convention one home and it is the project,
so a copy here would be a second one and two copies of a convention drift.
Not a `.mcp.json` either: its contents name *other* projects on that person's
machine. `check-plugin.mjs` enforces both, because `claude plugin validate`
needs the CLI installed and a check that silently passes when its tool is
missing is not a check.

The README carries what the plan asked it to and does not soften any of it:
that the installer is unsigned and what SmartScreen will therefore say, that
telling the other people in a call they are being recorded is the user's
responsibility and in many places the law, and that committing a wiki puts
`raw/` and `wiki/` in front of everyone with repository access — including
`.state/`, which is where a redaction survives the redaction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3VYWWTZtEE2NxPiksKsAK
The reviews found the packaging did not work and the socket trusted whoever
answered it. Both are fixed here, with what they cost written down.

**The packaged application could not start, and would not have recorded.**
The main bundle was CommonJS, so esbuild erased `import.meta.url` into an
empty object — three call sites, `fileURLToPath(undefined)` at module load,
and a *warning* rather than an error, so the packaging run stayed green.
It is ESM now, the sandboxed preload is `.cjs` because a sandboxed preload
cannot be an ES module, and both carry a `createRequire` shim: the ESM
output's `require` stub throws, and `yaml` calls `require("process")` on the
first page parsed. `resources.ts` states where ffmpeg and `recorder.exe` sit
in the packaged app instead of counting directories up from a source file,
which is three different depths flattened into one bundle. The preload took
`CHANNELS` from `ipc.ts` and dragged the whole main graph in behind it —
273 KB down to 5.4 KB through `channels.ts`.

**The published npm package could not be installed.** It declared two
`workspace:*` dependencies that are never published, so every `npx open-wiki`
would 404, and left three libraries external that npm installs for nobody.
The bundle carries the graph; the manifest declares nothing. Running the
built `ow.mjs` — which nothing had done — found two more: a duplicated
shebang on line 2, and the `require` stub above.

**The `ow` shim pointed at a file that is never packaged**, and `.mjs` has no
Windows file association to be invoked through anyway. It runs the CLI bundle
through `open-wiki.exe` with `ELECTRON_RUN_AS_NODE=1`, which is the only Node
an installer promising no external dependency can count on. It also passed
`--project "%CD%"`, overriding the one test that makes `ow` in a download
folder open the launcher rather than treating that folder as a wiki. The shim
now lives in its own `bin/` directory and only that goes on PATH, so nothing
else in the installation can be shadowed by a file dropped beside it. `EnVar`
is gone — a plugin that may not be in whichever NSIS electron-builder
downloads fails at `makensis` on a machine that is not this one.

**The socket trusted the endpoint name.** It is a hash of a directory path:
obscure, not secret, and off Windows it sat in world-writable `/tmp` where no
legitimate listener ever exists. A failed bind was swallowed, so a squatter
won silently and `ow read` printed its bytes as trusted wiki content into an
agent's context. Both directions are authenticated now — a random token in
the application's data directory, 0600 in a 0700 directory, and an HMAC over
the client's nonce coming back — both buffers are capped, connections are
bounded, the listen error is reported, and the project key is normalised so
two spellings of one directory reach one pipe. The read is confined to
`wiki/` rather than to the project, matching `mcp/src/tools.ts`.

**The release could publish half of itself.** npm ran before the GitHub
release: a failure after it left a permanent version on the registry with no
installer and no way to re-run the tag. Reversed, and the republish guard now
checks both registries. `id-token: write` and `registry-url` were both
missing, so provenance and authentication would each have failed; the tag is
no longer interpolated into a `run:` body in the job holding the npm token
and the signing certificate; a prerelease goes to the `next` dist-tag rather
than becoming what `npx open-wiki` resolves to; and the manifests 10.4
generates are attached to the release instead of being zipped and dropped.

**The plugin.** `continue-on-error` made `claude plugin validate --strict`
report success whatever it said. Its hooks dropped `Bash` — the matcher whose
whole purpose is shell writes — and ran `npx -y open-wiki` unpinned on every
page write, which is the cost the CLI bundle exists to remove and a skew 10.3
exists to prevent. `HOOK_MATCHERS` is now one declaration the plugin is
tested against, and `check-plugin.mjs` refuses an unpinned hook.

README: winget and Scoop were offered as if published. Scoop is, from the
manifest each release attaches; winget is not, and says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3VYWWTZtEE2NxPiksKsAK
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: adc13823-baa4-4d3d-ae1f-8d6c9a7d9426

📥 Commits

Reviewing files that changed from the base of the PR and between ec85f78 and 6c1117c.

📒 Files selected for processing (4)
  • .claude-plugin/marketplace.json
  • .github/workflows/ci.yml
  • packages/cli/tests/release.spec.ts
  • scripts/ci/check-plugin.mjs
🚧 Files skipped from review as they are similar to previous changes (4)
  • .claude-plugin/marketplace.json
  • scripts/ci/check-plugin.mjs
  • packages/cli/tests/release.spec.ts
  • .github/workflows/ci.yml

📝 Walkthrough

Walkthrough

The change adds authenticated CLI-to-desktop queries, packaged desktop and CLI builds, Open Wiki plugin manifests and validation, Windows launcher support, release manifest generation, npm publishing, documentation, and tests.

Changes

Open Wiki access and CLI

Layer / File(s) Summary
Authenticated query flow
packages/access/..., packages/cli/src/main.ts, packages/cli/tests/main.spec.ts
The access package adds project-scoped authenticated sockets for read and validate queries. ow read uses the running application when available and falls back to local handling.
Packaged desktop application
apps/desktop/..., scripts/build-main.mjs, package.json, .gitignore, eslint.config.js
The desktop app bundles main and preload processes, resolves packaged binaries, serves project queries, and installs a Windows ow launcher.
Plugin manifests and hook validation
.claude-plugin/..., plugins/open-wiki/..., scripts/ci/check-plugin.mjs, .github/workflows/ci.yml, packages/cli/src/install.ts
The plugin metadata and hooks are added. Validation checks paths, versions, hooks, and prohibited files. CI runs the validation job.
CLI packaging and release automation
packages/cli/package.json, scripts/build-cli.mjs, scripts/ci/..., .github/workflows/release.yml, README.md, plans/open-wiki.md
The CLI is bundled for npm publication. Release scripts validate versions, generate Winget and Scoop manifests, prevent republishing, and publish release artifacts and npm packages.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI as ow read
  participant Socket as askRunningApp
  participant App as serveQueries
  participant Wiki as Project wiki files
  CLI->>Socket: Send authenticated read request
  Socket->>App: Connect using project socket
  App->>Wiki: Read indexed page
  Wiki-->>App: Return page content
  App-->>Socket: Return signed response
  Socket-->>CLI: Verify response and print page
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately identifies the distribution work and names several primary additions, including the installer, plugin, and socket.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/distribution

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (5)
plugins/open-wiki/README.md (1)

5-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a language to the fenced code block.

The fenced block has no language tag. markdownlint-cli2 flags this as MD040. Add a language such as text to the fence.

📝 Proposed fix
-```
+```text
 /plugin marketplace add protonspy/open-wiki
 /plugin install open-wiki@protonspy
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @plugins/open-wiki/README.md around lines 5 - 8, Update the fenced code block
in the README to include a language tag, using text or another appropriate
plain-text language, while preserving the commands unchanged.


</details>

<!-- cr-comment:v1:1332452798af9287ef619484 -->

_Source: Linters/SAST tools_

</blockquote></details>
<details>
<summary>.github/workflows/ci.yml (1)</summary><blockquote>

`150-156`: _🔒 Security & Privacy_ | _🔵 Trivial_ | _⚡ Quick win_

**Pin the Claude Code CLI version installed in CI.**

`npm i -g `@anthropic-ai/claude-code`` installs whatever is `latest` on the registry. The package publishes new releases frequently, so this CI step is not reproducible: two runs on the same commit can install different CLI versions, and a compromised or broken release can affect CI unexpectedly. Pin the version installed here.






<details>
<summary>🔧 Proposed fix</summary>

```diff
-          if ! npm i -g `@anthropic-ai/claude-code` >/dev/null 2>&1; then
+          if ! npm i -g `@anthropic-ai/claude-code`@<pinned-version> >/dev/null 2>&1; then

Confirm the intended pinned version and whether it should be updated alongside a Renovate/Dependabot rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 150 - 156, Pin the package version in
the global installation command within the “claude plugin validate --strict”
workflow step instead of installing the registry’s latest release. Use the
confirmed intended `@anthropic-ai/claude-code` version, and preserve the existing
skip behavior and strict validation command; update dependency automation
configuration only if the repository already uses it for this package.

Source: Linters/SAST tools

eslint.config.js (1)

20-23: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

"**/build/**" makes the existing "apps/desktop/build/**" entry redundant.

The new glob already covers apps/desktop/build/. Remove the narrower entry so one rule governs generated bundles.

♻️ Proposed cleanup
       "**/release/**",
       "vendor/**",
-      "apps/desktop/build/**",
     ],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@eslint.config.js` around lines 20 - 23, Remove the redundant
"apps/desktop/build/**" ignore entry from the ESLint configuration, keeping the
broader "**/build/**" pattern as the single rule governing generated bundles.
packages/access/tests/socket.spec.ts (1)

137-181: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a large page with non-ASCII characters to the end-to-end test.

Every page in this spec is small ASCII, so each response arrives in one chunk. That hides the chunk-boundary decode defect raised on packages/access/src/socket.ts Line 314. A page of a few hundred kilobytes containing multi-byte characters exercises the multi-chunk path.

Note that such a page also exceeds MAX_LINE_BYTES, so askRunningApp returns null by design. Size the test body under MAX_LINE_BYTES but over one socket chunk, for example 32 KiB of a repeated multi-byte character, to assert that the wire answer still equals the standalone answer byte for byte.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/access/tests/socket.spec.ts` around lines 137 - 181, Add a large
page containing repeated multi-byte characters to the end-to-end test around
serveQueries, askRunningApp, and handleRequest, keeping the payload below
MAX_LINE_BYTES but larger than one socket chunk (for example, about 32 KiB).
Assert that the over-wire response equals the standalone response byte for byte,
while preserving the existing server cleanup.
.github/workflows/release.yml (1)

166-179: 🔒 Security & Privacy | 🔵 Trivial | ⚖️ Poor tradeoff

Consider replacing the third-party release action with the built-in gh CLI.

Static analysis notes that softprops/action-gh-release@v2's functionality is already available on the runner through the gh CLI, which is already used elsewhere in this job (the "Refuse to republish" step). Replacing the third-party action reduces the supply-chain surface for a job that already holds write permissions and secrets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 166 - 179, Replace the
softprops/action-gh-release step with a shell step using the runner’s existing
gh CLI, preserving tag gating, release-note generation, prerelease detection
from github.ref_name, all four uploaded files, and unmatched-file failure
behavior. Reuse the job’s existing release context and authentication setup from
the “Refuse to republish” step.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Around line 135-136: Update the actions/checkout@v4 step in the CI job to set
persist-credentials to false, ensuring the GitHub token is not retained in the
repository configuration while preserving the checkout behavior.

In @.github/workflows/release.yml:
- Around line 76-91: Restrict caching in the release job to trusted,
workflow-specific entries: update the setup-node pnpm cache and
Swatinem/rust-cache configuration so neither can restore cache data shared with
lower-trust workflows, using workflow/ref-scoped keys where supported or
disabling these caches and retaining dependency installation correctness.

In `@apps/desktop/src/main/index.ts`:
- Around line 75-79: Guard desktop startup in the main process before
initializing serveQueries so duplicate ow launches for the same project are
rejected via app.requestSingleInstanceLock() or an equivalent single-instance
mechanism. Ensure the duplicate process exits without invoking serveQueries,
preserving the first process’s shared query token and listener.

In `@packages/access/src/socket.ts`:
- Around line 314-321: Import StringDecoder from node:string_decoder and create
a separate decoder per connection. In packages/access/src/socket.ts lines
314-321, update the askRunningApp data handler to append decoder.write(chunk);
apply the same change in packages/access/src/socket.ts lines 213-218 within the
serveQueries connection handler, preserving each handler’s existing buffering
and termination behavior.
- Around line 254-261: Update the token cleanup around the server error and
close handlers in serveQueries to retain the exact token value written by that
server and remove the shared token file only when its current contents still
match that value. Preserve the existing cleanup and onError behavior while
preventing a failed second server from deleting the first server’s token.

In `@packages/cli/tests/main.spec.ts`:
- Around line 192-195: Update the test block containing “prints the page when no
application is listening” to isolate application-data resolution by setting
APPDATA and, on non-Windows platforms, HOME to a temporary fixture directory
before invoking main. Restore the environment after the test so
defaultAppDataDir and endpointDir cannot create directories in the developer’s
real profile.

In `@README.md`:
- Around line 42-44: Update the fenced code block containing “npx open-wiki
init” in README.md to specify the bash language identifier, preserving the
command and surrounding documentation unchanged.

In `@scripts/build-cli.mjs`:
- Around line 16-23: Update the esbuild configuration in the CLI build script to
use an output directory with code splitting enabled, so the dynamic-import graph
is emitted as lazy chunks instead of being inlined. Ensure the generated entry
filename remains aligned with the packages/cli package.json bin.ow target,
updating that target if necessary.

---

Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 150-156: Pin the package version in the global installation
command within the “claude plugin validate --strict” workflow step instead of
installing the registry’s latest release. Use the confirmed intended
`@anthropic-ai/claude-code` version, and preserve the existing skip behavior and
strict validation command; update dependency automation configuration only if
the repository already uses it for this package.

In @.github/workflows/release.yml:
- Around line 166-179: Replace the softprops/action-gh-release step with a shell
step using the runner’s existing gh CLI, preserving tag gating, release-note
generation, prerelease detection from github.ref_name, all four uploaded files,
and unmatched-file failure behavior. Reuse the job’s existing release context
and authentication setup from the “Refuse to republish” step.

In `@eslint.config.js`:
- Around line 20-23: Remove the redundant "apps/desktop/build/**" ignore entry
from the ESLint configuration, keeping the broader "**/build/**" pattern as the
single rule governing generated bundles.

In `@packages/access/tests/socket.spec.ts`:
- Around line 137-181: Add a large page containing repeated multi-byte
characters to the end-to-end test around serveQueries, askRunningApp, and
handleRequest, keeping the payload below MAX_LINE_BYTES but larger than one
socket chunk (for example, about 32 KiB). Assert that the over-wire response
equals the standalone response byte for byte, while preserving the existing
server cleanup.

In `@plugins/open-wiki/README.md`:
- Around line 5-8: Update the fenced code block in the README to include a
language tag, using text or another appropriate plain-text language, while
preserving the commands unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 88e08c05-adc2-4202-a226-7408edc09a24

📥 Commits

Reviewing files that changed from the base of the PR and between 69003a0 and ec85f78.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (39)
  • .claude-plugin/marketplace.json
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • .gitignore
  • README.md
  • apps/desktop/build-resources/installer.nsh
  • apps/desktop/package.json
  • apps/desktop/src/main/channels.ts
  • apps/desktop/src/main/index.ts
  • apps/desktop/src/main/ipc.ts
  • apps/desktop/src/main/preload.ts
  • apps/desktop/src/main/resources.ts
  • apps/desktop/tests/resources.spec.ts
  • eslint.config.js
  • package.json
  • packages/access/package.json
  • packages/access/src/config/app-dir.ts
  • packages/access/src/config/secrets.ts
  • packages/access/src/socket.ts
  • packages/access/tests/socket.spec.ts
  • packages/cli/package.json
  • packages/cli/src/cli.ts
  • packages/cli/src/install.ts
  • packages/cli/src/main.ts
  • packages/cli/tests/install.spec.ts
  • packages/cli/tests/main.spec.ts
  • packages/cli/tests/release.spec.ts
  • packages/cli/tsconfig.json
  • packages/cli/types/release-scripts.d.ts
  • plans/open-wiki.md
  • plugins/open-wiki/.claude-plugin/plugin.json
  • plugins/open-wiki/README.md
  • plugins/open-wiki/commands/ow-init.md
  • plugins/open-wiki/hooks/hooks.json
  • scripts/build-cli.mjs
  • scripts/build-main.mjs
  • scripts/ci/check-plugin.mjs
  • scripts/ci/package-manifests.mjs
  • scripts/ci/release-version.mjs

Comment thread .github/workflows/ci.yml
Comment on lines +135 to +136
steps:
- uses: actions/checkout@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Set persist-credentials: false on this checkout.

This job later runs npm i -g @anthropic-ai/claude-code``, third-party code with a postinstall path. By default, actions/checkout leaves the `GITHUB_TOKEN` credential in `.git/config`. If the installed package or one of its dependencies is compromised, it can read and exfiltrate that token.

Add persist-credentials: false, since this job does not need to push or fetch with the token afterward.

🔒 Proposed fix
       - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
steps:
- uses: actions/checkout@v4
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
🧰 Tools
🪛 zizmor (1.28.0)

[warning] 136-136: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 135 - 136, Update the
actions/checkout@v4 step in the CI job to set persist-credentials to false,
ensuring the GitHub token is not retained in the repository configuration while
preserving the checkout behavior.

Source: Linters/SAST tools

Comment on lines 76 to +91
- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
# Without this, `setup-node` writes no .npmrc and NODE_AUTH_TOKEN is
# read by nothing — the publish below would be unauthenticated.
registry-url: https://registry.npmjs.org

- run: pnpm install --frozen-lockfile

# The installer carries them; `vendor/ffmpeg/` is gitignored and fetched
# with hash verification, and the recorder is the one Rust crate.
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Address the cache-poisoning risk flagged on the caching actions.

Static analysis flags both actions/setup-node's cache: pnpm and Swatinem/rust-cache@v2 for cache-poisoning risk. This job runs on a tag push with contents: write and id-token: write permissions and holds the npm token and the signing certificate. If a cache entry with a shared restore key were populated by a lower-trust workflow run (for example, a regular PR-triggered CI run), this privileged release job could restore it and execute code from a poisoned dependency cache.

Consider scoping the cache key to this workflow only (for example a key that includes the workflow name or github.ref), or disabling the built-in cache here and installing dependencies without relying on a cache shared with other workflows.

🧰 Tools
🪛 zizmor (1.28.0)

[error] 78-78: runtime artifacts potentially vulnerable to a cache poisoning attack (cache-poisoning): this step

(cache-poisoning)


[error] 91-91: runtime artifacts potentially vulnerable to a cache poisoning attack (cache-poisoning): enables caching by default

(cache-poisoning)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 76 - 91, Restrict caching in the
release job to trusted, workflow-specific entries: update the setup-node pnpm
cache and Swatinem/rust-cache configuration so neither can restore cache data
shared with lower-trust workflows, using workflow/ref-scoped keys where
supported or disabling these caches and retaining dependency installation
correctness.

Source: Linters/SAST tools

Comment on lines +75 to +79
// 9.14 — the CLI asks here rather than starting a process, when this
// window already has the project open. Read and validate only; the socket
// never carries a write.
const queries = projectRoot ? serveQueries(projectRoot) : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Look for single-instance-lock or duplicate-project guards in the desktop main process.
rg -n "requestSingleInstanceLock|second-instance|projectRoot" apps/desktop/src/main/index.ts

Repository: protonspy/open-wiki

Length of output: 433


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- desktop main process ---'
ast-grep outline apps/desktop/src/main/index.ts
sed -n '1,180p' apps/desktop/src/main/index.ts
printf '%s\n' '--- socket implementation and usages ---'
rg -n -A35 -B15 "function serveQueries|const serveQueries|serveQueries\\(" packages/access apps
printf '%s\n' '--- single-instance APIs across desktop sources ---'
rg -n -i "requestSingleInstanceLock|hasSingleInstanceLock|second-instance|single.?instance|makeSingleInstance" apps/desktop packages

Repository: protonspy/open-wiki

Length of output: 24875


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- socket implementation ---'
sed -n '1,130p' packages/access/src/socket.ts
sed -n '180,330p' packages/access/src/socket.ts
printf '%s\n' '--- project resolution and desktop launch wiring ---'
sed -n '1,220p' apps/desktop/src/main/project.ts
rg -n -i "open-wiki\\.exe|ow\\.exe|requestSingleInstanceLock|second-instance|spawn|execFile|protocol|argv" --glob '!**/node_modules/**' .
printf '%s\n' '--- desktop package and entrypoint metadata ---'
fd -i 'package.json' apps packages | xargs -r rg -n -A8 -B3 '"(main|bin|scripts)"'

Repository: protonspy/open-wiki

Length of output: 33998


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- token-file helper and remaining socket lifecycle ---'
rg -n -A25 -B10 "function tokenFile|const tokenFile|export function tokenFile|server\\.on\\(\"error\"|server\\.on\\(\"close\"" packages/access/src/socket.ts
printf '%s\n' '--- CLI shim and installer launch commands ---'
fd -i -t f 'ow*' . apps packages scripts | sort
rg -n -A12 -B8 "resources/ow|ELECTRON_RUN_AS_NODE|open-wiki\\.exe|--project" apps packages scripts
printf '%s\n' '--- deterministic token-file race model ---'
python3 - <<'PY'
from pathlib import Path
from tempfile import TemporaryDirectory

with TemporaryDirectory() as d:
    token = Path(d) / "project.token"
    token.write_text("first-process-token")
    assert token.read_text() == "first-process-token"

    # serveQueries writes the new token before the second process calls listen.
    token.write_text("second-process-token")
    assert token.read_text() == "second-process-token"

    # The second server's bind error invokes rmSync(file, { force: true }).
    token.unlink(missing_ok=True)
    assert not token.exists()
    print("second bind failure removes the shared token path; first listener has no discoverable token")
PY

Repository: protonspy/open-wiki

Length of output: 38392


Protect the shared query token from duplicate desktop processes. apps/desktop/src/main/index.ts has no app.requestSingleInstanceLock() or equivalent guard. Each ow launch can start another open-wiki.exe process for the same project. When the second serveQueries call fails to bind, its error handler removes the shared token file while the first server continues listening. Add single-instance handling or make bind failure remove only the token created by that server.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/index.ts` around lines 75 - 79, Guard desktop startup
in the main process before initializing serveQueries so duplicate ow launches
for the same project are rejected via app.requestSingleInstanceLock() or an
equivalent single-instance mechanism. Ensure the duplicate process exits without
invoking serveQueries, preserving the first process’s shared query token and
listener.

Comment on lines +254 to +261
const file = tokenFile(projectRoot, options);
server.on("error", (error: Error) => {
// Whatever the reason, this window is not the one answering. Take the
// token back so a client reads nothing rather than the wrong thing.
rmSync(file, { force: true });
onError(error);
});
server.on("close", () => rmSync(file, { force: true }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A failed second server removes the first window's token.

The token file path depends only on projectRoot, so every window for the same project uses one file. Consider two windows on the same project. The second serveQueries overwrites the token file at Line 268, then listen fails with EADDRINUSE. The error handler removes the file. The first window keeps listening, but its token no longer exists on disk, so askRunningApp returns null for every later query. The socket optimisation stays off until the first window restarts.

Record the token this server wrote and remove the file only when the contents still match.

🐛 Proposed fix to scope the token removal
   const file = tokenFile(projectRoot, options);
+  // Only ours. Another window's token at this path is not ours to remove:
+  // taking it back would silently disable a healthy window's socket.
+  const dropToken = (): void => {
+    try {
+      if (readFileSync(file, "utf8") !== token) return;
+    } catch {
+      return;
+    }
+    rmSync(file, { force: true });
+  };
   server.on("error", (error: Error) => {
-    // Whatever the reason, this window is not the one answering. Take the
-    // token back so a client reads nothing rather than the wrong thing.
-    rmSync(file, { force: true });
+    dropToken();
     onError(error);
   });
-  server.on("close", () => rmSync(file, { force: true }));
+  server.on("close", () => dropToken());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/access/src/socket.ts` around lines 254 - 261, Update the token
cleanup around the server error and close handlers in serveQueries to retain the
exact token value written by that server and remove the shared token file only
when its current contents still match that value. Preserve the existing cleanup
and onError behavior while preventing a failed second server from deleting the
first server’s token.

Comment on lines +314 to +321
socket.on("data", (chunk: Buffer) => {
buffered += chunk.toString("utf8");
// A peer that answers with megabytes and no newline is not the
// application, and holding it all first is how it wins anyway.
if (buffered.length > MAX_LINE_BYTES) {
done(null);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Incremental UTF-8 decoding without StringDecoder on both sides of the socket. Both data handlers call chunk.toString("utf8") on each chunk and append the result to a string. Socket chunk boundaries fall at arbitrary byte offsets, so a multi-byte UTF-8 character split across two chunks decodes to U+FFFD on both sides of the split. Import StringDecoder from node:string_decoder once and give each connection its own decoder instance.

  • packages/access/src/socket.ts#L314-L321: replace buffered += chunk.toString("utf8") with buffered += decoder.write(chunk) in the askRunningApp data handler. This site returns the corrupted body as ok, because macOf covers only the nonce, so the CLI prints mojibake as wiki content.
  • packages/access/src/socket.ts#L213-L218: replace buffered += chunk.toString("utf8") with buffered += decoder.write(chunk) in the serveQueries connection handler. This site fails JSON.parse and destroys the connection when a slug carries non-ASCII characters.
📍 Affects 1 file
  • packages/access/src/socket.ts#L314-L321 (this comment)
  • packages/access/src/socket.ts#L213-L218
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/access/src/socket.ts` around lines 314 - 321, Import StringDecoder
from node:string_decoder and create a separate decoder per connection. In
packages/access/src/socket.ts lines 314-321, update the askRunningApp data
handler to append decoder.write(chunk); apply the same change in
packages/access/src/socket.ts lines 213-218 within the serveQueries connection
handler, preserving each handler’s existing buffering and termination behavior.

Comment on lines +192 to +195
it("prints the page when no application is listening", async () => {
expect(await main(["read", "fenix"], root)).toBe(0);
expect(stdout()).toContain("Fenix is a rebuild.");
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This test touches the real application data directory.

main calls askRunningApp without SocketOptions, so the socket resolves defaultAppDataDir() and endpointDir calls mkdirSync on the real user profile. Running the suite creates %APPDATA%/open-wiki/sockets on the developer machine and in CI. The assertions still pass, because the missing token file makes askRunningApp return null.

Set APPDATA (and HOME off Windows) to a temporary directory for this block, so the test stays inside its own fixture.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/tests/main.spec.ts` around lines 192 - 195, Update the test
block containing “prints the page when no application is listening” to isolate
application-data resolution by setting APPDATA and, on non-Windows platforms,
HOME to a temporary fixture directory before invoking main. Restore the
environment after the test so defaultAppDataDir and endpointDir cannot create
directories in the developer’s real profile.

Comment thread README.md
Comment on lines +42 to +44
```
npx open-wiki init
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language to the fenced code block.

markdownlint (MD040) flags this fence for missing a language identifier.

📝 Proposed fix
-```
+```bash
 npx open-wiki init
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 42-42: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 42 - 44, Update the fenced code block containing “npx
open-wiki init” in README.md to specify the bash language identifier, preserving
the command and surrounding documentation unchanged.

Source: Linters/SAST tools

Comment thread scripts/build-cli.mjs
Comment on lines +16 to +23
* **Nothing stays external.** `open-wiki` declares no runtime dependencies:
* `@open-wiki/access` and `@open-wiki/mcp` are workspace packages that are
* never published, and declaring them would make every install 404. So the
* bundle has to carry the whole graph — including the source adapters'
* libraries, which are reached through a dynamic `import()` and would
* otherwise be an `ERR_MODULE_NOT_FOUND` the first time somebody drops a PDF
* into `raw/`. The dynamic import still buys what it was for: esbuild splits
* those out of the startup path, so a hook run does not parse them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Does esbuild code splitting require outdir instead of outfile, and does a non-split dynamic import get inlined into the single bundle output?

💡 Result:

Yes, esbuild requires the use of --outdir instead of --outfile when code splitting is enabled [1][2]. Attempting to use --outfile with code splitting will result in an error [1]. Because code splitting produces multiple output files (the entry point bundle plus additional shared chunks), a directory is necessary to store these files [2][3]. Regarding dynamic imports, the behavior depends on whether code splitting is enabled: 1. If code splitting is enabled (--splitting=true), dynamic imports are treated as additional entry points [4][3]. This means the dynamically imported module will be bundled into its own separate chunk rather than being inlined into the main bundle [5][3]. 2. If code splitting is disabled, dynamic imports are typically bundled into the single output file [5]. In this case, esbuild will wrap the dynamic module in a way that allows it to be loaded (often using Promise.resolve and a require-style wrapper) [5][6], keeping everything within the single bundle output [5]. In summary, enabling code splitting automatically changes how dynamic imports are handled, moving them from inlined code to separate, lazily-loaded chunks [5][4][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- build script ---'
sed -n '1,140p' scripts/build-cli.mjs

printf '%s\n' '--- CLI package metadata ---'
pkg=$(git ls-files | grep -E '(^|/)packages/cli/package\.json$' | head -n 1 || true)
if [ -n "$pkg" ]; then
  cat -n "$pkg"
else
  echo 'packages/cli/package.json not found'
fi

printf '%s\n' '--- dynamic imports and build references ---'
rg -n -C 3 'import\(|splitting|outfile|outdir|build-cli|bin|ow\.mjs|build/' \
  scripts packages package.json 2>/dev/null || true

printf '%s\n' '--- relevant tracked files ---'
git ls-files | grep -E '(^|/)(build-cli\.mjs|main\.ts|package\.json)$' | head -80

Repository: protonspy/open-wiki

Length of output: 37425


🌐 Web query:

site:esbuild.github.io/api code splitting outdir outfile dynamic import bundle splitting false

💡 Result:

Code splitting and bundling are fundamentally linked in modern build tools like esbuild. To perform code splitting—such as splitting shared code into separate chunks or handling dynamic imports—the bundler must first be enabled [1]. When bundling is disabled (bundle: false), the tool does not resolve or inline imported dependencies; instead, it treats files individually, which makes code splitting impossible because the tool cannot analyze or restructure the dependency graph to create shared chunks [1]. Regarding output configuration, tools like esbuild enforce specific rules when code splitting is active: 1. Outdir vs. Outfile: When code splitting is enabled (which happens automatically when you have multiple entry points or use dynamic imports), you must use the outdir option to specify an output directory [1]. The outfile option cannot be used because code splitting generates multiple files, whereas outfile is designed to produce a single, specific file [1]. 2. Bundling Dependency: Code splitting is a feature of the bundling process [1]. If you attempt to disable bundling (bundle: false), the engine will not perform the recursive dependency analysis required to identify shared modules or handle dynamic imports, effectively disabling the code splitting mechanism [1]. In summary, if you need code splitting or dynamic imports, you must enable bundling and use outdir instead of outfile to accommodate the multiple output files generated by the process [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- esbuild availability and declared version ---'
command -v esbuild || true
node -e 'try { console.log(require.resolve("esbuild")) } catch { console.log("esbuild module unavailable") }' 2>/dev/null || true
rg -n -C 2 '"esbuild"|esbuild@' package.json pnpm-lock.yaml package-lock.json yarn.lock 2>/dev/null || true

printf '%s\n' '--- read-only configuration verifier ---'
python3 - <<'PY'
from pathlib import Path
import re

p = Path("scripts/build-cli.mjs").read_text()
outfile = re.search(r'\boutFile:\s*', p, re.I)
outdir = re.search(r'\boutdir:\s*', p, re.I)
splitting = re.search(r'\bsplitting\s*:\s*true\b', p)
dynamic = "await import(" in Path("packages/cli/src/main.ts").read_text()
print({
    "outfile_configured": bool(outfile),
    "outdir_configured": bool(outdir),
    "splitting_true": bool(splitting),
    "cli_dynamic_import": dynamic,
})
PY

Repository: protonspy/open-wiki

Length of output: 2462


🏁 Script executed:

node - <<'JS'
const { build } = require("esbuild");

const plugin = {
  name: "virtual",
  setup(build) {
    build.onResolve({ filter: /^entry$/ }, () => ({ path: "entry", namespace: "v" }));
    build.onResolve({ filter: /^lazy$/ }, () => ({ path: "lazy", namespace: "v" }));
    build.onLoad({ filter: /.*/, namespace: "v" }, ({ path }) => ({
      contents: path === "entry"
        ? 'export async function run() { return import("lazy"); }'
        : 'export const value = 42;',
      loader: "js",
    }));
  },
};

(async () => {
  const base = {
    stdin: { contents: 'export async function run() { return import("lazy"); }', sourcefile: "entry.js", loader: "js" },
    bundle: true,
    format: "esm",
    plugins: [plugin],
    write: false,
    logLevel: "silent",
  };

  const single = await build({ ...base, outfile: "ow.mjs" });
  const singleText = single.outputFiles[0].text;
  console.log("single_output_files:", single.outputFiles.map((f) => f.path));
  console.log("single_contains_dynamic_import:", /\bimport\s*\(/.test(singleText));
  console.log("single_contains_inlined_value:", singleText.includes("42"));

  try {
    await build({ ...base, splitting: true, outfile: "ow.mjs" });
    console.log("outfile_with_splitting: unexpectedly accepted");
  } catch (error) {
    console.log("outfile_with_splitting:", error.errors?.[0]?.text ?? String(error));
  }

  const split = await build({ ...base, splitting: true, outdir: "build" });
  console.log("split_output_files:", split.outputFiles.map((f) => f.path));
})();
JS

Repository: protonspy/open-wiki

Length of output: 466


Enable esbuild code splitting for lazy imports. scripts/build-cli.mjs uses outfile without splitting: true, so esbuild emits one ow.mjs file and inlines the dynamic-import graph. The import defers execution, but it does not emit a separate startup chunk. If this cold-start goal is required, use outdir with splitting: true and preserve or update packages/cli/package.json's bin.ow to match the generated entry filename.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/build-cli.mjs` around lines 16 - 23, Update the esbuild configuration
in the CLI build script to use an output directory with code splitting enabled,
so the dynamic-import graph is emitted as lazy chunks instead of being inlined.
Ensure the generated entry filename remains aligned with the packages/cli
package.json bin.ow target, updating that target if necessary.

…nifests

`claude plugin validate --strict` failed the moment CI could actually report
it — which is the point of removing `continue-on-error`. A marketplace with no
description is a warning, and `--strict` makes a warning an error.

Two things follow it into `check-plugin.mjs` and CI:

- The floor now refuses a marketplace with no description too. That script
  exists because "a check that silently passes when its tool is missing is not
  a check", and a floor that misses what the tool catches is the same failure
  one level down.
- The marketplace manifest and the plugin manifest are different files, and
  validating the first never opens the second. CI runs both.

Verified locally against the real CLI: both validate clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3VYWWTZtEE2NxPiksKsAK
@protonspy
protonspy merged commit e583583 into main Aug 1, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant