Skip to content

feat: modernize packaging and dev tooling (Phase 3) - #32

Merged
dopry merged 5 commits into
nextfrom
claude/pecans-phase-3-packaging
Jul 10, 2026
Merged

feat: modernize packaging and dev tooling (Phase 3)#32
dopry merged 5 commits into
nextfrom
claude/pecans-phase-3-packaging

Conversation

@dopry

@dopry dopry commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 3 of the modernization roadmap: replace the hand-rolled dual build with modern packaging and bring in real lint/format tooling. Two commits carry the substance (feat: + a follow-up fix:); the mechanical style: prettier pass is separate and listed in .git-blame-ignore-revs.

Packaging

  • tsup replaces dual-tsc + fixup.sh: one config produces dist/index.js (CJS), dist/index.mjs (ESM), .d.ts/.d.mts declarations, and sourcemaps, targeting node22. The four tsconfigs (base/cjs/mjs/test) collapse into one typecheck-only tsconfig.json.
  • Proper exports map with types for both module systems — the package previously had no types or exports at all, so TS consumers got no declarations. Kept main/module for older tooling; added type: "commonjs", sideEffects: false, canonical repo URL (publint suggestions).
  • Validation: publint all-good; arethetypeswrong green across node10 / node16-CJS / node16-ESM / bundler; packed-tarball smoke tests require() and import the package successfully; node dist/index.js boots the server and serves /api/status.
  • Phantom dependencies fixed: debug and qs are imported directly by src/ but were only present transitively via express. The ESM smoke test caught this — tsup inlined them, and their CJS require("tty") calls threw at import time. They're now declared dependencies (and externalized, shrinking the ESM bundle ~35%).
  • Models exported from the package root: PecansRelease, PecansAsset, PecansReleases, etc. were previously reachable only by deep-importing dist/ paths, which the bundled layout removes.

⚠️ Breaking (build layout): dist/cjs/* / dist/mjs/* deep-import paths no longer exist — import everything from the package root. Flagged as BREAKING CHANGE in the commit for semantic-release.

📝 Module format note: dual CJS+ESM per the Phase 0 default (couldn't inspect missioncontrol-api). If it turns out to be ESM-capable, ESM-only is a one-line tsup change.

Dev tooling

  • ts-node moved out of runtime dependencies (it was shipping to production); dev is now tsx watch, start runs the compiled dist, nodemon dropped.
  • ESLint 10 flat config + Prettier replace the mocha-era .eslintrc (whose rules contradicted the actual code style); lint re-runs clean after fixing the 19 real findings it surfaced (unused imports/vars, case-block declarations, missing error causes, useless assignment). no-explicit-any stays off until the Phase 7 cleanup of raw payloads.
  • Explicit @types/node; new lint / format / format:check / typecheck scripts (CI wiring lands in Phase 4).

Review follow-up

  • Copilot spotted that the extension-preference sort in resolveReleaseAssetForVersion still used path.extname, mis-ranking .tar.gz assets (prefs.indexOf === -1 beats every real preference) — same bug class as the Phase 2 satisfiesExtensions fix. Fixed with getSupportedExt + regression test.

Verification

  • npm run lint, npm run typecheck, npx prettier --check .: clean.
  • npm test: 30 files / 713 passed — the Phase 1 Electron autoUpdater contract suite is untouched and green.
  • npm run build + publint + attw --pack + CJS/ESM tarball import smoke tests + compiled-server boot: all pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

claude added 2 commits July 10, 2026 17:24
Mechanical formatting only (trailing commas, wrapping) from the new
prettier setup; no behavior changes. This commit is listed in
.git-blame-ignore-revs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM
BREAKING CHANGE: build output moves from dist/cjs + dist/mjs to a tsup
bundle (dist/index.js CJS, dist/index.mjs ESM). Deep imports into dist
paths no longer resolve; all models (PecansRelease, PecansAsset,
PecansReleases, ...) are now exported from the package root instead.

- replace the dual-tsc + fixup.sh build with tsup (CJS + ESM + d.ts +
  sourcemaps, node22 target); consolidate four tsconfigs into one
  typecheck-only tsconfig.json
- add a proper exports map with types for both module systems; verified
  with publint and arethetypeswrong (all green: node10/node16/bundler)
- declare debug and qs as real dependencies - both are imported directly
  but were only present transitively via express, which broke the ESM
  bundle (inlined CJS require calls)
- guard the run-directly check with typeof require so the ESM build is
  importable; node dist/index.js still starts the server
- ts-node out of runtime dependencies; dev now runs tsx watch; start
  runs the compiled dist; drop nodemon
- ESLint 9 flat config + prettier (replaces the stale mocha-era
  .eslintrc); fix the 19 findings it surfaced (unused imports/vars,
  case-block declarations, error causes, no-useless-assignment)
- add explicit @types/node; add lint/format/typecheck scripts
- package.json: type commonjs, sideEffects false, canonical repo url

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

Copilot AI 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.

Pull request overview

This PR advances Pecans’ “modernization roadmap” by switching the build/packaging pipeline to tsup, adding first-class lint/format tooling (ESLint flat config + Prettier), and updating the package surface (notably adding root exports for models and adding an exports map + types).

Changes:

  • Replace the prior dual-tsc build layout with tsup outputting CJS + ESM + declarations into dist/, and collapse TypeScript configs into a single typecheck-only tsconfig.json.
  • Add ESLint flat config and Prettier configuration/ignores; apply formatting updates across source + tests.
  • Update package metadata for modern consumption (exports, types, type: commonjs, and dependency declarations for direct imports like debug and qs).

Reviewed changes

Copilot reviewed 58 out of 77 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
vitest.config.ts Point Vitest typecheck at the consolidated tsconfig.json.
tsup.config.ts New tsup build configuration (CJS+ESM, dts, sourcemaps, node22 target).
tsconfig.test.json Removed legacy test tsconfig.
tsconfig.mjs.json Removed legacy ESM build tsconfig.
tsconfig.cjs.json Removed legacy CJS build tsconfig.
tsconfig.json Consolidated typecheck-only TS config; now includes tests and build config files.
test/unit/win-releases.spec.ts Formatting-only updates (trailing commas, etc.).
test/unit/SupportedFileExtension.spec.ts Formatting + remove unused type import.
test/unit/stripBOM.spec.ts Formatting-only updates.
test/unit/sortReleaseBySemVerDescending.spec.ts Formatting-only updates.
test/unit/platforms.spec.ts Formatting-only updates.
test/unit/PecansAsset.spec.ts Formatting-only updates.
test/unit/PackageFormat.spec.ts Formatting + remove unused type import.
test/unit/OperatingSystem.spec.ts Formatting-only updates.
test/unit/models-index.spec.ts Formatting-only updates.
test/unit/mergeReleaseNotes.spec.ts Formatting-only updates.
test/unit/index.spec.ts Formatting-only updates.
test/unit/channelFromVersion.spec.ts Formatting-only updates.
test/unit/backend-cache.spec.ts Remove unused local variable assignment in test.
test/unit/Architecture.spec.ts Formatting-only updates.
test/setup.ts Formatting-only updates.
test/nock/nockGithubReleaseAsset.ts Formatting-only updates (trailing comma).
test/nock/nockGithubListReleases.ts Formatting-only updates; minor expression parenthesization.
test/nock/nockGithubBadCredentials.ts Formatting-only updates.
test/nock/nockGithubAssetContent.ts Formatting-only updates (trailing comma).
test/nock/Nock.ts Formatting-only updates.
test/integration/webhook.spec.ts Formatting-only updates.
test/integration/update-win.spec.ts Formatting-only updates; minor line wrapping.
test/integration/update-mac.spec.ts Formatting-only updates.
test/integration/server-startup.spec.ts Formatting-only updates; remove unused mock vars.
test/integration/integration.spec.ts Formatting-only updates.
test/integration/download.spec.ts Formatting-only updates; minor line wrapping.
test/integration/dl.spec.ts Formatting-only updates; minor line wrapping.
test/integration/api.spec.ts Formatting-only updates.
test/helpers/contracts.ts Formatting-only updates (trailing comma).
test/harness.ts Formatting-only updates (trailing commas).
test/fixtures/builders.ts Formatting-only updates; split long ternary for Prettier.
src/versions.ts Remove unused semver import; formatting.
src/utils/win-releases.ts Formatting-only updates (trailing comma).
src/utils/SupportedFileExtension.ts Formatting + minor type syntax normalization.
src/utils/stripBOM.ts Formatting-only updates.
src/utils/sortReleaseBySemVerDescending.ts Formatting-only updates (trailing comma).
src/utils/resolveForVersion.ts Formatting + minor refactor within filtering/sorting logic.
src/utils/platforms.ts Remove unused os.platform import; replace pkg && ... with explicit if (pkg).
src/utils/PackageFormat.ts Formatting + minor type syntax normalization.
src/utils/OperatingSystem.ts Formatting + minor type syntax normalization; trailing commas.
src/utils/mergeReleaseNotes.ts Avoid string mutation in reduce; formatting.
src/utils/index.ts Formatting-only updates.
src/utils/Architecture.ts Formatting-only updates.
src/pecans.ts Change PecansOptions from empty interface to type alias; formatting/trailing commas.
src/models/PecansReleases.ts Formatting-only updates.
src/models/PecansReleaseQuery.ts Formatting-only updates.
src/models/PecansRelease.ts Formatting-only updates; add trailing comma in console.error args.
src/models/PecansChannel.ts Formatting-only updates.
src/models/PecansAssetQuery.ts Formatting-only updates.
src/models/PecansAsset.ts Formatting-only updates.
src/models/index.ts Formatting-only updates.
src/index.ts Export models from package root; make require.main guard safe for ESM; minor formatting.
src/backends/backend.ts Add cause when wrapping stream read errors; trailing commas.
package.json Switch entrypoints to dist/index.*, add exports + types, add dev tooling scripts/deps, and declare debug/qs as direct deps.
fixup.sh Removed legacy build fixup script for dual output layout.
eslint.config.mjs New ESLint flat config (TS + Prettier).
app.json Formatting-only updates.
.vscode/settings.json Formatting-only updates.
.prettierrc.json Add Prettier config (empty object).
.prettierignore Add Prettier ignore rules for dist/coverage/etc.
.git-blame-ignore-revs Add ignore rev for mechanical Prettier pass.
.eslintrc Remove legacy ESLint config.

Comment thread src/utils/resolveForVersion.ts Outdated
Comment thread package.json
path.extname reports '.gz' for .tar.gz filenames, so the sort fallback
ranked them at prefs.indexOf(-1) - ahead of every genuine preference -
whenever .tgz and .tar.gz assets coexisted. Use getSupportedExt, which
handles the double extension, matching the Phase 2 fix to
PecansAsset.satisfiesExtensions. Regression test added.

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

Copilot AI 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.

Pull request overview

Copilot reviewed 58 out of 77 changed files in this pull request and generated 3 comments.

Comment thread src/index.ts
Comment thread src/index.ts Outdated
Comment thread test/integration/server-startup.spec.ts
Review feedback: the default switch case in configure() threw a raw
string (no stack trace); the startup log said 'Lisening'. The test that
pinned the typo is updated to match.

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

Copilot AI 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.

Pull request overview

Copilot reviewed 58 out of 77 changed files in this pull request and generated 1 comment.

Comment thread src/utils/SupportedFileExtension.ts
Review feedback: the switch had no default, so an invalid OperatingSystem
cast in at runtime silently returned undefined against the declared
SupportedFileExtension[] return type. Fail loudly instead; edge-case test
updated to pin the throw.

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

Copilot AI 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.

Pull request overview

Copilot reviewed 58 out of 77 changed files in this pull request and generated no new comments.

@dopry
dopry merged commit 3345ed7 into next Jul 10, 2026
3 checks passed
@dopry
dopry deleted the claude/pecans-phase-3-packaging branch July 10, 2026 18:05
github-actions Bot pushed a commit that referenced this pull request Jul 21, 2026
# [2.0.0-next.18](v2.0.0-next.17...v2.0.0-next.18) (2026-07-21)

* feat!: unified ReleaseService resolution pipeline on discrete os/arch/pkg (Phase 7 PR B) ([#44](#44)) ([8a2f06a](8a2f06a))

### Bug Fixes

* **github:** default octokit to native fetch to prevent empty release lists ([#28](#28)) ([0f6eac6](0f6eac6))
* honor route params in downloads, dead code removal, small fixes (Phase 2) ([#31](#31)) ([9fd8f79](9fd8f79))
* semantic-release trusted publishing ([4f1ee88](4f1ee88))
* semantic-release trusted publishing ([#53](#53)) ([3d4d4ce](3d4d4ce))
* working generic refresh webhook; document raw as the backend-private asset slot (Phase 7 PR A) ([#43](#43)) ([23d1d9f](23d1d9f))

### chore

* esm only ([#24](#24)) ([1b8a546](1b8a546))

### Features

* dependency modernization — Express 5, octokit 22, remove UA autodetection (Phase 5) ([#41](#41)) ([8ea329a](8ea329a))
* modernize packaging and dev tooling (Phase 3) ([#32](#32)) ([3345ed7](3345ed7))
* recognize .msix / .msixbundle assets and 'msix' package format ([#46](#46)) ([4f848b9](4f848b9)), closes [#26](#26)
* typed HTTP errors with router-scoped error handling (Phase 6) ([#42](#42)) ([e3b7b54](e3b7b54))

### BREAKING CHANGES

* @dopry/pecans is now ESM-only. require('@dopry/pecans')
is no longer supported; use import (Node >= 22.12).

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

* refactor: import model types with import type in runtime modules

Follows up on Copilot review: PecansRelease/PecansReleases (and other
names used only in type positions) are classes, so tsc accepts plain
imports, but with verbatimModuleSyntax they would stay in the emitted
JS as runtime imports. Convert the type-only usages to import type to
keep the runtime module graph minimal and cycle-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zXcPSv6FFTPgD59T4KudM
* Pecans no longer exposes a versions property.

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

* feat!: remove the Versions and resolveReleaseAssetForVersion adapters

The deprecation shims this PR introduced are dropped instead of carried
to 3.0: route handlers and consumers resolve through ReleaseService /
resolveAssetForRelease directly. The table-driven specs that pinned the
legacy composite-id resolution semantics are migrated onto the pipeline
(via platformToQuery) so the behavioral pins survive the adapter
removal; unique Versions coverage moved into service.spec.

Pre-existing deprecations (GitHubBackend, PecansSettings.timeout,
PecansReleaseDTO.channel) keep their 3.0 schedule.
* Versions, VersionFilterOpts, PlatformQuery, and
resolveReleaseAssetForVersion are no longer exported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM
* getArchFromUserAgent, getOsFromUserAgent, and
getPlatformFromUserAgent now take the internal UserAgentDetails type
instead of express-useragent's Details, and getArchFromUserAgent
defaults Windows and Linux to '64' (32-bit desktops are effectively
extinct; the function is not used internally).

pecans consumed exactly four booleans from the unmaintained
express-useragent package; src/utils/userAgent.ts derives them from the
User-Agent header directly, with mobile exclusions the old library
handled via separate flags (iOS UAs contain 'like Mac OS X', Android
UAs contain 'Linux'). The middleware attaches the same req.useragent
shape. Unit specs cover the parser; the Phase 1 UA-driven download
contract tests pass unchanged.

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

* fix: validate update-route params through getStringParam consistently

Review feedback: handleUpdateOSX truthiness-checked req.params directly
but read values through getStringParam, and handleUpdateWin had no
version guard at all; a missing tag would have produced a '>=undefined'
range. Validate once through the helper and reuse the validated values.

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

* fix: exclude Macintosh+Mobile webview UAs from macOS detection

Review feedback (partial): a Mobile token alongside Macintosh indicates
an iPad-class webview masquerading as a Mac; genuine macOS browsers
never send it. Fixture + test added. Note true iPadOS desktop-mode UAs
are byte-identical to Mac Safari and undetectable by any parser.

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

* fix: short-circuit dlfilename when the filename param is absent

Review feedback: an undefined filename passed into queryReleases matches
every release (the predicate treats undefined as no-filter), which would
serve an arbitrary asset instead of a 404. Unreachable via the current
route but guarded for consistency with the update handlers.

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

* feat: remove user-agent platform autodetection
* selecting a platform is now the client's
responsibility. GET / is no longer a download route, and the platform
segment is required on /download, /download/version/:tag, and
/download/channel/:channel (a missing platform returns 400). The
user-agent parser, its middleware, and the getPlatformFromUserAgent /
getArchFromUserAgent / getOsFromUserAgent helpers are removed.

Autodetection only ever served bare browser links - Squirrel update
clients and /dl/* always send explicit platforms - and reliable device
detection is better handled client-side where UA Client Hints are
available. Reverting this commit restores the feature wholesale if
anyone misses it.

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

* fix: remove imports orphaned by the autodetection removal

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

* fix: validate tag ranges early in validateReqQueryTag

Review feedback: validRange's result was discarded, so invalid tags only
failed deep in release matching with a generic 'Invalid Range Specified'
error. Invalid ranges now throw UnsupportedTagError at the parameter
boundary ('latest' stays allowed), and the error message no longer says
'channel' for tags (copy-paste from UnsupportedChannelError).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM
* build output moves from dist/cjs + dist/mjs to a tsup
bundle (dist/index.js CJS, dist/index.mjs ESM). Deep imports into dist
paths no longer resolve; all models (PecansRelease, PecansAsset,
PecansReleases, ...) are now exported from the package root instead.

- replace the dual-tsc + fixup.sh build with tsup (CJS + ESM + d.ts +
  sourcemaps, node22 target); consolidate four tsconfigs into one
  typecheck-only tsconfig.json
- add a proper exports map with types for both module systems; verified
  with publint and arethetypeswrong (all green: node10/node16/bundler)
- declare debug and qs as real dependencies - both are imported directly
  but were only present transitively via express, which broke the ESM
  bundle (inlined CJS require calls)
- guard the run-directly check with typeof require so the ESM build is
  importable; node dist/index.js still starts the server
- ts-node out of runtime dependencies; dev now runs tsx watch; start
  runs the compiled dist; drop nodemon
- ESLint 9 flat config + prettier (replaces the stale mocha-era
  .eslintrc); fix the 19 findings it surfaced (unused imports/vars,
  case-block declarations, error causes, no-useless-assignment)
- add explicit @types/node; add lint/format/typecheck scripts
- package.json: type commonjs, sideEffects false, canonical repo url

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

* fix: rank .tar.gz assets by their full extension in resolveForVersion

path.extname reports '.gz' for .tar.gz filenames, so the sort fallback
ranked them at prefs.indexOf(-1) - ahead of every genuine preference -
whenever .tgz and .tar.gz assets coexisted. Use getSupportedExt, which
handles the double extension, matching the Phase 2 fix to
PecansAsset.satisfiesExtensions. Regression test added.

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

* fix: throw Error from configure(), correct Listening typo

Review feedback: the default switch case in configure() threw a raw
string (no stack trace); the startup log said 'Lisening'. The test that
pinned the typo is updated to match.

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

* fix: throw on invalid os in getDownloadExtensionsByOs

Review feedback: the switch had no default, so an invalid OperatingSystem
cast in at runtime silently returned undefined against the declared
SupportedFileExtension[] return type. Fail loudly instead; edge-case test
updated to pin the throw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM
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.

3 participants