From f39df874b3bd1b58209a2e6bb67178178f9b9dcc Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 9 Nov 2026 11:17:53 -0500 Subject: [PATCH] feat: build Socket 2.1 prerelease as Node 27 SEA --- .claude/skills/bump-coana/SKILL.md | 2 +- .config/release.json | 6 + .config/rollup.base.config.mjs | 16 +- .config/rollup.cdxgen-sea.config.mjs | 514 +++++ .config/rollup.dist.config.mjs | 83 +- .config/rollup.synp-sea.config.mjs | 222 ++ .config/taze.config.mts | 23 + .config/tsconfig.dts.json | 17 + .../tsconfig.scripts.json | 14 +- .../vitest.config.mts | 21 + .../vitest.e2e.config.mts | 3 + .config/vitest.integration.config.mts | 29 + .config/vitest.native.config.mts | 32 + .config/vitest.terminal.config.mts | 18 + .gitattributes | 4 + .../actions/repo/cache-downloads/action.yml | 22 + .../actions/repo/cache-downloads/index.mts | 131 ++ .github/workflows/bazel-windows.yml | 12 +- .github/workflows/ci.yml | 171 +- .github/workflows/e2e-tests.yml | 2 +- .github/workflows/publish-npm.yml | 249 ++- .github/workflows/release-github.yml | 119 ++ .gitignore | 1416 ++++++++++++- .npmrc | 2 + CLAUDE.md | 6 +- README.md | 7 +- eslint.config.js | 19 +- package.json | 44 +- .../@socketsecurity__registry@1.1.17.patch | 48 +- pnpm-lock.yaml | 880 +++++++- scripts/build-package-matrix.mts | 400 ++++ scripts/check.mts | 5 +- scripts/constants.js | 13 +- scripts/release/add-unreleased.mts | 2 +- scripts/release/approve-npm.mts | 259 +++ scripts/release/availability.mts | 158 ++ scripts/release/history.mts | 54 +- scripts/release/open-release-pr.mts | 11 +- scripts/release/packages.mts | 9 + scripts/release/preflight.mts | 13 +- scripts/release/{bump.mts => prepare.mts} | 135 +- scripts/release/registry.mts | 54 +- scripts/release/release-branch.mts | 2 +- scripts/release/validate-npm-policy.mts | 109 + scripts/release/version.mts | 74 +- scripts/test/apple-signing.mts | 397 ++++ scripts/test/integration.mts | 158 ++ scripts/test/native.mts | 368 ++++ scripts/test/snapshot-dependencies.mts | 99 + scripts/test/snapshots.mts | 283 +++ scripts/test/terminal-report.mts | 212 ++ .../manifest/bazel/bazel-bin-detect.mts | 42 +- src/commands/manifest/bazel/bazel-process.mts | 4 +- .../manifest/bazel/bazel-process.test.mts | 29 +- .../manifest/bazel/bazel-python-shim.mts | 67 +- .../manifest/bazel/bazel-python-shim.test.mts | 31 +- src/commands/manifest/cmd-manifest-cdxgen.mts | 49 + .../manifest/convert_gradle_to_maven.mts | 19 +- .../manifest/convert_sbt_to_maven.mts | 19 +- src/commands/manifest/run-cdxgen.mts | 288 ++- src/commands/manifest/scripts/build-tool.mts | 59 +- .../scripts/dotnet-tool/build-tool.sh | 4 +- src/commands/manifest/scripts/run.mts | 22 +- .../output-purls-shallow-malware.test.mts | 20 +- .../output-purls-shallow-score.test.mts | 20 +- src/commands/patch/cmd-patch.mts | 104 +- src/commands/scan/cmd-scan-create.mts | 2 +- src/commands/scan/cmd-scan-create.test.mts | 2 +- .../scan/generate-uv-member-sboms.mts | 59 +- .../scan/generate-uv-member-sboms.test.mts | 74 +- src/constants.mts | 75 +- src/sea/README.md | 103 +- src/sea/adapters/cdxgen-worker.mjs | 519 +++++ src/sea/adapters/cdxgen.mts | 95 + src/sea/adapters/synp-worker.mjs | 191 ++ src/sea/adapters/synp.mts | 166 ++ src/sea/archive-paths.mts | 19 + src/sea/bootstrap.mts | 205 +- src/sea/build-sea.mts | 1803 +++++++++++++---- src/sea/build/lock.mts | 25 + src/sea/build/publish.mts | 142 ++ src/sea/capabilities.mts | 95 + src/sea/download-bytes.mts | 162 ++ src/sea/download-cache.mts | 426 ++++ .../cdxgen-third-party/boolbase-1.0.0-ISC.txt | 13 + .../cdxgen-third-party/err-code-2.0.3-MIT.txt | 18 + .../imurmurhash-0.1.4-MIT.txt | 20 + .../spdx-exceptions-2.5.0-CC-BY-3.0.txt | 319 +++ .../spdx-license-ids-3.0.22-CC0-1.0.txt | 121 ++ .../core-third-party/ansi-term-0.0.2-ISC.txt | 7 + ...wille-canvas-blessed-contrib-0.1.3-MIT.txt | 18 + .../npmcli-agent-3.0.0-ISC.txt | 7 + .../npmcli-agent-4.0.0-ISC.txt | 7 + .../sigstore-verify-3.1.1-Apache-2.0.txt | 202 ++ .../core-third-party/x256-0.0.2-MIT.txt | 18 + src/sea/licenses/socket-patch-MIT.txt | 21 + src/sea/rollup-provenance.mjs | 762 +++++++ src/sea/runtime.mts | 465 +++++ src/sea/stage-synp.mts | 161 ++ src/sea/stage-uv.mts | 548 +++++ src/sea/uv-pins.json | 56 + src/sea/version.mts | 158 ++ src/utils/agent.mts | 100 +- src/utils/coana-release.mts | 27 +- src/utils/dlx-binary.mts | 409 ---- src/utils/dlx-binary.test.mts | 34 - src/utils/dlx.mts | 10 + src/utils/glob.mts | 21 +- src/utils/glob.test.mts | 101 +- src/utils/is-object-object.d.mts | 1 + src/utils/is-object-object.mjs | 4 + src/utils/meow-with-subcommands.mts | 5 +- src/utils/package-environment.mts | 14 +- src/utils/path-resolve.test.mts | 79 +- src/utils/self-update.mts | 36 +- src/utils/self-update.test.mts | 19 - taze.config.mts | 17 +- test/dist-runtime-requires.test.mts | 2 + test/lib/native/cli-harness.mts | 154 ++ test/lib/native/command-inventory.mts | 201 ++ test/lib/native/tui-api.mts | 148 ++ test/lib/optimize-svg.mts | 18 + test/lib/terminal/animation.mts | 387 ++++ test/lib/terminal/output.mts | 93 + .../behavior-config-get.output.svg | 19 + .../behavior-config-invalid-key.output.svg | 27 + .../behavior-config-list.output.svg | 31 + .../behavior-json-missing.output.svg | 17 + .../behavior-json-seeded.output.svg | 23 + .../behavior-manifest-setup.canceled.svg | 59 + .../behavior-manifest-setup.exit-selected.svg | 57 + .../behavior-manifest-setup.menu.svg | 58 + .../behavior-wrapper-on-dry-run.output.svg | 13 + .../native-analytics-tui.charts.svg | 279 +++ .../native-audit-log-tui.table.svg | 145 ++ .../native-threat-feed-tui.table.svg | 145 ++ .../self-update-notice-standard.standard.svg | 9 + .../self-update-notice-wide.wide.svg | 9 + .../self-update-notice-wrapped.wrapped.svg | 11 + .../self-update-suppressed.suppressed.svg | 9 + ...xgen-unsupported-type.unsupported-type.svg | 35 + .../socket-help-analytics.help.svg | 65 + .../socket-help-audit-log.help.svg | 89 + .../socket-help-cdxgen.help.svg | 35 + test/native-snapshots/socket-help-ci.help.svg | 59 + .../socket-help-config-auto.help.svg | 65 + .../socket-help-config-get.help.svg | 69 + .../socket-help-config-list.help.svg | 41 + .../socket-help-config-set.help.svg | 81 + .../socket-help-config-unset.help.svg | 69 + .../socket-help-config.help.svg | 45 + .../socket-help-dependencies.help.svg | 51 + .../native-snapshots/socket-help-fix.help.svg | 159 ++ .../socket-help-install-completion.help.svg | 73 + .../socket-help-install.help.svg | 37 + .../socket-help-json.help.svg | 37 + .../socket-help-license.help.svg | 59 + .../socket-help-login.help.svg | 59 + .../socket-help-logout.help.svg | 35 + .../socket-help-manifest-auto.help.svg | 67 + .../socket-help-manifest-bazel.help.svg | 143 ++ .../socket-help-manifest-cdxgen.help.svg | 35 + .../socket-help-manifest-conda.help.svg | 71 + .../socket-help-manifest-dotnet.help.svg | 97 + ...p-manifest-dynamic-sbom-inference.help.svg | 77 + .../socket-help-manifest-gradle.help.svg | 121 ++ .../socket-help-manifest-kotlin.help.svg | 121 ++ .../socket-help-manifest-maven.help.svg | 93 + .../socket-help-manifest-scala.help.svg | 153 ++ .../socket-help-manifest-setup.help.svg | 91 + .../socket-help-manifest.help.svg | 57 + .../native-snapshots/socket-help-npm.help.svg | 53 + .../native-snapshots/socket-help-npx.help.svg | 51 + .../socket-help-oops.help.svg | 29 + .../socket-help-optimize.help.svg | 49 + ...et-help-organization-dependencies.help.svg | 51 + .../socket-help-organization-list.help.svg | 47 + ...-help-organization-policy-license.help.svg | 61 + ...help-organization-policy-security.help.svg | 61 + .../socket-help-organization-policy.help.svg | 37 + .../socket-help-organization-quota.help.svg | 41 + .../socket-help-organization.help.svg | 41 + .../socket-help-package-score.help.svg | 91 + .../socket-help-package-shallow.help.svg | 89 + .../socket-help-package.help.svg | 39 + .../socket-help-patch.help.svg | 74 + .../socket-help-pnpm.help.svg | 53 + .../socket-help-raw-npm.help.svg | 45 + .../socket-help-raw-npx.help.svg | 45 + .../socket-help-repository-create.help.svg | 67 + .../socket-help-repository-del.help.svg | 53 + .../socket-help-repository-list.help.svg | 67 + .../socket-help-repository-update.help.svg | 63 + .../socket-help-repository-view.help.svg | 55 + .../socket-help-repository.help.svg | 45 + .../socket-help-scan-create.help.svg | 265 +++ .../socket-help-scan-del.help.svg | 55 + .../socket-help-scan-diff.help.svg | 75 + .../socket-help-scan-github.help.svg | 93 + .../socket-help-scan-list.help.svg | 77 + .../socket-help-scan-metadata.help.svg | 55 + .../socket-help-scan-reach.help.svg | 177 ++ .../socket-help-scan-report.help.svg | 113 ++ .../socket-help-scan-setup.help.svg | 65 + .../socket-help-scan-view.help.svg | 61 + .../socket-help-scan.help.svg | 53 + .../socket-help-security.help.svg | 59 + .../socket-help-threat-feed.help.svg | 155 ++ .../socket-help-uninstall-completion.help.svg | 57 + .../socket-help-uninstall.help.svg | 37 + .../socket-help-wrapper.help.svg | 47 + .../socket-help-yarn.help.svg | 53 + test/native-snapshots/socket-help.help.svg | 129 ++ .../socket-manifest-dry-run.dry-run.svg | 13 + .../socket-patch-apply-help.help.svg | 89 + .../socket-patch-get-help.help.svg | 102 + .../socket-patch-list-help.help.svg | 47 + .../socket-patch-remove-help.help.svg | 74 + .../socket-patch-repair-help.help.svg | 61 + .../socket-patch-rollback-help.help.svg | 106 + .../socket-patch-scan-help.help.svg | 77 + .../socket-patch-setup-help.help.svg | 53 + .../socket-scan-setup.canceled.svg | 58 + .../socket-scan-setup.github-selected.svg | 56 + .../socket-scan-setup.menu.svg | 58 + .../socket-version.version.svg | 9 + .../commands/cli-behavior.native.test.mts | 120 ++ test/native/commands/cli.native.test.mts | 239 +++ test/native/commands/tui.native.test.mts | 114 ++ test/package-matrix.test.mts | 256 +++ test/release-approve-npm.test.mts | 65 + test/release-availability.test.mts | 92 + test/release-history.test.mts | 63 +- test/release-npm-policy.test.mts | 45 + test/release-open-pr.test.mts | 4 +- ...bump.test.mts => release-prepare.test.mts} | 2 +- test/release-registry.test.mts | 18 + test/release-version.test.mts | 41 + test/release-workflow.test.mts | 179 +- test/script-run-main.test.mts | 2 +- test/unit/constants.test.mts | 15 + .../repo/cache-downloads/index.test.mts | 189 ++ test/unit/lib/native/cli-harness.test.mts | 160 ++ .../lib/native/command-inventory.test.mts | 128 ++ test/unit/lib/optimize-svg.test.mts | 57 + test/unit/lib/terminal/animation.test.mts | 317 +++ test/unit/lib/terminal/output.test.mts | 74 + test/unit/scripts/constants.test.mts | 15 + test/unit/scripts/test/apple-signing.test.mts | 429 ++++ .../test/snapshot-dependencies.test.mts | 22 + test/unit/scripts/test/snapshots.test.mts | 172 ++ test/unit/sea/build-sea.test.mts | 187 ++ test/unit/sea/build/lock.test.mts | 20 + test/unit/sea/build/publish.test.mts | 107 + test/unit/sea/download-bytes.test.mts | 135 ++ test/unit/sea/download-cache.test.mts | 265 +++ test/unit/sea/runtime.test.mts | 100 + test/unit/sea/version.test.mts | 171 ++ test/unit/utils/agent.test.mts | 52 + test/unit/utils/packages.test.mts | 70 + test/unit/utils/self-update.terminal.test.mts | 115 ++ test/unit/utils/self-update.test.mts | 95 + test/utils.mts | 134 +- test/utils.test.mts | 18 + tsconfig.dts.json | 19 - 265 files changed, 26146 insertions(+), 1784 deletions(-) create mode 100644 .config/release.json create mode 100644 .config/rollup.cdxgen-sea.config.mjs create mode 100644 .config/rollup.synp-sea.config.mjs create mode 100644 .config/taze.config.mts create mode 100644 .config/tsconfig.dts.json rename tsconfig.scripts.json => .config/tsconfig.scripts.json (72%) rename vitest.config.mts => .config/vitest.config.mts (50%) rename vitest.e2e.config.mts => .config/vitest.e2e.config.mts (89%) create mode 100644 .config/vitest.integration.config.mts create mode 100644 .config/vitest.native.config.mts create mode 100644 .config/vitest.terminal.config.mts create mode 100644 .github/actions/repo/cache-downloads/action.yml create mode 100644 .github/actions/repo/cache-downloads/index.mts create mode 100644 .github/workflows/release-github.yml create mode 100644 .npmrc create mode 100644 scripts/build-package-matrix.mts create mode 100644 scripts/release/approve-npm.mts create mode 100644 scripts/release/availability.mts create mode 100644 scripts/release/packages.mts rename scripts/release/{bump.mts => prepare.mts} (55%) create mode 100644 scripts/release/validate-npm-policy.mts create mode 100644 scripts/test/apple-signing.mts create mode 100644 scripts/test/integration.mts create mode 100644 scripts/test/native.mts create mode 100644 scripts/test/snapshot-dependencies.mts create mode 100644 scripts/test/snapshots.mts create mode 100644 scripts/test/terminal-report.mts create mode 100644 src/sea/adapters/cdxgen-worker.mjs create mode 100644 src/sea/adapters/cdxgen.mts create mode 100644 src/sea/adapters/synp-worker.mjs create mode 100644 src/sea/adapters/synp.mts create mode 100644 src/sea/archive-paths.mts create mode 100644 src/sea/build/lock.mts create mode 100644 src/sea/build/publish.mts create mode 100644 src/sea/capabilities.mts create mode 100644 src/sea/download-bytes.mts create mode 100644 src/sea/download-cache.mts create mode 100644 src/sea/licenses/cdxgen-third-party/boolbase-1.0.0-ISC.txt create mode 100644 src/sea/licenses/cdxgen-third-party/err-code-2.0.3-MIT.txt create mode 100644 src/sea/licenses/cdxgen-third-party/imurmurhash-0.1.4-MIT.txt create mode 100644 src/sea/licenses/cdxgen-third-party/spdx-exceptions-2.5.0-CC-BY-3.0.txt create mode 100644 src/sea/licenses/cdxgen-third-party/spdx-license-ids-3.0.22-CC0-1.0.txt create mode 100644 src/sea/licenses/core-third-party/ansi-term-0.0.2-ISC.txt create mode 100644 src/sea/licenses/core-third-party/drawille-canvas-blessed-contrib-0.1.3-MIT.txt create mode 100644 src/sea/licenses/core-third-party/npmcli-agent-3.0.0-ISC.txt create mode 100644 src/sea/licenses/core-third-party/npmcli-agent-4.0.0-ISC.txt create mode 100644 src/sea/licenses/core-third-party/sigstore-verify-3.1.1-Apache-2.0.txt create mode 100644 src/sea/licenses/core-third-party/x256-0.0.2-MIT.txt create mode 100644 src/sea/licenses/socket-patch-MIT.txt create mode 100644 src/sea/rollup-provenance.mjs create mode 100644 src/sea/runtime.mts create mode 100644 src/sea/stage-synp.mts create mode 100644 src/sea/stage-uv.mts create mode 100644 src/sea/uv-pins.json create mode 100644 src/sea/version.mts delete mode 100644 src/utils/dlx-binary.mts delete mode 100644 src/utils/dlx-binary.test.mts create mode 100644 src/utils/is-object-object.d.mts create mode 100644 src/utils/is-object-object.mjs delete mode 100644 src/utils/self-update.test.mts create mode 100644 test/lib/native/cli-harness.mts create mode 100644 test/lib/native/command-inventory.mts create mode 100644 test/lib/native/tui-api.mts create mode 100644 test/lib/optimize-svg.mts create mode 100644 test/lib/terminal/animation.mts create mode 100644 test/lib/terminal/output.mts create mode 100644 test/native-snapshots/behavior-config-get.output.svg create mode 100644 test/native-snapshots/behavior-config-invalid-key.output.svg create mode 100644 test/native-snapshots/behavior-config-list.output.svg create mode 100644 test/native-snapshots/behavior-json-missing.output.svg create mode 100644 test/native-snapshots/behavior-json-seeded.output.svg create mode 100644 test/native-snapshots/behavior-manifest-setup.canceled.svg create mode 100644 test/native-snapshots/behavior-manifest-setup.exit-selected.svg create mode 100644 test/native-snapshots/behavior-manifest-setup.menu.svg create mode 100644 test/native-snapshots/behavior-wrapper-on-dry-run.output.svg create mode 100644 test/native-snapshots/native-analytics-tui.charts.svg create mode 100644 test/native-snapshots/native-audit-log-tui.table.svg create mode 100644 test/native-snapshots/native-threat-feed-tui.table.svg create mode 100644 test/native-snapshots/self-update-notice-standard.standard.svg create mode 100644 test/native-snapshots/self-update-notice-wide.wide.svg create mode 100644 test/native-snapshots/self-update-notice-wrapped.wrapped.svg create mode 100644 test/native-snapshots/self-update-suppressed.suppressed.svg create mode 100644 test/native-snapshots/socket-cdxgen-unsupported-type.unsupported-type.svg create mode 100644 test/native-snapshots/socket-help-analytics.help.svg create mode 100644 test/native-snapshots/socket-help-audit-log.help.svg create mode 100644 test/native-snapshots/socket-help-cdxgen.help.svg create mode 100644 test/native-snapshots/socket-help-ci.help.svg create mode 100644 test/native-snapshots/socket-help-config-auto.help.svg create mode 100644 test/native-snapshots/socket-help-config-get.help.svg create mode 100644 test/native-snapshots/socket-help-config-list.help.svg create mode 100644 test/native-snapshots/socket-help-config-set.help.svg create mode 100644 test/native-snapshots/socket-help-config-unset.help.svg create mode 100644 test/native-snapshots/socket-help-config.help.svg create mode 100644 test/native-snapshots/socket-help-dependencies.help.svg create mode 100644 test/native-snapshots/socket-help-fix.help.svg create mode 100644 test/native-snapshots/socket-help-install-completion.help.svg create mode 100644 test/native-snapshots/socket-help-install.help.svg create mode 100644 test/native-snapshots/socket-help-json.help.svg create mode 100644 test/native-snapshots/socket-help-license.help.svg create mode 100644 test/native-snapshots/socket-help-login.help.svg create mode 100644 test/native-snapshots/socket-help-logout.help.svg create mode 100644 test/native-snapshots/socket-help-manifest-auto.help.svg create mode 100644 test/native-snapshots/socket-help-manifest-bazel.help.svg create mode 100644 test/native-snapshots/socket-help-manifest-cdxgen.help.svg create mode 100644 test/native-snapshots/socket-help-manifest-conda.help.svg create mode 100644 test/native-snapshots/socket-help-manifest-dotnet.help.svg create mode 100644 test/native-snapshots/socket-help-manifest-dynamic-sbom-inference.help.svg create mode 100644 test/native-snapshots/socket-help-manifest-gradle.help.svg create mode 100644 test/native-snapshots/socket-help-manifest-kotlin.help.svg create mode 100644 test/native-snapshots/socket-help-manifest-maven.help.svg create mode 100644 test/native-snapshots/socket-help-manifest-scala.help.svg create mode 100644 test/native-snapshots/socket-help-manifest-setup.help.svg create mode 100644 test/native-snapshots/socket-help-manifest.help.svg create mode 100644 test/native-snapshots/socket-help-npm.help.svg create mode 100644 test/native-snapshots/socket-help-npx.help.svg create mode 100644 test/native-snapshots/socket-help-oops.help.svg create mode 100644 test/native-snapshots/socket-help-optimize.help.svg create mode 100644 test/native-snapshots/socket-help-organization-dependencies.help.svg create mode 100644 test/native-snapshots/socket-help-organization-list.help.svg create mode 100644 test/native-snapshots/socket-help-organization-policy-license.help.svg create mode 100644 test/native-snapshots/socket-help-organization-policy-security.help.svg create mode 100644 test/native-snapshots/socket-help-organization-policy.help.svg create mode 100644 test/native-snapshots/socket-help-organization-quota.help.svg create mode 100644 test/native-snapshots/socket-help-organization.help.svg create mode 100644 test/native-snapshots/socket-help-package-score.help.svg create mode 100644 test/native-snapshots/socket-help-package-shallow.help.svg create mode 100644 test/native-snapshots/socket-help-package.help.svg create mode 100644 test/native-snapshots/socket-help-patch.help.svg create mode 100644 test/native-snapshots/socket-help-pnpm.help.svg create mode 100644 test/native-snapshots/socket-help-raw-npm.help.svg create mode 100644 test/native-snapshots/socket-help-raw-npx.help.svg create mode 100644 test/native-snapshots/socket-help-repository-create.help.svg create mode 100644 test/native-snapshots/socket-help-repository-del.help.svg create mode 100644 test/native-snapshots/socket-help-repository-list.help.svg create mode 100644 test/native-snapshots/socket-help-repository-update.help.svg create mode 100644 test/native-snapshots/socket-help-repository-view.help.svg create mode 100644 test/native-snapshots/socket-help-repository.help.svg create mode 100644 test/native-snapshots/socket-help-scan-create.help.svg create mode 100644 test/native-snapshots/socket-help-scan-del.help.svg create mode 100644 test/native-snapshots/socket-help-scan-diff.help.svg create mode 100644 test/native-snapshots/socket-help-scan-github.help.svg create mode 100644 test/native-snapshots/socket-help-scan-list.help.svg create mode 100644 test/native-snapshots/socket-help-scan-metadata.help.svg create mode 100644 test/native-snapshots/socket-help-scan-reach.help.svg create mode 100644 test/native-snapshots/socket-help-scan-report.help.svg create mode 100644 test/native-snapshots/socket-help-scan-setup.help.svg create mode 100644 test/native-snapshots/socket-help-scan-view.help.svg create mode 100644 test/native-snapshots/socket-help-scan.help.svg create mode 100644 test/native-snapshots/socket-help-security.help.svg create mode 100644 test/native-snapshots/socket-help-threat-feed.help.svg create mode 100644 test/native-snapshots/socket-help-uninstall-completion.help.svg create mode 100644 test/native-snapshots/socket-help-uninstall.help.svg create mode 100644 test/native-snapshots/socket-help-wrapper.help.svg create mode 100644 test/native-snapshots/socket-help-yarn.help.svg create mode 100644 test/native-snapshots/socket-help.help.svg create mode 100644 test/native-snapshots/socket-manifest-dry-run.dry-run.svg create mode 100644 test/native-snapshots/socket-patch-apply-help.help.svg create mode 100644 test/native-snapshots/socket-patch-get-help.help.svg create mode 100644 test/native-snapshots/socket-patch-list-help.help.svg create mode 100644 test/native-snapshots/socket-patch-remove-help.help.svg create mode 100644 test/native-snapshots/socket-patch-repair-help.help.svg create mode 100644 test/native-snapshots/socket-patch-rollback-help.help.svg create mode 100644 test/native-snapshots/socket-patch-scan-help.help.svg create mode 100644 test/native-snapshots/socket-patch-setup-help.help.svg create mode 100644 test/native-snapshots/socket-scan-setup.canceled.svg create mode 100644 test/native-snapshots/socket-scan-setup.github-selected.svg create mode 100644 test/native-snapshots/socket-scan-setup.menu.svg create mode 100644 test/native-snapshots/socket-version.version.svg create mode 100644 test/native/commands/cli-behavior.native.test.mts create mode 100644 test/native/commands/cli.native.test.mts create mode 100644 test/native/commands/tui.native.test.mts create mode 100644 test/package-matrix.test.mts create mode 100644 test/release-approve-npm.test.mts create mode 100644 test/release-availability.test.mts create mode 100644 test/release-npm-policy.test.mts rename test/{release-bump.test.mts => release-prepare.test.mts} (97%) create mode 100644 test/unit/constants.test.mts create mode 100644 test/unit/github/actions/repo/cache-downloads/index.test.mts create mode 100644 test/unit/lib/native/cli-harness.test.mts create mode 100644 test/unit/lib/native/command-inventory.test.mts create mode 100644 test/unit/lib/optimize-svg.test.mts create mode 100644 test/unit/lib/terminal/animation.test.mts create mode 100644 test/unit/lib/terminal/output.test.mts create mode 100644 test/unit/scripts/constants.test.mts create mode 100644 test/unit/scripts/test/apple-signing.test.mts create mode 100644 test/unit/scripts/test/snapshot-dependencies.test.mts create mode 100644 test/unit/scripts/test/snapshots.test.mts create mode 100644 test/unit/sea/build-sea.test.mts create mode 100644 test/unit/sea/build/lock.test.mts create mode 100644 test/unit/sea/build/publish.test.mts create mode 100644 test/unit/sea/download-bytes.test.mts create mode 100644 test/unit/sea/download-cache.test.mts create mode 100644 test/unit/sea/runtime.test.mts create mode 100644 test/unit/sea/version.test.mts create mode 100644 test/unit/utils/agent.test.mts create mode 100644 test/unit/utils/packages.test.mts create mode 100644 test/unit/utils/self-update.terminal.test.mts create mode 100644 test/unit/utils/self-update.test.mts create mode 100644 test/utils.test.mts delete mode 100644 tsconfig.dts.json diff --git a/.claude/skills/bump-coana/SKILL.md b/.claude/skills/bump-coana/SKILL.md index b8e3e60e7e..830361b029 100644 --- a/.claude/skills/bump-coana/SKILL.md +++ b/.claude/skills/bump-coana/SKILL.md @@ -47,7 +47,7 @@ PRs. 🚨 **Do NOT touch `package.json`'s top-level `version` field.** The release workflow owns it: between releases the manifest holds the last released version, and -`scripts/release/bump.mts` derives and writes the next one in-run. Hand-writing +`scripts/release/prepare.mts` derives and writes the next one in-run. Hand-writing it invents a version that never releases and breaks the next release — a manifest already sitting on the version being released has no line for the bump to advance. diff --git a/.config/release.json b/.config/release.json new file mode 100644 index 0000000000..1b4c659517 --- /dev/null +++ b/.config/release.json @@ -0,0 +1,6 @@ +{ + "mode": "prerelease-only", + "versionHint": "2.1.0", + "identifier": "rc", + "distTag": "rc-2.1.0" +} diff --git a/.config/rollup.base.config.mjs b/.config/rollup.base.config.mjs index e551e24256..79466b09c7 100644 --- a/.config/rollup.base.config.mjs +++ b/.config/rollup.base.config.mjs @@ -21,6 +21,7 @@ import { isBuiltin, normalizeId, } from '../scripts/utils/packages.js' +import { includedModulesInventoryPlugin } from '../src/sea/rollup-provenance.mjs' const { INLINED_SOCKET_CLI_CYCLONEDX_CDXGEN_VERSION, @@ -229,7 +230,11 @@ export default function baseConfig(extendConfig = {}) { ], [ INLINED_SOCKET_CLI_VERSION, - () => JSON.stringify(getRootPkgJsonSync().version), + () => + JSON.stringify( + constants.ENV.INLINED_SOCKET_CLI_VERSION || + getRootPkgJsonSync().version, + ), ], [ INLINED_SOCKET_CLI_VERSION_HASH, @@ -289,6 +294,15 @@ export default function baseConfig(extendConfig = {}) { }, }), ...extendPlugins, + ...(process.env.SOCKET_SEA_BUILD === 'true' + ? [ + includedModulesInventoryPlugin({ + root: rootPath, + inventoryFile: 'THIRD-PARTY-NOTICES.json', + bundleId: 'core-rollup', + }), + ] + : []), ], } } diff --git a/.config/rollup.cdxgen-sea.config.mjs b/.config/rollup.cdxgen-sea.config.mjs new file mode 100644 index 0000000000..64087b8e74 --- /dev/null +++ b/.config/rollup.cdxgen-sea.config.mjs @@ -0,0 +1,514 @@ +/** Bundle the cdxgen API for offline execution from the Node SEA VFS. */ +/* eslint-disable no-await-in-loop -- VFS staging writes shared paths in dependency order. */ +import { createHash } from 'node:crypto' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import url from 'node:url' + +import commonjsPlugin from '@rollup/plugin-commonjs' +import jsonPlugin from '@rollup/plugin-json' +import { nodeResolve } from '@rollup/plugin-node-resolve' +import { parse as parseYaml } from 'yaml' + +const root = path.resolve( + path.dirname(url.fileURLToPath(import.meta.url)), + '..', +) +const curatedLegalRoot = path.join(root, 'src/sea/licenses/cdxgen-third-party') + +// Exact version keys ensure dependency bumps cannot silently reuse stale text. +// A missing match is recorded in the notice inventory as an obligation. +const curatedLegalDocuments = { + 'boolbase@1.0.0': [ + { + filename: 'boolbase-1.0.0-ISC.txt', + kind: 'license', + sourcePath: 'LICENSE (upstream file; omitted from npm tarball)', + authority: + 'https://github.com/fb55/boolbase/blob/be0bcd8a4e917a0a5895e95b523fbbed05a64871/LICENSE', + }, + ], + 'err-code@2.0.3': [ + { + filename: 'err-code-2.0.3-MIT.txt', + kind: 'license', + sourcePath: + 'MIT license (npm package README declares MIT; package author used for copyright attribution)', + authority: + 'https://raw.githubusercontent.com/spdx/license-list-data/v3.26.0/text/MIT.txt', + attributionSource: + 'https://raw.githubusercontent.com/IndigoUnited/js-err-code/92511d41a6a926c94c9d11493404867b1e92a77/README.md', + attributionInferredFrom: + 'npm package author field (IndigoUnited); upstream v2.0.3 README declares MIT but no copyright line or license file.', + }, + ], + 'imurmurhash@0.1.4': [ + { + filename: 'imurmurhash-0.1.4-MIT.txt', + kind: 'license', + sourcePath: 'LICENSE (upstream file; omitted from npm tarball)', + authority: 'https://github.com/jensyt/imurmurhash-js/blob/0.1.4/LICENSE', + }, + ], + 'spdx-exceptions@2.5.0': [ + { + filename: 'spdx-exceptions-2.5.0-CC-BY-3.0.txt', + kind: 'license', + sourcePath: 'CC-BY-3.0 legal code (npm README declares CC-BY-3.0)', + authority: + 'https://raw.githubusercontent.com/spdx/license-list-data/v3.26.0/text/CC-BY-3.0.txt', + }, + ], + 'spdx-license-ids@3.0.22': [ + { + filename: 'spdx-license-ids-3.0.22-CC0-1.0.txt', + kind: 'license', + sourcePath: 'CC0-1.0 legal code (npm README declares CC0-1.0)', + authority: + 'https://raw.githubusercontent.com/spdx/license-list-data/v3.26.0/text/CC0-1.0.txt', + }, + ], +} + +const legalFilenamePattern = + /^(?:license|licence|copying|copyright|notice|patents?)(?:[._ -].*)?$/i + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function safePart(value) { + return String(value).replace(/[^A-Za-z0-9._-]+/g, '_') || 'unknown' +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0 +} + +async function findPackageRoot(modulePath) { + let current = path.dirname(modulePath) + while (current !== path.dirname(current)) { + const packageJsonPath = path.join(current, 'package.json') + try { + const metadata = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) + if ( + typeof metadata.name === 'string' && + typeof metadata.version === 'string' + ) { + return { directory: current, metadata } + } + } catch (error) { + if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) { + throw error + } + } + if (path.basename(current) === 'node_modules') { + break + } + current = path.dirname(current) + } +} + +async function findLegalFiles(packageRoot) { + const files = [] + const skippedLinks = [] + const visit = async (directory, relativeDirectory = '') => { + for (const entry of ( + await fs.readdir(directory, { withFileTypes: true }) + ).sort((left, right) => compareText(left.name, right.name))) { + const relativePath = path.posix.join( + relativeDirectory.split(path.sep).join('/'), + entry.name, + ) + const fullPath = path.join(directory, entry.name) + if (entry.isDirectory()) { + if (entry.name !== 'node_modules' && entry.name !== '.git') { + await visit(fullPath, path.join(relativeDirectory, entry.name)) + } + } else if (entry.isSymbolicLink()) { + if (legalFilenamePattern.test(entry.name)) { + skippedLinks.push(relativePath) + } + } else if (entry.isFile() && legalFilenamePattern.test(entry.name)) { + files.push({ fullPath, relativePath }) + } + } + } + await visit(packageRoot) + return { files, skippedLinks } +} + +function thirdPartyNoticePlugin() { + return { + name: 'sea-cdxgen-third-party-notices', + async generateBundle() { + const packages = new Map() + const sourceModules = new Map() + const unmappedModuleCount = { count: 0 } + const lock = parseYaml( + await fs.readFile(path.join(root, 'pnpm-lock.yaml'), 'utf8'), + ) + if (!lock || typeof lock.packages !== 'object' || !lock.packages) { + throw new Error('pnpm-lock.yaml does not contain a packages graph') + } + const lockPackages = lock.packages + const patchedDependencies = lock.patchedDependencies ?? {} + for (const id of this.getModuleIds()) { + if (id.startsWith('\0')) { + continue + } + const rawPath = id.startsWith('file:') + ? url.fileURLToPath(id) + : id.split('?')[0] + if ( + !path.isAbsolute(rawPath) || + !rawPath.split(path.sep).includes('node_modules') + ) { + continue + } + let modulePath + try { + modulePath = await fs.realpath(rawPath) + } catch (error) { + if (error?.code === 'ENOENT') { + continue + } + throw error + } + const packageInfo = await findPackageRoot(modulePath) + if (!packageInfo) { + unmappedModuleCount.count += 1 + const moduleDigest = sha256(await fs.readFile(modulePath)) + sourceModules.set(modulePath, { + sourcePath: `unmapped/${path.basename(modulePath)}-${moduleDigest.slice(0, 12)}`, + sha256: moduleDigest, + package: null, + verification: 'unverified', + verificationReason: + 'No package metadata root was found for this Rollup module.', + }) + continue + } + const { directory, metadata } = packageInfo + const lockKeyCandidates = Object.keys(lockPackages) + .filter( + key => + key === `${metadata.name}@${metadata.version}` || + key.startsWith(`${metadata.name}@${metadata.version}(`), + ) + .sort(compareText) + const lockKey = + lockKeyCandidates.length === 1 ? lockKeyCandidates[0] : null + const lockRecord = lockKey ? lockPackages[lockKey] : null + const lockContexts = lockKeyCandidates.map(candidate => ({ + packageKey: candidate, + resolution: lockPackages[candidate]?.resolution ?? null, + })) + const patch = + patchedDependencies[`${metadata.name}@${metadata.version}`] ?? null + const instancePath = + lockKey ?? + (lockKeyCandidates.length + ? `ambiguous:${lockKeyCandidates.join('|')}` + : `${metadata.name}@${metadata.version}:missing-lock-entry`) + const key = `${metadata.name}@${metadata.version}\0${instancePath}` + let record = packages.get(key) + if (!record) { + record = { + packageName: metadata.name, + version: metadata.version, + instancePath, + lockKey, + lockContexts, + lockResolution: lockRecord?.resolution ?? null, + patch: patch + ? { path: patch.path ?? null, hash: patch.hash ?? null } + : null, + curatedLegalDocuments: + curatedLegalDocuments[`${metadata.name}@${metadata.version}`] ?? + [], + license: null, + licenseDeclarations: new Map(), + roots: new Map(), + moduleCount: 0, + obligations: [], + } + packages.set(key, record) + } + const licenseDeclaration = metadata.license ?? metadata.licenses ?? null + record.licenseDeclarations.set( + JSON.stringify(licenseDeclaration), + licenseDeclaration, + ) + const declarationValues = [...record.licenseDeclarations.entries()] + .sort(([left], [right]) => compareText(left, right)) + .map(([, value]) => value) + record.license = + declarationValues.length === 1 + ? declarationValues[0] + : declarationValues + record.moduleCount += 1 + const relativeModulePath = path + .relative(directory, modulePath) + .split(path.sep) + .join('/') + sourceModules.set(modulePath, { + sourcePath: `${instancePath}/${relativeModulePath}`, + package: `${metadata.name}@${metadata.version}`, + packageInstance: instancePath, + packagePath: relativeModulePath, + sha256: sha256(await fs.readFile(modulePath)), + lockPackageKey: lockKey, + lockPackageCandidates: lockContexts, + lockResolution: lockRecord?.resolution ?? null, + declaredLockIntegrity: lockRecord?.resolution?.integrity ?? null, + patch: patch + ? { path: patch.path ?? null, hash: patch.hash ?? null } + : null, + verification: 'unverified', + verificationReason: + 'Installed module bytes were hashed but not authenticated against locked tarball or patch output; declared lock integrity does not verify generated bundle bytes.', + }) + if (!record.roots.has(directory)) { + const { files, skippedLinks } = await findLegalFiles(directory) + record.roots.set(directory, { files, skippedLinks }) + } + } + + const inventory = { + schemaVersion: 1, + generator: 'rollup-module-closure', + packages: [], + unmappedModuleCount: unmappedModuleCount.count, + obligations: [], + } + const sourceInventory = { + schemaVersion: 1, + generator: 'rollup-module-source-hashes', + lockfile: { + path: 'pnpm-lock.yaml', + lockfileVersion: lock.lockfileVersion, + }, + verification: 'unverified', + verificationReason: + 'Hashes identify installed module source files. Declared lock SRI is metadata only and does not authenticate installed, patched, or generated bundle bytes.', + modules: [...sourceModules.values()].sort((left, right) => + compareText(left.sourcePath, right.sourcePath), + ), + } + const sourceInventoryJson = `${JSON.stringify(sourceInventory, null, 2)}\n` + const sourceInventorySha256 = sha256(Buffer.from(sourceInventoryJson)) + this.emitFile({ + type: 'asset', + fileName: 'ROLLUP-SOURCE-INVENTORY.json', + source: sourceInventoryJson, + }) + inventory.sourceInventory = { + path: 'tools/cdxgen/ROLLUP-SOURCE-INVENTORY.json', + sha256: sourceInventorySha256, + moduleCount: sourceModules.size, + verification: 'unverified', + } + if (unmappedModuleCount.count > 0) { + inventory.obligations.push({ + code: 'unmapped-rollup-modules', + count: unmappedModuleCount.count, + message: + 'Some bundled node_modules modules could not be mapped to package metadata.', + }) + } + + for (const record of [...packages.values()].sort((left, right) => + compareText( + `${left.packageName}@${left.version}\0${left.instancePath}`, + `${right.packageName}@${right.version}\0${right.instancePath}`, + ), + )) { + if (record.licenseDeclarations.size > 1) { + record.obligations.push({ + code: 'conflicting-license-declarations', + message: + 'The same package name and version resolved with different license declarations.', + }) + } + if ( + record.curatedLegalDocuments.some( + document => document.attributionInferredFrom, + ) + ) { + record.obligations.push({ + code: 'inferred-copyright-attribution', + message: record.curatedLegalDocuments.find( + document => document.attributionInferredFrom, + ).attributionInferredFrom, + }) + } + const licenseIsString = + typeof record.license === 'string' && record.license.trim() !== '' + if (!licenseIsString) { + record.obligations.push({ + code: record.license + ? 'ambiguous-license-declaration' + : 'missing-license-declaration', + message: + 'package.json does not provide one unambiguous nonempty license string.', + }) + } + if ( + Array.isArray(record.license) || + (record.license && typeof record.license === 'object') + ) { + record.obligations.push({ + code: 'ambiguous-license-declaration-format', + message: + 'The license declaration is structured or deprecated and requires review.', + }) + } + + const id = `${record.packageName}@${record.version}\0${record.instancePath}` + const packageSlug = `${safePart(record.packageName)}-${safePart(record.version)}-${sha256(Buffer.from(id)).slice(0, 12)}` + const stagedFiles = new Map() + const skippedLinks = [] + for (const { files, skippedLinks: links } of record.roots.values()) { + skippedLinks.push(...links) + for (const file of files) { + const contents = await fs.readFile(file.fullPath) + const digest = sha256(contents) + const relativePath = file.relativePath.split(path.sep).join('/') + const sourcePath = path.posix.parse(relativePath) + const contentAddressedName = `${sourcePath.name}.${digest.slice(0, 12)}${sourcePath.ext}` + const outputPath = `notices/${packageSlug}/${sourcePath.dir ? `${sourcePath.dir}/` : ''}${contentAddressedName}` + stagedFiles.set(outputPath, { + contents, + sha256: digest, + relativePath, + }) + } + } + + // Several published npm tarballs omit the license text even though + // package.json declares a license. Include version-pinned authoritative + // texts and attribution material collected during the SEA audit. + for (const document of record.curatedLegalDocuments) { + const contents = await fs.readFile( + path.join(curatedLegalRoot, document.filename), + ) + const digest = sha256(contents) + const outputPath = `notices/${packageSlug}/${document.filename}` + stagedFiles.set(outputPath, { + contents, + sha256: digest, + relativePath: document.sourcePath, + kind: document.kind, + authority: document.authority, + ...(document.attributionSource + ? { attributionSource: document.attributionSource } + : {}), + ...(document.attributionInferredFrom + ? { attributionInferredFrom: document.attributionInferredFrom } + : {}), + }) + } + + const licenseFiles = [] + const noticeFiles = [] + for (const [outputPath, file] of [...stagedFiles].sort(([a], [b]) => + compareText(a, b), + )) { + this.emitFile({ + type: 'asset', + fileName: outputPath, + source: file.contents, + }) + const reference = { + path: `tools/cdxgen/${outputPath}`, + sourcePath: file.relativePath, + sha256: file.sha256, + ...(file.authority + ? { + source: file.authority, + ...(file.attributionSource + ? { attributionSource: file.attributionSource } + : {}), + ...(file.attributionInferredFrom + ? { attributionInferredFrom: file.attributionInferredFrom } + : {}), + } + : {}), + } + const basename = path.posix.basename(file.relativePath) + if ( + file.kind === 'notice' || + /^(?:notice|copyright|patents?)/i.test(basename) + ) { + noticeFiles.push(reference) + } else { + licenseFiles.push(reference) + } + } + if (licenseFiles.length === 0) { + record.obligations.push({ + code: 'missing-license-text', + message: + 'No license text file was found in the resolved package tree.', + }) + } + if (skippedLinks.length > 0) { + record.obligations.push({ + code: 'unresolved-license-symlink', + paths: [...new Set(skippedLinks)].sort(compareText), + message: + 'A license or notice symlink was not followed when collecting package texts.', + }) + } + const packageRecord = { + name: record.packageName, + version: record.version, + instancePath: record.instancePath, + lockPackageKey: record.lockKey, + lockContexts: record.lockContexts, + patch: record.patch, + license: record.license, + moduleCount: record.moduleCount, + licenseFiles, + noticeFiles, + obligations: record.obligations, + } + inventory.packages.push(packageRecord) + for (const obligation of record.obligations) { + inventory.obligations.push({ + package: `${record.packageName}@${record.version}`, + ...obligation, + }) + } + } + + this.emitFile({ + type: 'asset', + fileName: 'THIRD-PARTY-NOTICES.json', + source: `${JSON.stringify(inventory, null, 2)}\n`, + }) + }, + } +} + +export default { + input: + process.env.SEA_CDXGEN_INPUT || + path.join(root, 'node_modules/@cyclonedx/cdxgen/lib/cli/index.js'), + output: { + dir: + process.env.SEA_CDXGEN_OUTPUT || path.join(root, 'dist/sea-cdxgen-api'), + format: 'es', + entryFileNames: 'lib/cli/index.js', + chunkFileNames: 'lib/cli/chunks/[name]-[hash].js', + assetFileNames: 'lib/cli/assets/[name]-[hash][extname]', + }, + external: id => id.startsWith('node:'), + plugins: [ + nodeResolve({ preferBuiltins: true, exportConditions: ['node'] }), + jsonPlugin(), + commonjsPlugin(), + thirdPartyNoticePlugin(), + ], +} diff --git a/.config/rollup.dist.config.mjs b/.config/rollup.dist.config.mjs index 1c41116912..14abdf87b1 100644 --- a/.config/rollup.dist.config.mjs +++ b/.config/rollup.dist.config.mjs @@ -30,6 +30,10 @@ import { isBuiltin, normalizeId, } from '../scripts/utils/packages.js' +import { + includedModulesInventoryPlugin, + writeCopiedPackageInventory, +} from '../src/sea/rollup-provenance.mjs' const { CONSTANTS, @@ -68,10 +72,11 @@ const BLESSED_CONTRIB = 'blessed-contrib' const FLAGS = 'flags' const LICENSE_MD = `LICENSE.md` const SENTRY_NODE = '@sentry/node' +const SEA_BUILD_ENV = 'SOCKET_SEA_BUILD' const SOCKET_DESCRIPTION = 'CLI for Socket.dev' const SOCKET_DESCRIPTION_WITH_SENTRY = `${SOCKET_DESCRIPTION}, includes Sentry error handling, otherwise identical to the regular \`${SOCKET_CLI_BIN_NAME}\` package` -const SOCKET_SECURITY_REGISTRY = '@socketsecurity/registry' const UTILS = 'utils' +const SOCKET_SECURITY_REGISTRY = '@socketsecurity/registry' const VENDOR = 'vendor' // A fresh regexp per call: socketModifyPlugin advances lastIndex, so a shared @@ -106,9 +111,8 @@ async function copyInitGradle() { // Copy the build-tool resolution assets (Gradle init script, sbt plugin, // Maven extension jar, dotnet tool) into dist/manifest-scripts, where run.mts resolves them -// at runtime. The Maven jar is compiled by maven-extension/build-jar.sh (run in -// CI / local dev) and is absent from a fresh checkout — copy it only if present; -// run.mts surfaces a build hint when it's missing. +// at runtime. The Maven jar is compiled by maven-extension/build-jar.sh. Local +// dev builds may omit it; published builds fail here if it is missing. async function copyManifestScripts() { const srcDir = path.join(constants.srcPath, 'commands/manifest/scripts') const destDir = path.join(constants.distPath, 'manifest-scripts') @@ -155,7 +159,9 @@ async function copyManifestScripts() { jarPath, path.join(destDir, 'maven-extension', 'coana-maven-extension.jar'), ) - } else if (constants.ENV[INLINED_SOCKET_CLI_PUBLISHED_BUILD]) { + } else if ( + constants.ENV[INLINED_SOCKET_CLI_PUBLISHED_BUILD] + ) { // Fail closed: a published build without the jar would ship a package whose // `socket manifest maven` / Maven reachability silently produces an empty // SBOM. Run `pnpm run build:maven-extension` before `build:dist`. (A local @@ -191,9 +197,7 @@ async function copyExternalPackages() { // Copy the other packages over to ./external/. .map(n => copyPackage(n, { - strict: - // Skip adding 'use strict' directives to Socket packages. - n !== SOCKET_SECURITY_REGISTRY, + strict: n !== SOCKET_SECURITY_REGISTRY, }), ), // Copy 'blessed-contrib' license over to @@ -259,6 +263,24 @@ async function copyExternalPackages() { ) } +function blessedContribInventoryPlugin(filepath) { + if (process.env[SEA_BUILD_ENV] !== 'true') { + return undefined + } + const relativeEntry = path + .relative(constants.srcPath, filepath) + .split(path.sep) + .join('/') + .replace(/\.mjs$/, '') + return includedModulesInventoryPlugin({ + root: constants.rootPath, + inventoryFile: `THIRD-PARTY-NOTICES-${relativeEntry.replaceAll('/', '-')}.json`, + bundleId: `external-${relativeEntry}`, + outputRoot: constants.distPath, + mode: 'stage', + }) +} + async function copyPackage(pkgName, options) { const { strict = true } = { __proto__: null, ...options } const nmPath = path.join(constants.rootPath, NODE_MODULES) @@ -555,19 +577,47 @@ export default async () => { }), { async writeBundle() { + const updatePackageMetadata = process.env[SEA_BUILD_ENV] !== 'true' await Promise.all([ copyCoanaCache(), copyInitGradle(), copyManifestScripts(), copyBashCompletion(), - updatePackageJson(), + ...(updatePackageMetadata ? [updatePackageJson()] : []), // Remove dist/vendor.js.map file. trash([path.join(distPath, `${VENDOR}.js.map`)]), ]) // Copy external packages AFTER other operations to avoid conflicts. await copyExternalPackages() + if (process.env[SEA_BUILD_ENV] === 'true') { + await writeCopiedPackageInventory({ + root: rootPath, + packageName: SOCKET_SECURITY_REGISTRY, + packageRoot: path.join( + rootPath, + NODE_MODULES, + SOCKET_SECURITY_REGISTRY, + ), + outputPackageRoot: constants.socketRegistryPath, + outputRoot: distPath, + inventoryFile: + 'THIRD-PARTY-NOTICES-external-socket-registry.json', + bundleId: 'external-socket-registry', + }) + await writeCopiedPackageInventory({ + root: rootPath, + packageName: BLESSED, + packageRoot: path.join(rootPath, NODE_MODULES, BLESSED), + outputPackageRoot: path.join(constants.externalPath, BLESSED), + outputRoot: distPath, + inventoryFile: 'THIRD-PARTY-NOTICES-external-blessed.json', + bundleId: 'external-blessed', + }) + } // Update package-lock.json AFTER package.json. - await updatePackageLockFile() + if (updatePackageMetadata) { + await updatePackageLockFile() + } }, }, ], @@ -581,11 +631,19 @@ export default async () => { }) ).map(filepath => { const relPath = `${path.relative(srcPath, filepath).slice(0, -4 /*.mjs*/)}.js` + const relParts = relPath.split(path.sep) + if (relParts[0] !== 'external') { + throw new Error( + `Unexpected Blessed source path outside src/external: ${filepath}`, + ) + } + const outputFile = path.join(constants.externalPath, ...relParts.slice(1)) + const provenancePlugin = blessedContribInventoryPlugin(filepath) return { input: filepath, output: [ { - file: path.join(rootPath, relPath), + file: outputFile, exports: 'auto', externalLiveBindings: false, format: 'cjs', @@ -606,10 +664,11 @@ export default async () => { ) }, plugins: [ + ...(provenancePlugin ? [provenancePlugin] : []), // Runs after copyExternalPackages() and overwrites what it rewired. socketModifyPlugin({ find: newBareBlessedRequireRegExp(), - replace: () => relativeBlessedPath(path.join(rootPath, relPath)), + replace: () => relativeBlessedPath(outputFile), }), nodeResolve({ exportConditions: ['node'], diff --git a/.config/rollup.synp-sea.config.mjs b/.config/rollup.synp-sea.config.mjs new file mode 100644 index 0000000000..8d5e6da89d --- /dev/null +++ b/.config/rollup.synp-sea.config.mjs @@ -0,0 +1,222 @@ +/** Bundle the pinned Synp API and its dependency/license closure for SEA VFS. */ +/* eslint-disable no-await-in-loop -- VFS staging writes shared paths in dependency order. */ +import { createHash } from 'node:crypto' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import url from 'node:url' + +import commonjsPlugin from '@rollup/plugin-commonjs' +import { nodeResolve } from '@rollup/plugin-node-resolve' + +const root = path.resolve( + path.dirname(url.fileURLToPath(import.meta.url)), + '..', +) +const legalPattern = + /^(?:license|licence|copying|copyright|notice|patents?)(?:[._ -].*)?$/i + +function digest(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function slug(value) { + return String(value).replace(/[^A-Za-z0-9._-]+/g, '_') || 'unknown' +} + +async function packageRootFor(modulePath) { + let current = path.dirname(modulePath) + while (current !== path.dirname(current)) { + try { + const metadata = JSON.parse( + await fs.readFile(path.join(current, 'package.json'), 'utf8'), + ) + if ( + typeof metadata.name === 'string' && + typeof metadata.version === 'string' + ) { + return { root: current, metadata } + } + } catch (error) { + if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) { + throw error + } + } + if (path.basename(current) === 'node_modules') { + break + } + current = path.dirname(current) + } +} + +async function legalFiles(packageRoot) { + const found = [] + const visit = async (directory, relative = '') => { + for (const entry of ( + await fs.readdir(directory, { withFileTypes: true }) + ).sort((a, b) => a.name.localeCompare(b.name))) { + const relativePath = path.posix.join( + relative.split(path.sep).join('/'), + entry.name, + ) + const fullPath = path.join(directory, entry.name) + if (entry.isDirectory()) { + if (entry.name !== 'node_modules' && entry.name !== '.git') { + await visit(fullPath, path.join(relative, entry.name)) + } + } else if ( + (entry.isFile() || entry.isSymbolicLink()) && + legalPattern.test(entry.name) + ) { + const resolvedPath = await fs.realpath(fullPath) + if ( + resolvedPath !== packageRoot && + !resolvedPath.startsWith(`${packageRoot}${path.sep}`) + ) { + throw new Error( + `Synp notice file escapes package root: ${relativePath}`, + ) + } + found.push({ fullPath: resolvedPath, relativePath }) + } + } + } + await visit(packageRoot) + return found +} + +function noticesPlugin() { + return { + name: 'sea-synp-third-party-notices', + async generateBundle() { + const packages = new Map() + const unmapped = [] + for (const id of this.getModuleIds()) { + if (id.startsWith('\0')) { + continue + } + const raw = id.startsWith('file:') + ? url.fileURLToPath(id) + : id.split('?')[0] + if ( + !path.isAbsolute(raw) || + !raw.split(path.sep).includes('node_modules') + ) { + continue + } + let real + try { + real = await fs.realpath(raw) + } catch (error) { + if (error?.code === 'ENOENT') { + continue + } + throw error + } + const info = await packageRootFor(real) + if (!info) { + unmapped.push(real) + continue + } + const key = `${info.metadata.name}@${info.metadata.version}` + if (!packages.has(key)) { + packages.set(key, { ...info, key }) + } + } + if (unmapped.length) { + throw new Error( + `Unable to map ${unmapped.length} bundled Synp module(s) to package metadata`, + ) + } + + const inventory = { + schemaVersion: 1, + generator: 'rollup-module-closure', + packages: [], + obligations: [], + } + for (const record of [...packages.values()].sort((a, b) => + a.key.localeCompare(b.key), + )) { + const files = await legalFiles(record.root) + const obligations = [] + if (!files.length) { + obligations.push({ + code: 'missing-license-text', + message: + 'No license or notice text file is present in the installed package; its package metadata license is retained.', + }) + } + const packageSlug = `${slug(record.metadata.name)}-${slug(record.metadata.version)}` + const licenseFiles = [] + const noticeFiles = [] + for (const file of files) { + const contents = await fs.readFile(file.fullPath) + const filename = `notices/${packageSlug}/${file.relativePath}` + this.emitFile({ type: 'asset', fileName: filename, source: contents }) + const reference = { + path: `tools/synp/${filename}`, + sourcePath: file.relativePath, + sha256: digest(contents), + } + if ( + /^(?:notice|copyright|patents?)/i.test( + path.posix.basename(file.relativePath), + ) + ) { + noticeFiles.push(reference) + } else { + licenseFiles.push(reference) + } + } + inventory.packages.push({ + name: record.metadata.name, + version: record.metadata.version, + license: record.metadata.license ?? record.metadata.licenses ?? null, + licenseFiles, + noticeFiles, + obligations, + }) + for (const obligation of obligations) { + inventory.obligations.push({ package: record.key, ...obligation }) + } + } + this.emitFile({ + type: 'asset', + fileName: 'THIRD-PARTY-NOTICES.json', + source: `${JSON.stringify(inventory, null, 2)}\n`, + }) + }, + } +} + +export default { + input: + process.env.SEA_SYNP_INPUT || + path.join(root, 'src/sea/adapters/synp-worker.mjs'), + output: { + dir: process.env.SEA_SYNP_OUTPUT || path.join(root, 'dist/sea-synp'), + format: 'cjs', + entryFileNames: 'bin/synp.js', + }, + external: id => + id.startsWith('node:') || + [ + 'assert', + 'buffer', + 'crypto', + 'fs', + 'fs/promises', + 'module', + 'os', + 'path', + 'stream', + 'url', + 'util', + 'worker_threads', + ].includes(id), + plugins: [ + nodeResolve({ preferBuiltins: true, exportConditions: ['node'] }), + commonjsPlugin(), + noticesPlugin(), + ], +} diff --git a/.config/taze.config.mts b/.config/taze.config.mts new file mode 100644 index 0000000000..d1de62ff0b --- /dev/null +++ b/.config/taze.config.mts @@ -0,0 +1,23 @@ +import { defineConfig } from 'taze' + +export default defineConfig({ + // Keep prior rejects and the pre-release PTY dependency on reviewed pins. + exclude: [ + '@termless/core', + '@termless/xtermjs', + 'eslint-plugin-unicorn', + 'node-pty', + 'terminal-link', + 'yargs-parser', + ], + // Interactive mode disabled for automation. + interactive: false, + // Use minimal logging similar to ncu loglevel. + loglevel: 'warn', + // Only update packages that have been stable for 7 days. + maturityPeriod: 7, + // Update mode: 'latest' is similar to ncu's default behavior. + mode: 'latest', + // Write to package.json automatically. + write: true, +}) diff --git a/.config/tsconfig.dts.json b/.config/tsconfig.dts.json new file mode 100644 index 0000000000..88e4852cbb --- /dev/null +++ b/.config/tsconfig.dts.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "../dist/types", + "declarationMap": false, + "emitDeclarationOnly": true, + "module": "preserve", + "moduleResolution": "bundler", + "noEmit": false, + "outDir": "../dist/types", + "rootDir": "../src", + "sourceMap": false + }, + "include": ["../src/**/*.mts", "../src/**/*.json"], + "exclude": ["../test", "../**/*.test.mts"] +} diff --git a/tsconfig.scripts.json b/.config/tsconfig.scripts.json similarity index 72% rename from tsconfig.scripts.json rename to .config/tsconfig.scripts.json index e1d3d72839..710aac2aac 100644 --- a/tsconfig.scripts.json +++ b/.config/tsconfig.scripts.json @@ -1,5 +1,5 @@ { - "extends": "./.config/tsconfig.base.json", + "extends": "./tsconfig.base.json", // The top-level entry scripts get their own project so tsconfig.json can // leave them out. eslint.config.js hands `scripts/*.mts` to the project // service's default project via `allowDefaultProject`, and a file that is @@ -8,13 +8,15 @@ // `tsconfig.json`, so a differently-named project like this one type-checks // them without eslint noticing. // - // Like tsconfig.dts.json, this lives in the repository root because - // @typescript/native-preview cannot resolve "include" paths for a config - // outside it. // scripts/lib is listed here as well as in tsconfig.json. A file may belong // to two tsc projects — they are separate invocations — and the entry scripts // import from it, so leaving it out is a TS6307 "not listed within the file // list of project" error. - "include": ["scripts/*.mts", "scripts/lib/**/*.mts"], - "exclude": ["node_modules/**"] + "include": [ + "../scripts/*.mts", + "../scripts/lib/**/*.mts", + "../scripts/test/**/*.mts", + "../src/sea/version.mts" + ], + "exclude": ["../node_modules/**"] } diff --git a/vitest.config.mts b/.config/vitest.config.mts similarity index 50% rename from vitest.config.mts rename to .config/vitest.config.mts index fc26f18cf5..cbb627ec5f 100644 --- a/vitest.config.mts +++ b/.config/vitest.config.mts @@ -1,10 +1,17 @@ +import { fileURLToPath } from 'node:url' + import { defineConfig } from 'vitest/config' export default defineConfig({ + root: fileURLToPath(new URL('../', import.meta.url)), resolve: { preserveSymlinks: false, }, test: { + // Keep the package unit lane scoped to this CLI. The checkout also contains + // ignored upstream repositories and fleet tooling with independent deps and + // Vitest configs; those are exercised by their own workflows. + include: ['src/**/*.test.mts', 'test/**/*.test.mts'], exclude: [ '**/node_modules/**', '**/dist/**', @@ -14,10 +21,24 @@ export default defineConfig({ '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*', // Exclude E2E tests from regular test runs. '**/*.e2e.test.mts', + // Process-level CLI tests run in the serialized integration lane. + '**/*.integration.test.mts', + 'src/commands/cli.test.mts', + 'src/commands/json/cmd-json.test.mts', + 'src/commands/manifest/cmd-manifest-auto.test.mts', + // Native process tests require the explicitly built SEA artifact. + '**/*.native.test.mts', + // Termless requires Node 23.6+; these focused UI captures run in the Node 26 lane. + 'test/unit/lib/terminal/animation.test.mts', + 'test/unit/utils/self-update.terminal.test.mts', + 'test/unit/lib/optimize-svg.test.mts', + 'test/unit/lib/native/cli-harness.test.mts', // Exclude test fixtures — directories like test/fixtures/commands/optimize/pnpm{8,9}/ // contain full repo copies populated by `socket optimize` runs; their .test.mts // files would otherwise be globbed and run as if they were the real suite. 'test/fixtures/**', + 'test/fleet/**', + 'upstream/**', ], coverage: { exclude: [ diff --git a/vitest.e2e.config.mts b/.config/vitest.e2e.config.mts similarity index 89% rename from vitest.e2e.config.mts rename to .config/vitest.e2e.config.mts index 2bd52b8c0a..5c4c891b85 100644 --- a/vitest.e2e.config.mts +++ b/.config/vitest.e2e.config.mts @@ -1,6 +1,9 @@ +import { fileURLToPath } from 'node:url' + import { defineConfig } from 'vitest/config' export default defineConfig({ + root: fileURLToPath(new URL('../', import.meta.url)), resolve: { preserveSymlinks: false, }, diff --git a/.config/vitest.integration.config.mts b/.config/vitest.integration.config.mts new file mode 100644 index 0000000000..3449bdd824 --- /dev/null +++ b/.config/vitest.integration.config.mts @@ -0,0 +1,29 @@ +import { fileURLToPath } from 'node:url' + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + root: fileURLToPath(new URL('../', import.meta.url)), + resolve: { + preserveSymlinks: false, + }, + test: { + // CLI integration tests may exercise npm exec/npx and pnpm dlx. Keep files + // serial so they never mutate the same package-manager cache concurrently. + fileParallelism: false, + include: [ + '**/*.integration.test.mts', + 'src/commands/cli.test.mts', + 'src/commands/json/cmd-json.test.mts', + 'src/commands/manifest/cmd-manifest-auto.test.mts', + ], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '.claude/worktrees/**', + '.pnpm-store/**', + '**/.{idea,git,cache,output,temp}/**', + 'test/fixtures/**', + ], + }, +}) diff --git a/.config/vitest.native.config.mts b/.config/vitest.native.config.mts new file mode 100644 index 0000000000..00fa819786 --- /dev/null +++ b/.config/vitest.native.config.mts @@ -0,0 +1,32 @@ +import { fileURLToPath } from 'node:url' + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + root: fileURLToPath(new URL('../', import.meta.url)), + resolve: { + preserveSymlinks: false, + }, + test: { + // These tests run a native executable and may invoke npm exec/npx or pnpm + // dlx. Serialize files to protect both package managers' shared caches. + fileParallelism: false, + // The integration files already execute complete CLI processes through + // spawnSocketCli, which switches to the SEA binary in this lane. + include: [ + '**/*.native.test.mts', + '**/*.integration.test.mts', + 'src/commands/cli.test.mts', + 'src/commands/json/cmd-json.test.mts', + 'src/commands/manifest/cmd-manifest-auto.test.mts', + ], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '.claude/worktrees/**', + '.pnpm-store/**', + '**/.{idea,git,cache,output,temp}/**', + 'test/fixtures/**', + ], + }, +}) diff --git a/.config/vitest.terminal.config.mts b/.config/vitest.terminal.config.mts new file mode 100644 index 0000000000..71d38c3c29 --- /dev/null +++ b/.config/vitest.terminal.config.mts @@ -0,0 +1,18 @@ +import { fileURLToPath } from 'node:url' + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + root: fileURLToPath(new URL('../', import.meta.url)), + resolve: { preserveSymlinks: false }, + test: { + include: [ + 'test/unit/lib/terminal/animation.test.mts', + 'test/unit/utils/self-update.terminal.test.mts', + 'test/unit/lib/terminal/output.test.mts', + 'test/unit/lib/optimize-svg.test.mts', + 'test/unit/lib/native/cli-harness.test.mts', + ], + exclude: ['**/node_modules/**', '**/dist/**', '.claude/worktrees/**'], + }, +}) diff --git a/.gitattributes b/.gitattributes index af7d217f6e..409688ea32 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,5 @@ * text=auto eol=lfs + +# Generated terminal image baselines are reviewed through the screenshot report. +test/native-snapshots/**/*.svg binary linguist-generated=true +test/native-snapshots/**/*.png binary linguist-generated=true diff --git a/.github/actions/repo/cache-downloads/action.yml b/.github/actions/repo/cache-downloads/action.yml new file mode 100644 index 0000000000..8ed326c132 --- /dev/null +++ b/.github/actions/repo/cache-downloads/action.yml @@ -0,0 +1,22 @@ +name: Cache SEA downloads +description: Restore and save verified content-addressed SEA download blobs +outputs: + cache-hit: + description: Whether the exact SEA download cache key was restored +inputs: + mode: + description: restore verified blobs or save newly downloaded blobs + required: true + key: + description: Exact content-addressed cache key + required: true + path: + description: Content-addressed blob directory + required: true + restore-keys: + description: Optional newline-separated fallback keys + required: false + default: '' +runs: + using: node24 + main: index.mts diff --git a/.github/actions/repo/cache-downloads/index.mts b/.github/actions/repo/cache-downloads/index.mts new file mode 100644 index 0000000000..406f05d307 --- /dev/null +++ b/.github/actions/repo/cache-downloads/index.mts @@ -0,0 +1,131 @@ +import { appendFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { restoreCache, saveCache } from '@actions/cache' + +export type CacheMode = 'restore' | 'save' + +export interface CacheClient { + restoreCache( + paths: string[], + key: string, + restoreKeys?: string[], + ): Promise + saveCache(paths: string[], key: string): Promise +} + +export interface CacheActionOptions { + readonly mode: CacheMode + readonly key: string + readonly path: string + readonly restoreKeys?: string[] +} + +export interface CacheActionReporter { + warning(message: string): void + output(name: string, value: string): Promise +} + +export function seaDownloadBlobPath(home = os.homedir()): string { + return path.join(home, '.cache', 'socket-sea', 'downloads', 'v1', 'blobs') +} + +export function resolveBlobPath(input: string, home = os.homedir()): string { + const expanded = + input === '~' || input.startsWith('~/') + ? path.join(home, input.slice(2)) + : path.resolve(input) + if (path.resolve(expanded) !== seaDownloadBlobPath(home)) { + throw new TypeError('SEA download cache path must target only the blob directory') + } + return expanded +} + +export async function runCacheAction( + options: CacheActionOptions, + cache: CacheClient, + reporter: CacheActionReporter, +): Promise<{ cacheHit: boolean }> { + if (options.mode !== 'restore' && options.mode !== 'save') { + throw new TypeError(`Unknown SEA download cache mode: ${options.mode}`) + } + if (!options.key || !options.path) { + throw new TypeError('SEA download cache key and path are required') + } + const paths = [options.path] + const cacheAccess = process.env['ACTIONS_CACHE_MODE'] + if (options.mode === 'restore') { + if (cacheAccess === 'none' || cacheAccess === 'write-only') { + await reporter.output('cache-hit', 'false') + return { cacheHit: false } + } + try { + const matchedKey = await cache.restoreCache( + paths, + options.key, + options.restoreKeys, + ) + const cacheHit = matchedKey === options.key + await reporter.output('cache-hit', String(cacheHit)) + return { cacheHit } + } catch (error) { + reporter.warning(`SEA download cache restore failed: ${String(error)}`) + await reporter.output('cache-hit', 'false') + return { cacheHit: false } + } + } + if ( + cacheAccess === 'none' || + cacheAccess === 'read' || + cacheAccess === 'read-only' + ) { + reporter.warning('SEA download cache save skipped in read-only mode') + return { cacheHit: false } + } + try { + await cache.saveCache(paths, options.key) + } catch (error) { + reporter.warning(`SEA download cache save failed: ${String(error)}`) + } + return { cacheHit: false } +} + +export function actionInput(name: string): string { + return process.env[`INPUT_${name.toUpperCase()}`] ?? '' +} + +export async function writeOutput(name: string, value: string): Promise { + const outputPath = process.env['GITHUB_OUTPUT'] + if (!outputPath) { + return + } + await appendFile(outputPath, `${name}=${value}\n`) +} + +export async function main(): Promise { + const mode = actionInput('mode') as CacheMode + const key = actionInput('key') + const pathInput = resolveBlobPath(actionInput('path')) + const restoreKeys = actionInput('restore-keys') + .split(/\r?\n/) + .map(value => value.trim()) + .filter(Boolean) + await runCacheAction( + { mode, key, path: pathInput, restoreKeys }, + { restoreCache, saveCache }, + { + warning: message => process.stderr.write(`::warning::${message}\n`), + output: writeOutput, + }, + ) +} + +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) +) { + await main() +} diff --git a/.github/workflows/bazel-windows.yml b/.github/workflows/bazel-windows.yml index 7816a309a3..20ba7ee64c 100644 --- a/.github/workflows/bazel-windows.yml +++ b/.github/workflows/bazel-windows.yml @@ -7,6 +7,10 @@ on: - .github/workflows/bazel-windows.yml - package.json - pnpm-lock.yaml + - .config/rollup.sea.config.mjs + - src/sea/** + - scripts/build-package-matrix.mts + - test/package-matrix.test.mts - src/commands/manifest/bazel/** - test/fixtures/manifest-bazel/** push: @@ -15,6 +19,10 @@ on: - .github/workflows/bazel-windows.yml - package.json - pnpm-lock.yaml + - .config/rollup.sea.config.mjs + - src/sea/** + - scripts/build-package-matrix.mts + - test/package-matrix.test.mts - src/commands/manifest/bazel/** - test/fixtures/manifest-bazel/** @@ -101,7 +109,7 @@ jobs: - name: Test executable handling and prerequisites run: | $PSNativeCommandUseErrorActionPreference = $true - pnpm test:unit src/commands/manifest/bazel/bazel-bin-detect.test.mts src/commands/manifest/bazel/bazel-process.test.mts src/commands/manifest/bazel/bazel-java-shim.test.mts src/commands/manifest/bazel/bazel-python-shim.test.mts src/commands/manifest/bazel/bazel-query-runner.test.mts src/commands/manifest/bazel/bazel-cquery.test.mts + pnpm test:unit src/commands/manifest/bazel/bazel-bin-detect.test.mts src/commands/manifest/bazel/bazel-process.test.mts src/commands/manifest/bazel/bazel-java-shim.test.mts src/commands/manifest/bazel/bazel-python-shim.test.mts src/commands/manifest/bazel/bazel-query-runner.test.mts src/commands/manifest/bazel/bazel-cquery.test.mts test/unit/sea/build-sea.test.mts test/package-matrix.test.mts - name: Install the packaged CLI run: | @@ -118,4 +126,4 @@ jobs: SOCKET_BAZEL_INTEGRATION: '1' run: | $PSNativeCommandUseErrorActionPreference = $true - pnpm exec vitest run --config vitest.e2e.config.mts src/commands/manifest/bazel/bazel-native.e2e.test.mts + pnpm exec vitest run --config .config/vitest.e2e.config.mts src/commands/manifest/bazel/bazel-native.e2e.test.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0752fcfcb8..73cfc03e68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,17 @@ on: branches: [v1.x] pull_request: branches: [v1.x] + workflow_dispatch: + inputs: + refresh-native-cache: + description: Refresh native download cache + type: boolean + required: false + default: false permissions: contents: read +cache-mode: read jobs: lint: @@ -126,7 +134,7 @@ jobs: - name: Install Node.js shell: bash env: - NODE_VERSION: 25.9.0 + NODE_VERSION: 26.13.0 run: node scripts/ci/setup-node.mjs --version "$NODE_VERSION" - name: Download sfw @@ -320,7 +328,7 @@ jobs: - name: Install Node.js shell: bash env: - NODE_VERSION: 25.9.0 + NODE_VERSION: 26.13.0 run: node scripts/ci/setup-node.mjs --version "$NODE_VERSION" - name: Download sfw @@ -456,7 +464,7 @@ jobs: fail-fast: true matrix: # Node 20 reached end-of-life 2026-03-24; test supported majors only. - node-version: [22, 24] + node-version: [22, 24, 26.13.0] os: [ubuntu-latest] steps: - name: Checkout source @@ -645,3 +653,160 @@ jobs: - name: Build and run unit tests run: pnpm test-ci + + native-cli-pr: + name: Native CLI tests • Cache read-only • No Apple signing + if: github.event_name == 'pull_request' && github.base_ref == 'v1.x' + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + concurrency: &native_concurrency + group: native-cli-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}-${{ matrix.target }} + cancel-in-progress: true + strategy: &native_strategy + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + target: linux-x64 + - os: macos-15 + target: darwin-arm64 + - os: windows-2025 + target: win-x64 + steps: &native_steps + - name: Checkout source + shell: bash + env: + CHECKOUT_REF: ${{ github.sha }} + GITHUB_TOKEN: ${{ github.token }} + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + git init -q + git config --local advice.detachedHead false + git remote add origin "${SERVER_URL}/${REPOSITORY}" + AUTH_B64="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')" + export GIT_CONFIG_COUNT=1 + export GIT_CONFIG_KEY_0="http.${SERVER_URL}/.extraheader" + export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${AUTH_B64}" + git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" + git checkout -q --detach FETCH_HEAD + + - name: Install Node.js 26.13.0 (checksum verified) + shell: bash + run: | + set -euo pipefail + version=26.13.0 + base="https://nodejs.org/dist/v${version}" + if [[ "$RUNNER_OS" == Windows ]]; then + archive="node-v${version}-win-x64.zip" + curl -fsSLO "$base/$archive" + curl -fsSLO "$base/SHASUMS256.txt" + grep " $archive$" SHASUMS256.txt | sha256sum -c - + mkdir -p "$RUNNER_TEMP/node" + unzip -q "$archive" -d "$RUNNER_TEMP/node" + cygpath -w "$RUNNER_TEMP/node/node-v${version}-win-x64" >> "$GITHUB_PATH" + else + if [[ "$RUNNER_OS" == macOS ]]; then platform=darwin-arm64; else platform=linux-x64; fi + archive="node-v${version}-${platform}.tar.xz" + curl -fsSLO "$base/$archive" + curl -fsSLO "$base/SHASUMS256.txt" + grep " $archive$" SHASUMS256.txt | shasum -a 256 -c - + mkdir -p "$RUNNER_TEMP/node" + tar -xJf "$archive" -C "$RUNNER_TEMP/node" --strip-components=1 + echo "$RUNNER_TEMP/node/bin" >> "$GITHUB_PATH" + fi + if [[ "$RUNNER_OS" == Windows ]]; then "$RUNNER_TEMP/node/node-v${version}-win-x64/node.exe" -e 'if(process.versions.node!=="26.13.0")process.exit(1)'; else "$RUNNER_TEMP/node/bin/node" -e 'if(process.versions.node!=="26.13.0")process.exit(1)'; fi + + - name: Install pinned pnpm + shell: bash + run: | + set -euo pipefail + version=10.33.0 + case "$RUNNER_OS/$RUNNER_ARCH" in + Linux/X64) asset=pnpm-linux-x64; sha=8d4e8f7d778e8ac482022e2577011706a872542f6f6f233e795a4d9f978ea8b5 ;; + macOS/ARM64) asset=pnpm-macos-arm64; sha=ed8a1f140f4de457b01ebe0be3ae28e9a7e28863315dcd53d22ff1e5a32d63ae ;; + Windows/X64) asset=pnpm-win-x64.exe; sha=afc96009dc39fe23a835d65192049e6a995f342496b175585dc2beda7d42d33f ;; + *) echo "Unsupported runner: $RUNNER_OS/$RUNNER_ARCH" >&2; exit 1 ;; + esac + dir="$RUNNER_TEMP/pnpm-bin" + mkdir -p "$dir" + curl -fsSL "https://github.com/pnpm/pnpm/releases/download/v${version}/${asset}" -o "$dir/$asset" + actual=$( (sha256sum "$dir/$asset" 2>/dev/null || shasum -a 256 "$dir/$asset") | cut -d' ' -f1 | tr -d '\134') + [[ "$actual" == "$sha" ]] || { echo "pnpm checksum mismatch" >&2; exit 1; } + if [[ "$asset" == *.exe ]]; then cp "$dir/$asset" "$dir/pnpm.exe"; else chmod +x "$dir/$asset"; ln -sf "$asset" "$dir/pnpm"; fi + if [[ "$RUNNER_OS" == Windows ]]; then dir=$(cygpath -w "$dir"); fi + echo "$dir" >> "$GITHUB_PATH" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Restore pinned SEA download cache + id: sea-download-cache + uses: ./.github/actions/repo/cache-downloads + with: + mode: restore + path: ~/.cache/socket-sea/downloads/v1/blobs + key: sea-downloads-v1-${{ matrix.target }}-${{ hashFiles('pnpm-lock.yaml', 'src/sea/uv-pins.json', 'src/sea/build-sea.mts') }}${{ inputs.refresh-native-cache && format('-refresh-{0}-{1}', github.run_id, github.run_attempt) || '' }} + restore-keys: | + sea-downloads-v1-${{ matrix.target }}- + + - name: Test screenshot and signing helpers + shell: bash + run: | + pnpm exec vitest run --config .config/vitest.config.mts test/unit/scripts/test/apple-signing.test.mts + pnpm exec vitest run --config .config/vitest.terminal.config.mts + + - name: Verify .NET SDK + shell: bash + run: dotnet --list-sdks | awk -F. '$1 >= 8 { found=1 } END { exit !found }' + + - name: Build prototype native executable + env: + NODE_OPTIONS: ${{ matrix.target == 'darwin-arm64' && '--max-old-space-size=4096' || '' }} + run: pnpm build:sea --prototype + + - name: Save pinned SEA download cache + if: success() && steps.sea-download-cache.outputs.cache-hit != 'true' && github.repository == 'SocketDev/socket-cli' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (github.ref == 'refs/heads/v1.x' || github.ref == 'refs/heads/feat/v1x-node27-sea-vfs-socket' || startsWith(github.ref, 'refs/heads/test/native-terminal-validation-')) + uses: ./.github/actions/repo/cache-downloads + with: + mode: save + path: ~/.cache/socket-sea/downloads/v1/blobs + key: sea-downloads-v1-${{ matrix.target }}-${{ hashFiles('pnpm-lock.yaml', 'src/sea/uv-pins.json', 'src/sea/build-sea.mts') }}${{ inputs.refresh-native-cache && format('-refresh-{0}-{1}', github.run_id, github.run_attempt) || '' }} + + - name: Run native screenshot tests + shell: bash + env: + SOCKET_CLI_NATIVE_TEST_ARTIFACTS: build/native-test-artifacts/${{ matrix.target }} + APPLE_DEVELOPER_ID_IDENTITY: ${{ matrix.target == 'darwin-arm64' && github.event_name != 'pull_request' && github.repository == 'SocketDev/socket-cli' && (github.ref == 'refs/heads/v1.x' || github.ref == 'refs/heads/feat/v1x-node27-sea-vfs-socket' || startsWith(github.ref, 'refs/heads/test/native-terminal-validation-')) && secrets.APPLE_DEVELOPER_ID_IDENTITY || '' }} + APPLE_TEAM_ID: ${{ matrix.target == 'darwin-arm64' && github.event_name != 'pull_request' && github.repository == 'SocketDev/socket-cli' && (github.ref == 'refs/heads/v1.x' || github.ref == 'refs/heads/feat/v1x-node27-sea-vfs-socket' || startsWith(github.ref, 'refs/heads/test/native-terminal-validation-')) && secrets.APPLE_TEAM_ID || '' }} + APPLE_DEVELOPER_ID_APPLICATION_CERTS_P12: ${{ matrix.target == 'darwin-arm64' && github.event_name != 'pull_request' && github.repository == 'SocketDev/socket-cli' && (github.ref == 'refs/heads/v1.x' || github.ref == 'refs/heads/feat/v1x-node27-sea-vfs-socket' || startsWith(github.ref, 'refs/heads/test/native-terminal-validation-')) && secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTS_P12 || '' }} + APPLE_DEVELOPER_ID_APPLICATION_CERTS_P12_PASS: ${{ matrix.target == 'darwin-arm64' && github.event_name != 'pull_request' && github.repository == 'SocketDev/socket-cli' && (github.ref == 'refs/heads/v1.x' || github.ref == 'refs/heads/feat/v1x-node27-sea-vfs-socket' || startsWith(github.ref, 'refs/heads/test/native-terminal-validation-')) && secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTS_P12_PASS || '' }} + SOCKET_CLI_NATIVE_TEST_REQUIRE_APPLE_SIGNATURE: ${{ matrix.target == 'darwin-arm64' && github.event_name != 'pull_request' && github.repository == 'SocketDev/socket-cli' && (github.ref == 'refs/heads/v1.x' || github.ref == 'refs/heads/feat/v1x-node27-sea-vfs-socket' || startsWith(github.ref, 'refs/heads/test/native-terminal-validation-')) && (secrets.APPLE_DEVELOPER_ID_IDENTITY != '' || secrets.APPLE_TEAM_ID != '' || secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTS_P12 != '' || secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTS_P12_PASS != '') && '1' || '' }} + run: | + set -euo pipefail + export SOCKET_CLI_NATIVE_TEST_ARTIFACTS + rm -rf "$SOCKET_CLI_NATIVE_TEST_ARTIFACTS" + mkdir -p "$SOCKET_CLI_NATIVE_TEST_ARTIFACTS" + if pnpm test:native test/native/commands/cli.native.test.mts test/native/commands/cli-behavior.native.test.mts test/native/commands/tui.native.test.mts; then result=0; else result=$?; fi + if [[ $result == 0 ]]; then status=passed; else status=failed; fi + export TEST_STATUS="$status" TEST_EXIT_CODE="$result" TEST_PLATFORM="$RUNNER_OS/$RUNNER_ARCH" + node -e 'const fs=require("node:fs"); let receipt={}; try { receipt=JSON.parse(fs.readFileSync(process.env.SOCKET_CLI_NATIVE_TEST_ARTIFACTS+"/signing-receipt.json","utf8")); } catch {} process.stdout.write(JSON.stringify({status:process.env.TEST_STATUS,exitCode:Number(process.env.TEST_EXIT_CODE),platform:process.env.TEST_PLATFORM,sourceCommit:process.env.GITHUB_SHA||"unknown",signingMode:receipt.mode||"unknown",sourceSha256:receipt.sourceSha256,binarySha256:receipt.stagedSha256})+"\n")' > "$SOCKET_CLI_NATIVE_TEST_ARTIFACTS/test-result.json" + exit "$result" + + - name: Generate self-contained report + if: always() + shell: bash + env: + SOCKET_CLI_NATIVE_TEST_ARTIFACTS: build/native-test-artifacts/${{ matrix.target }} + run: node scripts/test/terminal-report.mts --artifacts "$SOCKET_CLI_NATIVE_TEST_ARTIFACTS" --output "$SOCKET_CLI_NATIVE_TEST_ARTIFACTS/index.html" + + native-cli-writer: + name: Native CLI tests • Cache writes • Apple signing available + cache-mode: write + if: github.repository == 'SocketDev/socket-cli' && ((github.event_name == 'push' && (github.ref == 'refs/heads/v1.x' || github.ref == 'refs/heads/feat/v1x-node27-sea-vfs-socket' || startsWith(github.ref, 'refs/heads/test/native-terminal-validation-'))) || (github.event_name == 'workflow_dispatch' && (github.ref == 'refs/heads/v1.x' || github.ref == 'refs/heads/feat/v1x-node27-sea-vfs-socket' || startsWith(github.ref, 'refs/heads/test/native-terminal-validation-')))) + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + concurrency: *native_concurrency + strategy: *native_strategy + steps: *native_steps diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 401bdd47c4..249e4f9f2f 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -17,7 +17,7 @@ jobs: fail-fast: true matrix: # Node 20 reached end-of-life 2026-03-24; test supported majors only. - node-version: [22, 24] + node-version: [22, 24, 26.13.0] os: [ubuntu-latest] # os: [ubuntu-latest, windows-latest] - Windows tests disbaled (see project https://linear.app/socketdev/project/autofixes-windows-support-fc2f2a45f759) steps: diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 246ab618f3..64f01860c0 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -1,13 +1,13 @@ name: Publish to npm registry -# Release process for the v1.x line — three packages from one source -# (socket, @socketsecurity/cli, @socketsecurity/cli-with-sentry): +# Release process for v1.x and the configured Socket prerelease. Stable v1.x +# publishes all three packages; the 2.1.0-rc track publishes only `socket`. # # 1. Between releases nobody touches the version. package.json keeps the # last released number and user-facing notes accrue under the # CHANGELOG's `## [Unreleased]` section as they land. -# 2. Dispatch with mode=release-pr. The `derive` job runs -# scripts/release/bump.mts to pick the next version and rewrite +# 2. Dispatch with mode=release-pr. The `prepare` job runs +# scripts/release/prepare.mts to pick the next version and rewrite # package.json + CHANGELOG.md. With dry-run=true (the default) it only # prints the version. With dry-run=false the `release-pr` job commits the # pair via the release App onto `npm-publish-v` and opens a @@ -17,22 +17,23 @@ name: Publish to npm registry # 4. Dispatch with mode=publish. A dry run builds, packs, and smoke-tests # HEAD and creates nothing. With dry-run=false the `verify` job checks out # the newest commit that changed package.json's version (the merged -# release PR) and builds that. This branch is the line customers consume, -# so it owns `latest`. The guard below refuses that tag from anywhere else. -# 5. `publish` cuts the v tag + the immutable GitHub release at that -# commit, then STAGES the exact tarballs `verify` packed. -# 6. A human promotes each staged upload (`pnpm stage approve` with web -# 2FA, or the npm web UI). Nothing is public until then. +# release PR) and builds that. Stable v1.x owns `latest`; the prerelease +# uses the tag declared by `.config/release.json`. +# 5. `publish` reserves the v tag, then STAGES the exact tarballs +# `verify` packed. It never creates a GitHub release. +# 6. A human approves each stage by ID with `pnpm run approve:npm `. +# That command checks the full package set is public, then dispatches +# `release-github.yml` to finalize the matching GitHub release. # -# Stable tags from this major version reserve their versions, including tags -# on unlanded bump commits. Reachable tags anchor the changelog history. -# The next release must exceed every reserved version on this major line. +# Tags reserve versions, including burned attempts. The changelog starts at the +# last publicly released version; a first prerelease starts at the latest stable +# release. The next release must exceed every reserved version on its track. # # THE VERSION DECISION IS THE COMMITS'. Patch by default; a `feat:` in range # makes it minor. A MAJOR is never derived — a breaking commit stops the bump # and asks a human to pass release-as. # -# CREDENTIAL BOUNDARIES. `derive` and `verify` install and build, and hold no +# CREDENTIAL BOUNDARIES. `prepare` and `verify` install and build, and hold no # write credential of any kind. `release-pr` holds the release App token and # runs no installed code. `publish` holds the registry credential and does # almost nothing: no checkout, no install, no build. It publishes the exact @@ -41,13 +42,8 @@ name: Publish to npm registry on: workflow_dispatch: inputs: - dist-tag: - description: 'npm dist-tag (latest, next, beta, canary, backport, etc.)' - required: false - default: 'latest' - type: string mode: - description: 'release-pr opens the version bump PR. publish releases the newest merged release PR.' + description: 'release-pr opens the version bump PR. publish stages the newest merged release preparation.' required: false default: 'publish' type: choice @@ -63,12 +59,15 @@ on: description: 'release-pr only. Force the bump level instead of deriving it from the commits. MAJOR is never derived — a breaking change stops the bump until a human picks major here.' required: false default: '' + type: string + release-major: + description: 'release-pr only. Select the release line (2 selects the configured Socket prerelease track).' + required: false + default: '1' type: choice options: - - '' - - major - - minor - - patch + - '1' + - '2' debug: description: 'Enable debug output' required: false @@ -102,36 +101,16 @@ jobs: artifact-digest: ${{ steps.upload.outputs.artifact-digest }} sha: ${{ steps.release-meta.outputs.sha }} version: ${{ steps.release-meta.outputs.version }} + dist-tag: ${{ steps.release-policy.outputs.dist-tag }} steps: # npm trusted publishing authorizes on repository + workflow filename + # GitHub environment. It does NOT pin a branch. The `publish-npm` - # environment's deployment-branch policy (main + v1.x) is the outer - # gate; this guard is the in-repo half. - # - # `latest` is what an untagged install of the package resolves to, so it - # belongs to the line customers consume — and that is THIS branch. The - # default branch carries the 2.x PRERELEASE line and is refused `latest` - # by its own copy of this workflow, which reads the owning branch from - # `release.latestDistTagBranch` in .config/repo/socket-wheelhouse.json. + # environment's deployment-branch policy is the outer gate; this guard + # is the in-repo half. # - # Dry runs pass regardless of dist-tag: they upload nothing. - - name: Guard the latest dist-tag to the consumable release line - if: ${{ inputs.dry-run == false && inputs.dist-tag == 'latest' }} - env: - LATEST_BRANCH: v1.x - REF: ${{ github.ref }} - run: | - if [ "$REF" != "refs/heads/$LATEST_BRANCH" ]; then - echo "Refusing to publish dist-tag 'latest' from $REF." >&2 - echo "Only refs/heads/$LATEST_BRANCH may publish 'latest' — it is the line customers consume." >&2 - echo "Re-dispatch from $LATEST_BRANCH, or pick a prerelease dist-tag (next, beta, canary, rc)." >&2 - exit 1 - fi - echo "dist-tag 'latest' is allowed from $REF (consumable line: $LATEST_BRANCH)." - # Full history locates the release commit here and anchors the changelog - # in `derive`. All tags expose reserved versions, including release tags + # in `prepare`. All tags expose reserved versions, including release tags # on bump commits that did not land. - &checkout-full-history name: Checkout source @@ -174,6 +153,20 @@ jobs: echo "::error::Fix: dispatch with mode=release-pr, merge that PR, then dispatch mode=publish." >&2 exit 1 + - name: Verify the release commit before staging + if: ${{ inputs.dry-run == false }} + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + SHA="$(git rev-parse --verify 'HEAD^{commit}')" + VERIFIED="$(gh api "repos/${REPOSITORY}/commits/${SHA}" --jq '.commit.verification.verified')" + if [ "$VERIFIED" != true ]; then + echo "::error::Release commit ${SHA} is not GitHub-verified; npm staging stopped." >&2 + exit 1 + fi + - &install-pnpm name: Install pnpm shell: bash @@ -352,23 +345,26 @@ jobs: name: Install dependencies run: pnpm install --loglevel error - # The version must be a bare X.Y.Z before anything reaches the registry. A - # prerelease-suffixed version is a work-in-progress marker, not a - # releasable one, so fail closed rather than publish it. - - name: Refuse a publish on a non-release version + # Validate the version and dist-tag against the checked-in release policy. + - name: Validate release policy and choose npm dist-tag + id: release-policy if: ${{ inputs.dry-run == false }} run: | VERSION=$(node -p "require('./package.json').version") - case "$VERSION" in - *-*) - echo "::error::package.json version is '$VERSION' — a prerelease version, not a releasable one." >&2 - echo "::error::Where: the tree this run will pack." >&2 - echo "::error::Saw: a prerelease suffix; wanted a bare X.Y.Z." >&2 - echo "::error::Fix: land a release-shaped version on v1.x, then re-dispatch." >&2 - exit 1 - ;; - esac - echo "Version $VERSION is release-shaped." + node scripts/release/validate-npm-policy.mts "$VERSION" + if [[ "$VERSION" == *-* ]]; then + echo "NPM_PRERELEASE_RELEASE=true" >> "$GITHUB_ENV" + fi + + - name: Restrict latest releases to v1.x + if: ${{ inputs.dry-run == false && steps.release-policy.outputs.dist-tag == 'latest' }} + env: + REF: ${{ github.ref }} + run: | + if [ "$REF" != "refs/heads/v1.x" ]; then + echo "Refusing to publish the latest dist-tag outside refs/heads/v1.x." >&2 + exit 1 + fi # Compile the Maven manifest extension jar so the dist build bundles it # into dist/manifest-scripts (the jar is never committed; it ships only in @@ -421,6 +417,7 @@ jobs: run: bash .github/scripts/smoke-test-tarball.sh - name: Build and pack @socketsecurity/cli (legacy) + if: ${{ env.NPM_PRERELEASE_RELEASE != 'true' }} env: SOCKET_CLI_DEBUG: ${{ inputs.debug }} run: | @@ -428,11 +425,13 @@ jobs: pnpm pack --pack-destination "$RUNNER_TEMP/dist" - name: Smoke test @socketsecurity/cli + if: ${{ env.NPM_PRERELEASE_RELEASE != 'true' }} env: PKG: '@socketsecurity/cli' run: bash .github/scripts/smoke-test-tarball.sh - name: Build and pack @socketsecurity/cli-with-sentry + if: ${{ env.NPM_PRERELEASE_RELEASE != 'true' }} env: SOCKET_CLI_DEBUG: ${{ inputs.debug }} run: | @@ -440,6 +439,7 @@ jobs: pnpm pack --pack-destination "$RUNNER_TEMP/dist" - name: Smoke test @socketsecurity/cli-with-sentry + if: ${{ env.NPM_PRERELEASE_RELEASE != 'true' }} env: PKG: '@socketsecurity/cli-with-sentry' run: bash .github/scripts/smoke-test-tarball.sh @@ -517,12 +517,13 @@ jobs: version = os.environ["VERSION"] if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", version): raise SystemExit("Invalid release version.") - expected = {f"{name}-{version}.tgz" for name in - ("socket", "socketsecurity-cli", "socketsecurity-cli-with-sentry")} + packages = ("socket",) if "-" in version else ( + "socket", "socketsecurity-cli", "socketsecurity-cli-with-sentry") + expected = {f"{name}-{version}.tgz" for name in packages} with zipfile.ZipFile(archive) as bundle: entries = bundle.infolist() - if len(entries) != 3 or {entry.filename for entry in entries} != expected: - raise SystemExit("Artifact must contain exactly the three release tarballs.") + if len(entries) != len(expected) or {entry.filename for entry in entries} != expected: + raise SystemExit("Artifact package set does not match the selected release track.") if any(entry.file_size > 512 * 1024 * 1024 or stat.S_ISLNK(entry.external_attr >> 16) for entry in entries): raise SystemExit("Artifact contains an invalid tarball entry.") @@ -597,20 +598,15 @@ jobs: DIST_DIRECTORY: ${{ runner.temp }}/dist run: *download-verified-tarballs - # ORDER RULE (markers first): the v tag and the immutable - # GitHub release are cut BEFORE the staged uploads, so the uploads' - # provenance binds markers that exist. Exactly ONE tag and ONE release - # per run, and they belong to the `socket` package — the two variants - # ride the same version without markers of their own. - # - # The trade is the BURN RULE: a stage rejected after the markers burns - # the version. The next release is a patch bump, never a re-publish of - # the burned number — the different-SHA hard-fail below enforces it, - # while a same-SHA re-run is a no-op tag plus a fresh stage attempt. + # Reserve one immutable tag before npm staging so provenance can refer + # to it. An existing tag always stops this run, even at the same SHA: + # recover its stage ID from workflow logs/npm, or keep the burned version + # and prepare the next one. Never stage the same version twice. GitHub + # release creation happens only after local npm approval. # # Uses gh api rather than git so the token lives only in this step's # env; the job has no checkout at all. - - name: Tag release (idempotent) + - name: Reserve release tag once env: GH_TOKEN: ${{ github.token }} PUBLISHED_SHA: ${{ needs.verify.outputs.sha }} @@ -630,47 +626,16 @@ jobs: else EXISTING_SHA="$REF_OBJECT_SHA" fi - if [ "$EXISTING_SHA" = "$PUBLISHED_SHA" ]; then - echo "Tag $TAG already exists at $PUBLISHED_SHA — no-op." - exit 0 - fi - echo "::error::Tag $TAG exists at $EXISTING_SHA but this run's SHA is $PUBLISHED_SHA." >&2 - echo "::error::That version is spent. Bump to the next patch instead of re-releasing it." >&2 + echo "::error::Tag $TAG is already reserved at $EXISTING_SHA (requested SHA: $PUBLISHED_SHA)." >&2 + echo "::error::Do not stage this version again. Recover its stage ID from workflow logs/npm, or keep the tag and prepare the next version." >&2 exit 1 fi gh api "repos/$REPO/git/refs" -X POST -f "ref=refs/tags/$TAG" -f "sha=$PUBLISHED_SHA" echo "Created tag $TAG at $PUBLISHED_SHA" - # Create-as-draft then publish: immutable releases attest the locked - # asset set at publish time, so the release goes live in a separate - # `--draft=false` flip. That flip carries `--latest=false` — v1.x is the - # maintenance line and its releases never take the repository's Latest - # badge from the newer line. Re-runs skip a published release and flip a - # stranded draft live. - - name: Cut GitHub release (idempotent) - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - run: | - IS_DRAFT=$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq '.isDraft' 2>/dev/null || echo "absent") - if [ "$IS_DRAFT" = "false" ]; then - echo "Release $TAG already exists — no-op." - exit 0 - fi - if [ "$IS_DRAFT" = "absent" ]; then - gh release create "$TAG" \ - --repo "$REPO" \ - --title "$TAG" \ - --verify-tag \ - --generate-notes \ - --draft - fi - gh release edit "$TAG" --repo "$REPO" --draft=false --latest=false - echo "Published GitHub release $TAG" - # STAGED uploads: the tarball and its provenance land in npm's staging - # area and NOTHING is public until a human promotes each stage - # (`pnpm stage approve` with web 2FA, or the npm web UI). The + # area and NOTHING is public until a human approves each stage by ID + # with `pnpm run approve:npm `. The # per-package trusted publishers allow "stage publish" ONLY — a direct # `npm publish` dies at the OIDC token exchange. # @@ -679,31 +644,56 @@ jobs: # manifest, and the firewall shim guards inbound package fetches while # a stage upload is outbound. This job has neither a checkout nor a # shim, so both hazards are structurally absent. - - name: Stage all three packages + - name: Stage release packages working-directory: ${{ runner.temp }} env: - NPM_DIST_TAG: ${{ inputs.dist-tag }} + NPM_DIST_TAG: ${{ needs.verify.outputs.dist-tag }} + VERSION: ${{ needs.verify.outputs.version }} run: | shopt -s nullglob TARBALLS=("$RUNNER_TEMP"/dist/*.tgz) - if [ "${#TARBALLS[@]}" -ne 3 ]; then - echo "::error::Expected 3 verified tarballs, found ${#TARBALLS[@]}." >&2 + EXPECTED=3 + if [[ "$VERSION" == *-* ]]; then EXPECTED=1; fi + if [ "${#TARBALLS[@]}" -ne "$EXPECTED" ]; then + echo "::error::Expected $EXPECTED verified tarballs, found ${#TARBALLS[@]}." >&2 exit 1 fi + SUMMARY="$RUNNER_TEMP/npm-stage-summary.md" + : > "$SUMMARY" for tarball in "${TARBALLS[@]}"; do echo "Staging $(basename "$tarball")" "${RUNNER_TEMP}/pnpm-bin/pnpm" stage publish "$tarball" \ --provenance \ --access public \ - --tag "$NPM_DIST_TAG" + --tag "$NPM_DIST_TAG" \ + --json > "$RUNNER_TEMP/npm-stage.json" + node --input-type=module - "$RUNNER_TEMP/npm-stage.json" "$SUMMARY" "$tarball" <<'NODE' + import { readFileSync, appendFileSync } from 'node:fs' + import { basename } from 'node:path' + const output = readFileSync(process.argv[2], 'utf8') + const start = output.search(/^(?:\{|\[)/mu) + const value = JSON.parse(start < 0 ? output : output.slice(start)) + const rows = Array.isArray(value) ? value : Object.values(value ?? {}) + const matches = rows.filter(row => row && typeof row === 'object' && + typeof row.name === 'string' && typeof row.version === 'string' && + typeof row.stageId === 'string' && + /^[a-f\d]{8}-[a-f\d]{4}-[1-8][a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/iu.test(row.stageId)) + if (matches.length !== 1) throw new Error('Expected one npm stage ID in pnpm JSON output.') + const stage = matches[0] + const artifactName = `${stage.name.replace(/^@/u, '').replace('/', '-')}-${stage.version}.tgz` + if (basename(process.argv[4]) !== artifactName) throw new Error('Stage identity does not match the verified tarball.') + appendFileSync(process.argv[3], `- ${stage.name}@${stage.version} — stage ID: \`${stage.stageId}\`\n`) + process.stdout.write(`Stage ID: ${stage.stageId}\n`) + NODE done - echo "All three packages staged. Promote each with: pnpm stage approve " - - # Derive the next version and write package.json + CHANGELOG.md. Runs the - # installed toolchain, so it holds no write credential. The bump leaves this - # job as a patch limited to those two files. - derive: - name: Derive the release bump + cat "$SUMMARY" >> "$GITHUB_STEP_SUMMARY" + echo "Staged $EXPECTED package(s). Approve each ID locally with: pnpm run approve:npm " + + # Prepare the next version and changelog. This job runs the installed + # toolchain without write credentials and changes only package.json and + # CHANGELOG.md. + prepare: + name: Prepare the release version if: ${{ inputs.mode == 'release-pr' }} runs-on: ubuntu-latest @@ -722,12 +712,13 @@ jobs: - *create-sfw-shims - *install-dependencies - - name: Bump version and changelog + - name: Prepare version and changelog id: bump env: RELEASE_AS: ${{ inputs.release-as }} + SOCKET_CLI_RELEASE_MAJOR: ${{ inputs.release-major }} run: | - node scripts/release/bump.mts \ + node scripts/release/prepare.mts \ ${RELEASE_AS:+--release-as "$RELEASE_AS"} \ ${{ inputs.dry-run && '--dry-run' || '' }} @@ -742,7 +733,7 @@ jobs: # installs nothing, so no third-party code shares a job with the App token. release-pr: name: Open the release PR - needs: derive + needs: prepare if: ${{ inputs.mode == 'release-pr' && inputs.dry-run == false }} runs-on: ubuntu-latest @@ -775,7 +766,7 @@ jobs: - name: Apply the bump env: - PATCH_B64: ${{ needs.derive.outputs.patch }} + PATCH_B64: ${{ needs.prepare.outputs.patch }} run: printf '%s' "$PATCH_B64" | base64 -d | git apply - name: Mint release App token @@ -791,5 +782,5 @@ jobs: - name: Commit the bump and open the release PR env: RELEASE_APP_TOKEN: ${{ steps.release-app.outputs.token }} - VERSION: ${{ needs.derive.outputs.version }} + VERSION: ${{ needs.prepare.outputs.version }} run: node scripts/release/open-release-pr.mts --version "$VERSION" diff --git a/.github/workflows/release-github.yml b/.github/workflows/release-github.yml new file mode 100644 index 0000000000..95cc3d960a --- /dev/null +++ b/.github/workflows/release-github.yml @@ -0,0 +1,119 @@ +name: Finalize GitHub release + +on: + workflow_dispatch: + inputs: + tag: + description: Reserved release tag whose npm version is approved + required: true + type: string + +permissions: + contents: read + +concurrency: + group: github-release-${{ github.repository }}-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + release: + name: Verify npm publication and finalize release + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: write + steps: + - name: Check out default branch and reserved tag + shell: bash + env: + GH_TOKEN: ${{ github.token }} + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ ! "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release tag: $TAG" >&2 + exit 1 + fi + if [[ ! "$DEFAULT_BRANCH" =~ ^[A-Za-z0-9._/-]+$ || "$DEFAULT_BRANCH" == -* ]]; then + echo "Invalid default branch name." >&2 + exit 1 + fi + git init -q + git config --local advice.detachedHead false + git remote add origin "${SERVER_URL}/${REPOSITORY}" + AUTH_B64="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')" + export GIT_CONFIG_COUNT=1 + export GIT_CONFIG_KEY_0="http.${SERVER_URL}/.extraheader" + export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${AUTH_B64}" + git fetch --no-tags origin \ + "refs/tags/${TAG}:refs/tags/${TAG}" \ + "refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" + git checkout -q --detach "refs/tags/${TAG}" + TAG_SHA="$(git rev-parse --verify "${TAG}^{commit}")" + git merge-base --is-ancestor "$TAG_SHA" "refs/remotes/origin/${DEFAULT_BRANCH}" + VERIFIED="$(gh api "repos/${REPOSITORY}/commits/${TAG_SHA}" --jq '.commit.verification.verified')" + if [ "$VERIFIED" != true ]; then + echo "Tag $TAG does not point to a GitHub-verified commit." >&2 + exit 1 + fi + + - name: Validate release policy and wait for npm publication + id: metadata + working-directory: ${{ runner.temp }} + env: + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + VERSION="${TAG#v}" + cd "$GITHUB_WORKSPACE" + MANIFEST_NAME="$(node -p "require('./package.json').name")" + MANIFEST_VERSION="$(node -p "require('./package.json').version")" + if [ "$MANIFEST_NAME" != socket ] || [ "$VERSION" != "$MANIFEST_VERSION" ]; then + echo "Tag $TAG does not contain socket@$VERSION." >&2 + exit 1 + fi + node scripts/release/validate-npm-policy.mts "$VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # Anonymous HEAD only shows whether npm serves this version. It cannot + # distinguish validating, blocked, deleted, or missing versions; a + # timeout preserves the reserved version for retry and creates no release. + node scripts/release/availability.mts "$VERSION" + + - name: Create or retry GitHub release finalization + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + TAG: ${{ inputs.tag }} + VERSION: ${{ steps.metadata.outputs.version }} + run: | + set -euo pipefail + IS_PRERELEASE=false + if [[ "$VERSION" == *-* ]]; then IS_PRERELEASE=true; fi + gh release view "${TAG}" --json databaseId --jq '.databaseId' > "$RUNNER_TEMP/release-id" 2>/dev/null || true + RELEASE_ID="$(cat "$RUNNER_TEMP/release-id" 2>/dev/null || true)" + if [[ ! "$RELEASE_ID" =~ ^[0-9]+$ ]]; then + awk -v version="$VERSION" 'index($0, "## [" version "]") == 1 { found=1; print; next } found && /^## / { exit } found { print }' \ + "$GITHUB_WORKSPACE/CHANGELOG.md" > "$RUNNER_TEMP/release-notes.md" + if [ ! -s "$RUNNER_TEMP/release-notes.md" ]; then + echo "CHANGELOG.md has no section for $VERSION." >&2 + exit 1 + fi + RELEASE_FLAGS=() + if [ "$IS_PRERELEASE" = true ]; then RELEASE_FLAGS+=(--prerelease); fi + gh release create "$TAG" \ + --title "$TAG" \ + --verify-tag \ + --notes-file "$RUNNER_TEMP/release-notes.md" \ + --draft \ + "${RELEASE_FLAGS[@]}" + RELEASE_ID="$(gh release view "${TAG}" --json databaseId --jq '.databaseId')" + fi + gh api "repos/${REPOSITORY}/releases/${RELEASE_ID}" -X PATCH \ + -F draft=false \ + -F "prerelease=${IS_PRERELEASE}" \ + -F make_latest=false >/dev/null + echo "GitHub release $TAG is public." diff --git a/.gitignore b/.gitignore index b75a474eeb..24b7ae1f10 100644 --- a/.gitignore +++ b/.gitignore @@ -1,39 +1,1389 @@ -.DS_Store -._.DS_Store -.Trash/ -Thumbs.db +# Ignore everything by default; explicitly allow tracked repository files below. +* +!.gitignore +!/.claude/ +!/.claude/skills/ +!/.claude/skills/bump-coana/ +!/.config/ +!/.config/** +!/.husky/ +!/.vscode/ +!/.github/ +!/.github/actions/ +!/.github/actions/repo/ +!/.github/actions/repo/cache-downloads/ +!/.github/actions/repo/upload-artifact/ +!/.github/scripts/ +!/.github/workflows/ +!/.github/actions/repo/upload-artifact/action.yml +!/.github/actions/repo/upload-artifact/index.mjs +!/.github/actions/repo/cache-downloads/action.yml +!/.github/actions/repo/cache-downloads/index.mts +!/.github/scripts/pin-readme-assets.mjs +!/.github/scripts/smoke-test-tarball.sh +!/.github/workflows/bazel-windows.yml +!/.github/workflows/ci.yml +!/.github/workflows/e2e-tests.yml +!/.github/workflows/publish-npm.yml +!/.github/workflows/release-github.yml +!/assets/ +!/assets/fleet/ +!/bin/ +!/docs/ +!/patches/ +!/scripts/ +!/scripts/build-package-matrix.mts +!/scripts/babel/ +!/scripts/ci/ +!/scripts/lib/ +!/scripts/release/ +!/scripts/rollup/ +!/scripts/test/ +!/scripts/test/terminal-report.mts +!/scripts/test/snapshots.mts +!/scripts/test/snapshot-dependencies.mts +!/scripts/utils/ +!/shadow-bin/ +!/src/ +!/src/commands/ +!/src/commands/analytics/ +!/src/commands/audit-log/ +!/src/commands/cdxgen/ +!/src/commands/ci/ +!/src/commands/config/ +!/src/commands/fix/ +!/src/commands/install/ +!/src/commands/json/ +!/src/commands/login/ +!/src/commands/logout/ +!/src/commands/manifest/ +!/src/commands/manifest/bazel/ +!/src/commands/manifest/scripts/ +!/src/commands/manifest/scripts/dotnet-tool/ +!/src/commands/manifest/scripts/maven-extension/ +!/src/commands/manifest/scripts/maven-extension/.mvn/ +!/src/commands/manifest/scripts/maven-extension/.mvn/wrapper/ +!/src/commands/manifest/scripts/maven-extension/src/ +!/src/commands/manifest/scripts/maven-extension/src/main/ +!/src/commands/manifest/scripts/maven-extension/src/main/java/ +!/src/commands/manifest/scripts/maven-extension/src/main/java/tech/ +!/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ +!/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/ +!/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/ +!/src/commands/manifest/scripts/test/ +!/src/commands/manifest/scripts/test/dotnet-compat/ +!/src/commands/manifest/scripts/test/dotnet-compat/basic/ +!/src/commands/manifest/scripts/test/dotnet-compat/basic/shared/ +!/src/commands/manifest/scripts/test/dotnet-compat/basic/src/ +!/src/commands/manifest/scripts/test/dotnet-compat/basic/src/App/ +!/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Extra/ +!/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Lib/ +!/src/commands/manifest/scripts/test/dotnet-compat/basic/test/ +!/src/commands/manifest/scripts/test/dotnet-compat/basic/test/App.Tests/ +!/src/commands/manifest/scripts/test/dotnet-compat/basic/tools/ +!/src/commands/manifest/scripts/test/dotnet-compat/basic/tools/Nuke/ +!/src/commands/manifest/scripts/test/dotnet-compat/cpm/ +!/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/ +!/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Api/ +!/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Core/ +!/src/commands/manifest/scripts/test/dotnet-compat/dev/ +!/src/commands/manifest/scripts/test/dotnet-compat/evaluation/ +!/src/commands/manifest/scripts/test/dotnet-compat/evaluation/Lib/ +!/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/ +!/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/ +!/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/core/ +!/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/shared/ +!/src/commands/manifest/scripts/test/dotnet-compat/failures/ +!/src/commands/manifest/scripts/test/dotnet-compat/failures/broken/ +!/src/commands/manifest/scripts/test/dotnet-compat/failures/missing/ +!/src/commands/manifest/scripts/test/dotnet-compat/langs/ +!/src/commands/manifest/scripts/test/dotnet-compat/langs/Fs/ +!/src/commands/manifest/scripts/test/dotnet-compat/langs/Vb/ +!/src/commands/manifest/scripts/test/dotnet-compat/multi/ +!/src/commands/manifest/scripts/test/dotnet-compat/multi/One/ +!/src/commands/manifest/scripts/test/dotnet-compat/multi/Two/ +!/src/commands/manifest/scripts/test/dotnet-compat/packages-config/ +!/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/ +!/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/Properties/ +!/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Modern/ +!/src/commands/manifest/scripts/test/dotnet-compat/pair/ +!/src/commands/manifest/scripts/test/dotnet-compat/rid/ +!/src/commands/manifest/scripts/test/dotnet-compat/sibling/ +!/src/commands/manifest/scripts/test/dotnet-compat/sibling/sln/ +!/src/commands/manifest/scripts/test/dotnet-compat/sibling/src/ +!/src/commands/manifest/scripts/test/dotnet-compat/sibling/src/A/ +!/src/commands/manifest/scripts/test/gradle-compat/ +!/src/commands/manifest/scripts/test/gradle-compat/exclude-base/ +!/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/ +!/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/core/ +!/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/ +!/src/commands/manifest/scripts/test/gradle-compat/failures/ +!/src/commands/manifest/scripts/test/gradle-compat/failures/app/ +!/src/commands/manifest/scripts/test/gradle-compat/failures/gradle/ +!/src/commands/manifest/scripts/test/gradle-compat/failures/lib/ +!/src/commands/manifest/scripts/test/gradle-compat/project/ +!/src/commands/manifest/scripts/test/maven-compat/ +!/src/commands/manifest/scripts/test/maven-compat/descriptor-failure/ +!/src/commands/manifest/scripts/test/maven-compat/descriptor-failure/a/ +!/src/commands/manifest/scripts/test/maven-compat/descriptor-failure/b/ +!/src/commands/manifest/scripts/test/maven-compat/duplicate-failure/ +!/src/commands/manifest/scripts/test/maven-compat/duplicate-failure/m1/ +!/src/commands/manifest/scripts/test/maven-compat/duplicate-failure/m2/ +!/src/commands/manifest/scripts/test/maven-compat/exclude-base/ +!/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/ +!/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/core/ +!/src/commands/manifest/scripts/test/maven-compat/exclude-base/shared/ +!/src/commands/manifest/scripts/test/maven-compat/graph-failure/ +!/src/commands/manifest/scripts/test/maven-compat/project/ +!/src/commands/manifest/scripts/test/maven-compat/project/app/ +!/src/commands/manifest/scripts/test/maven-compat/project/app/src/ +!/src/commands/manifest/scripts/test/maven-compat/project/app/src/main/ +!/src/commands/manifest/scripts/test/maven-compat/project/app/src/main/java/ +!/src/commands/manifest/scripts/test/maven-compat/project/app/src/main/java/demo/ +!/src/commands/manifest/scripts/test/maven-compat/project/lib/ +!/src/commands/manifest/scripts/test/maven-compat/project/lib/src/ +!/src/commands/manifest/scripts/test/maven-compat/project/lib/src/main/ +!/src/commands/manifest/scripts/test/maven-compat/project/lib/src/main/java/ +!/src/commands/manifest/scripts/test/maven-compat/project/lib/src/main/java/demo/ +!/src/commands/manifest/scripts/test/maven-compat/project/mid/ +!/src/commands/manifest/scripts/test/maven-compat/project/mid/src/ +!/src/commands/manifest/scripts/test/maven-compat/project/mid/src/main/ +!/src/commands/manifest/scripts/test/maven-compat/project/mid/src/main/java/ +!/src/commands/manifest/scripts/test/maven-compat/project/mid/src/main/java/demo/ +!/src/commands/manifest/scripts/test/maven-compat/repo-inheritance/ +!/src/commands/manifest/scripts/test/maven-compat/repo-inheritance/liba/ +!/src/commands/manifest/scripts/test/maven-compat/repo-inheritance/libb/ +!/src/commands/manifest/scripts/test/sbt-compat/ +!/src/commands/manifest/scripts/test/sbt-compat/artifact-failure/ +!/src/commands/manifest/scripts/test/sbt-compat/exclude-base/ +!/src/commands/manifest/scripts/test/sbt-compat/exclude-base/app/ +!/src/commands/manifest/scripts/test/sbt-compat/failure/ +!/src/commands/manifest/scripts/test/sbt-compat/project/ +!/src/commands/manifest/scripts/test/sbt-compat/project/src/ +!/src/commands/manifest/scripts/test/sbt-compat/project/src/main/ +!/src/commands/manifest/scripts/test/sbt-compat/project/src/main/scala/ +!/src/commands/manifest/scripts/test/sbt-compat/relocation/ +!/src/commands/npm/ +!/src/commands/npx/ +!/src/commands/oops/ +!/src/commands/optimize/ +!/src/commands/organization/ +!/src/commands/package/ +!/src/commands/package/fixtures/ +!/src/commands/patch/ +!/src/commands/pnpm/ +!/src/commands/raw-npm/ +!/src/commands/raw-npx/ +!/src/commands/repository/ +!/src/commands/scan/ +!/src/commands/threat-feed/ +!/src/commands/uninstall/ +!/src/commands/wrapper/ +!/src/commands/yarn/ +!/src/external/ +!/src/external/blessed-contrib/ +!/src/external/blessed-contrib/lib/ +!/src/external/blessed-contrib/lib/layout/ +!/src/external/blessed-contrib/lib/widget/ +!/src/external/blessed-contrib/lib/widget/charts/ +!/src/sea/ +!/src/sea/archive-paths.mts +!/src/sea/adapters/ +!/src/sea/build/ +!/src/sea/download-cache.mts +!/src/sea/capabilities.mts +!/src/sea/licenses/ +!/src/sea/licenses/cdxgen-third-party/ +!/src/sea/licenses/core-third-party/ +!/src/shadow/ +!/src/shadow/npm/ +!/src/shadow/npm/arborist/ +!/src/shadow/npm/arborist/lib/ +!/src/shadow/npm/arborist/lib/arborist/ +!/src/shadow/npx/ +!/src/shadow/pnpm/ +!/src/shadow/yarn/ +!/src/test/ +!/src/test/mocks/ +!/src/utils/ +!/src/utils/alert/ +!/src/utils/telemetry/ +!/src/vendor/ +!/test/ +!/test/native/ +!/test/native/commands/ +!/test/native/commands/cli.native.test.mts +!/test/native/commands/cli-behavior.native.test.mts +!/test/native/commands/tui.native.test.mts +!/test/lib/ +!/test/lib/native/ +!/test/lib/native/tui-api.mts +!/test/unit/utils/packages.test.mts +!/test/unit/ +!/test/unit/github/ +!/test/unit/github/actions/ +!/test/unit/github/actions/repo/ +!/test/unit/github/actions/repo/cache-downloads/ +!/test/unit/github/actions/repo/cache-downloads/index.test.mts +!/test/unit/constants.test.mts +!/test/unit/sea/ +!/test/unit/sea/build/ +!/test/unit/sea/build/lock.test.mts +!/test/unit/sea/build/publish.test.mts +!/test/unit/sea/build-sea.test.mts +!/test/unit/sea/download-bytes.test.mts +!/test/unit/sea/download-cache.test.mts +!/test/unit/sea/runtime.test.mts +!/test/unit/sea/version.test.mts +!/test/unit/utils/ +!/test/unit/utils/agent.test.mts +!/test/unit/lib/ +!/test/unit/lib/native/ +!/test/unit/lib/native/cli-harness.test.mts +!/test/unit/lib/native/command-inventory.test.mts +!/test/unit/lib/optimize-svg.test.mts +!/test/unit/lib/terminal/ +!/test/unit/lib/terminal/animation.test.mts +!/test/unit/utils/self-update.terminal.test.mts +!/test/unit/lib/terminal/output.test.mts +!/test/unit/scripts/ +!/test/unit/scripts/test/ +!/test/unit/scripts/constants.test.mts +!/test/native-snapshots/ +!/test/native-snapshots/** +!/test/unit/scripts/test/snapshots.test.mts +!/test/unit/scripts/test/snapshot-dependencies.test.mts +!/test/package-matrix.test.mts +!/test/fixtures/ +!/test/fixtures/commands/ +!/test/fixtures/commands/cdxgen/ +!/test/fixtures/commands/fix/ +!/test/fixtures/commands/fix/e2e-test-js/ +!/test/fixtures/commands/fix/e2e-test-multipm/ +!/test/fixtures/commands/fix/e2e-test-multipm/npm-app/ +!/test/fixtures/commands/fix/e2e-test-multipm/pnpm-app/ +!/test/fixtures/commands/fix/e2e-test-py/ +!/test/fixtures/commands/fix/npm/ +!/test/fixtures/commands/fix/npm/monorepo/ +!/test/fixtures/commands/fix/npm/monorepo/packages/ +!/test/fixtures/commands/fix/npm/monorepo/packages/app/ +!/test/fixtures/commands/fix/npm/monorepo/packages/lib/ +!/test/fixtures/commands/fix/npm/vulnerable-deps/ +!/test/fixtures/commands/fix/pnpm/ +!/test/fixtures/commands/fix/pnpm/monorepo/ +!/test/fixtures/commands/fix/pnpm/monorepo/packages/ +!/test/fixtures/commands/fix/pnpm/monorepo/packages/app/ +!/test/fixtures/commands/fix/pnpm/monorepo/packages/lib/ +!/test/fixtures/commands/fix/pnpm/vulnerable-deps/ +!/test/fixtures/commands/fix/yarn/ +!/test/fixtures/commands/fix/yarn/monorepo/ +!/test/fixtures/commands/fix/yarn/monorepo/packages/ +!/test/fixtures/commands/fix/yarn/monorepo/packages/app/ +!/test/fixtures/commands/fix/yarn/monorepo/packages/lib/ +!/test/fixtures/commands/fix/yarn/vulnerable-deps/ +!/test/fixtures/commands/json/ +!/test/fixtures/commands/manifest/ +!/test/fixtures/commands/manifest/conda/ +!/test/fixtures/commands/manifest/dynamic-sbom-inference/ +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/ +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/ +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/ +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/ +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/nested-gradle/ +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/ +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/independent-submodule/ +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/ +!/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/ +!/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/bare/ +!/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/upper/ +!/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/vendored/ +!/test/fixtures/commands/manifest/python/ +!/test/fixtures/commands/npm/ +!/test/fixtures/commands/npm/lacking-typosquat/ +!/test/fixtures/commands/npm/npm10/ +!/test/fixtures/commands/npm/npm11/ +!/test/fixtures/commands/npm/npm9/ +!/test/fixtures/commands/optimize/ +!/test/fixtures/commands/optimize/npm/ +!/test/fixtures/commands/optimize/pnpm/ +!/test/fixtures/commands/optimize/pnpm8/ +!/test/fixtures/commands/optimize/pnpm9/ +!/test/fixtures/commands/optimize/yarn/ +!/test/fixtures/commands/patch/ +!/test/fixtures/commands/patch/npm/ +!/test/fixtures/commands/patch/npm/.socket/ +!/test/fixtures/commands/patch/npm/.socket/blobs/ +!/test/fixtures/commands/patch/pnpm/ +!/test/fixtures/commands/patch/pnpm/.socket/ +!/test/fixtures/commands/patch/pnpm/.socket/blobs/ +!/test/fixtures/commands/patch/yarn/ +!/test/fixtures/commands/patch/yarn/.socket/ +!/test/fixtures/commands/patch/yarn/.socket/blobs/ +!/test/fixtures/commands/scan/ +!/test/fixtures/commands/scan/npm-test-workspace-mono/ +!/test/fixtures/commands/scan/npm-test-workspace-mono/packages/ +!/test/fixtures/commands/scan/npm-test-workspace-mono/packages/package-a/ +!/test/fixtures/commands/scan/npm-test-workspace-mono/packages/package-b/ +!/test/fixtures/commands/scan/plain-requirements-txt/ +!/test/fixtures/commands/scan/simple-npm/ +!/test/fixtures/commands/scan/uv-workspace/ +!/test/fixtures/commands/scan/uv-workspace/packages/ +!/test/fixtures/commands/scan/uv-workspace/packages/api/ +!/test/fixtures/commands/scan/uv-workspace/packages/other/ +!/test/fixtures/commands/scan/uv-workspace/packages/shared/ +!/test/fixtures/manifest-bazel/ +!/test/fixtures/manifest-bazel/bzlmod-only/ +!/test/fixtures/manifest-bazel/custom-name-bzlmod/ +!/test/fixtures/manifest-bazel/legacy-only/ +!/test/fixtures/manifest-bazel/legacy-with-load/ +!/test/fixtures/manifest-bazel/migration/ +!/test/fixtures/manifest-bazel/multi-repo-bzlmod/ +!/test/fixtures/manifest-bazel/native/ +!/test/fixtures/manifest-bazel/native/bzlmod/ +!/test/fixtures/manifest-bazel/native/workspace/ +!/test/fixtures/manifest-bazel/python-pypi/ +!/.claude/skills/bump-coana/SKILL.md +!/.editorconfig +!/.env.dist +!/.env.external +!/.env.test +!/.env.testu +!/.gitattributes +!/.gitignore +!/.husky/commit-msg +!/.husky/pre-commit +!/.npmrc +!/.oxlintignore +!/.oxlintrc.json +!/.pnpmrc +!/.vscode/extensions.json +!/CHANGELOG.md +!/CLAUDE.md +!/DISCLOSURE +!/LICENSE +!/README.md +!/assets/fleet/badge-follow-bluesky.svg +!/assets/fleet/badge-follow-x.svg +!/assets/fleet/socket-combomark-dark.svg +!/assets/fleet/socket-combomark-light.svg +!/bin/cli.js +!/bin/npm-cli.js +!/bin/npx-cli.js +!/bin/pnpm-cli.js +!/bin/yarn-cli.js +!/biome.json +!/eslint.config.js +!/knip.json +!/package.json +!/patches/@rollup__plugin-commonjs@28.0.6.patch +!/patches/ansi-term@0.0.2.patch +!/patches/blessed-contrib@4.11.0.patch +!/patches/blessed@0.1.81.patch +!/patches/brace-expansion@2.0.2.patch +!/patches/bresenham@0.0.3.patch +!/patches/drawille-blessed-contrib@1.0.0.patch +!/patches/drawille-canvas-blessed-contrib@0.1.3.patch +!/patches/graceful-fs@4.2.11.patch +!/patches/lodash@4.17.21.patch +!/patches/meow@13.2.0.patch +!/patches/rollup@4.50.1.patch +!/patches/string_decoder@0.10.31.patch +!/patches/tiny-updater@3.5.3.patch +!/pnpm-lock.yaml +!/requirements.json +!/scripts/babel/transform-set-proto-plugin.js +!/scripts/babel/transform-url-parse-plugin.js +!/scripts/check-commit-pii.js +!/scripts/check.mts +!/scripts/ci/check-actions.mts +!/scripts/ci/setup-node.mjs +!/scripts/ci/setup-uv.mts +!/scripts/ci/upload-artifact.mjs +!/scripts/constants.js +!/scripts/lib/is-main-module.mts +!/scripts/lib/linters.mts +!/scripts/lib/run-main.mts +!/scripts/lib/scope.mts +!/scripts/lint.mts +!/scripts/release/add-unreleased.mts +!/scripts/release/availability.mts +!/scripts/release/approve-npm.mts +!/scripts/release/prepare.mts +!/scripts/release/changelog.mts +!/scripts/release/github-api.mts +!/scripts/release/history.mts +!/scripts/release/mint-app-token.mjs +!/scripts/release/open-release-pr.mts +!/scripts/release/packages.mts +!/scripts/release/preflight.mts +!/scripts/release/registry.mts +!/scripts/release/release-branch.mts +!/scripts/release/version.mts +!/scripts/release/validate-npm-policy.mts +!/scripts/rollup/socket-modify-plugin.js +!/scripts/test/native.mts +!/scripts/test/apple-signing.mts +!/scripts/test/integration.mts +!/scripts/update.mts +!/scripts/utils/fs.js +!/scripts/utils/packages.js +!/scripts/utils/registry-helpers.js +!/sd +!/shadow-bin/npm +!/shadow-bin/npx +!/shadow-bin/pnpm +!/shadow-bin/yarn +!/socket.yml +!/src/cli.mts +!/src/commands.mts +!/src/commands/analytics/analytics-fixture.json +!/src/commands/analytics/cmd-analytics.mts +!/src/commands/analytics/cmd-analytics.test.mts +!/src/commands/analytics/fetch-org-analytics.mts +!/src/commands/analytics/fetch-repo-analytics.mts +!/src/commands/analytics/handle-analytics.mts +!/src/commands/analytics/output-analytics.mts +!/src/commands/analytics/output-analytics.test.mts +!/src/commands/audit-log/audit-fixture.json +!/src/commands/audit-log/cmd-audit-log.mts +!/src/commands/audit-log/cmd-audit-log.test.mts +!/src/commands/audit-log/fetch-audit-log.mts +!/src/commands/audit-log/handle-audit-log.mts +!/src/commands/audit-log/output-audit-log.mts +!/src/commands/audit-log/output-audit-log.test.mts +!/src/commands/cdxgen/cmd-cdxgen.test.mts +!/src/commands/ci/cmd-ci.mts +!/src/commands/ci/cmd-ci.test.mts +!/src/commands/ci/fetch-default-org-slug.mts +!/src/commands/ci/fetch-default-org-slug.test.mts +!/src/commands/ci/handle-ci.mts +!/src/commands/ci/handle-ci.test.mts +!/src/commands/cli.test.mts +!/src/commands/config/cmd-config-auto.mts +!/src/commands/config/cmd-config-auto.test.mts +!/src/commands/config/cmd-config-get.mts +!/src/commands/config/cmd-config-get.test.mts +!/src/commands/config/cmd-config-list.mts +!/src/commands/config/cmd-config-list.test.mts +!/src/commands/config/cmd-config-set.mts +!/src/commands/config/cmd-config-set.test.mts +!/src/commands/config/cmd-config-unset.mts +!/src/commands/config/cmd-config-unset.test.mts +!/src/commands/config/cmd-config.mts +!/src/commands/config/cmd-config.test.mts +!/src/commands/config/discover-config-value.mts +!/src/commands/config/handle-config-auto.mts +!/src/commands/config/handle-config-get.mts +!/src/commands/config/handle-config-set.mts +!/src/commands/config/handle-config-unset.mts +!/src/commands/config/output-config-auto.mts +!/src/commands/config/output-config-get.mts +!/src/commands/config/output-config-list.mts +!/src/commands/config/output-config-set.mts +!/src/commands/config/output-config-unset.mts +!/src/commands/fix/branch-cleanup.integration.test.mts +!/src/commands/fix/branch-cleanup.mts +!/src/commands/fix/branch-cleanup.test.mts +!/src/commands/fix/cmd-fix.e2e.test.mts +!/src/commands/fix/cmd-fix.integration.test.mts +!/src/commands/fix/cmd-fix.mts +!/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts +!/src/commands/fix/coana-fix-pr-files.test.mts +!/src/commands/fix/coana-fix.mts +!/src/commands/fix/env-helpers.mts +!/src/commands/fix/env-helpers.test.mts +!/src/commands/fix/generated-socket-facts.mts +!/src/commands/fix/generated-socket-facts.test.mts +!/src/commands/fix/git.mts +!/src/commands/fix/handle-fix-id.test.mts +!/src/commands/fix/handle-fix-limit.test.mts +!/src/commands/fix/handle-fix.mts +!/src/commands/fix/output-fix-result.mts +!/src/commands/fix/pull-request.mts +!/src/commands/fix/types.mts +!/src/commands/install/cmd-install-completion.mts +!/src/commands/install/cmd-install-completion.test.mts +!/src/commands/install/cmd-install.mts +!/src/commands/install/cmd-install.test.mts +!/src/commands/install/handle-install-completion.mts +!/src/commands/install/output-install-completion.mts +!/src/commands/install/setup-tab-completion.mts +!/src/commands/install/socket-completion.bash +!/src/commands/json/cmd-json.mts +!/src/commands/json/cmd-json.test.mts +!/src/commands/json/handle-cmd-json.mts +!/src/commands/json/output-cmd-json.mts +!/src/commands/login/apply-login.mts +!/src/commands/login/attempt-login.mts +!/src/commands/login/cmd-login-smoke.test.mts +!/src/commands/login/cmd-login.mts +!/src/commands/login/cmd-login.test.mts +!/src/commands/logout/apply-logout.mts +!/src/commands/logout/attempt-logout.mts +!/src/commands/logout/cmd-logout.mts +!/src/commands/logout/cmd-logout.test.mts +!/src/commands/manifest/README.md +!/src/commands/manifest/bazel/bazel-bin-detect.mts +!/src/commands/manifest/bazel/bazel-bin-detect.test.mts +!/src/commands/manifest/bazel/bazel-cleanup.mts +!/src/commands/manifest/bazel/bazel-cquery.mts +!/src/commands/manifest/bazel/bazel-cquery.test.mts +!/src/commands/manifest/bazel/bazel-java-shim.mts +!/src/commands/manifest/bazel/bazel-java-shim.test.mts +!/src/commands/manifest/bazel/bazel-native.e2e.test.mts +!/src/commands/manifest/bazel/bazel-output-base-check.mts +!/src/commands/manifest/bazel/bazel-output-base-check.test.mts +!/src/commands/manifest/bazel/bazel-process.mts +!/src/commands/manifest/bazel/bazel-process.test.mts +!/src/commands/manifest/bazel/bazel-pypi-discovery.mts +!/src/commands/manifest/bazel/bazel-pypi-discovery.test.mts +!/src/commands/manifest/bazel/bazel-pypi-parser.mts +!/src/commands/manifest/bazel/bazel-pypi-parser.test.mts +!/src/commands/manifest/bazel/bazel-python-shim.mts +!/src/commands/manifest/bazel/bazel-python-shim.test.mts +!/src/commands/manifest/bazel/bazel-query-runner.mts +!/src/commands/manifest/bazel/bazel-query-runner.test.mts +!/src/commands/manifest/bazel/bazel-repo-discovery.mts +!/src/commands/manifest/bazel/bazel-repo-discovery.test.mts +!/src/commands/manifest/bazel/bazel-workspace-detect.mts +!/src/commands/manifest/bazel/bazel-workspace-detect.test.mts +!/src/commands/manifest/bazel/bazel-workspace-walk.mts +!/src/commands/manifest/bazel/bazel-workspace-walk.test.mts +!/src/commands/manifest/bazel/cmd-manifest-bazel.mts +!/src/commands/manifest/bazel/cmd-manifest-bazel.test.mts +!/src/commands/manifest/bazel/extract_bazel_to_maven.mts +!/src/commands/manifest/bazel/extract_bazel_to_maven.test.mts +!/src/commands/manifest/bazel/extract_bazel_to_pypi.constructed.test.mts +!/src/commands/manifest/bazel/extract_bazel_to_pypi.mts +!/src/commands/manifest/bazel/extract_bazel_to_pypi.test.mts +!/src/commands/manifest/cmd-manifest-auto.mts +!/src/commands/manifest/cmd-manifest-auto.test.mts +!/src/commands/manifest/cmd-manifest-cdxgen.mts +!/src/commands/manifest/cmd-manifest-cdxgen.test.mts +!/src/commands/manifest/cmd-manifest-conda.mts +!/src/commands/manifest/cmd-manifest-conda.test.mts +!/src/commands/manifest/cmd-manifest-dotnet.mts +!/src/commands/manifest/cmd-manifest-dotnet.test.mts +!/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts +!/src/commands/manifest/cmd-manifest-dynamic-sbom-inference.test.mts +!/src/commands/manifest/cmd-manifest-gradle.mts +!/src/commands/manifest/cmd-manifest-gradle.test.mts +!/src/commands/manifest/cmd-manifest-kotlin.mts +!/src/commands/manifest/cmd-manifest-kotlin.test.mts +!/src/commands/manifest/cmd-manifest-maven.mts +!/src/commands/manifest/cmd-manifest-maven.test.mts +!/src/commands/manifest/cmd-manifest-scala.mts +!/src/commands/manifest/cmd-manifest-scala.test.mts +!/src/commands/manifest/cmd-manifest-setup.mts +!/src/commands/manifest/cmd-manifest-setup.test.mts +!/src/commands/manifest/cmd-manifest.mts +!/src/commands/manifest/cmd-manifest.test.mts +!/src/commands/manifest/convert-conda-to-requirements.mts +!/src/commands/manifest/convert-conda-to-requirements.test.mts +!/src/commands/manifest/convert-dotnet-to-facts.mts +!/src/commands/manifest/convert-gradle-to-facts.mts +!/src/commands/manifest/convert-maven-to-facts.mts +!/src/commands/manifest/convert-sbt-to-facts.mts +!/src/commands/manifest/convert_gradle_to_maven.mts +!/src/commands/manifest/convert_sbt_to_maven.mts +!/src/commands/manifest/detect-manifest-actions.mts +!/src/commands/manifest/detect-manifest-actions.test.mts +!/src/commands/manifest/discover-manifest-roots.mts +!/src/commands/manifest/discover-manifest-roots.test.mts +!/src/commands/manifest/enumerate-workspaces.mts +!/src/commands/manifest/enumerate-workspaces.test.mts +!/src/commands/manifest/expand-env-var-refs.mts +!/src/commands/manifest/expand-env-var-refs.test.mts +!/src/commands/manifest/generate-recursive-manifests.mts +!/src/commands/manifest/generate-recursive-manifests.test.mts +!/src/commands/manifest/generate_auto_manifest.mts +!/src/commands/manifest/generate_auto_manifest.test.mts +!/src/commands/manifest/handle-manifest-conda.mts +!/src/commands/manifest/handle-manifest-dynamic-sbom-inference.mts +!/src/commands/manifest/handle-manifest-dynamic-sbom-inference.test.mts +!/src/commands/manifest/handle-manifest-setup.mts +!/src/commands/manifest/init.gradle +!/src/commands/manifest/manifest-flags.mts +!/src/commands/manifest/output-manifest-dynamic-sbom-inference.mts +!/src/commands/manifest/output-manifest-setup.mts +!/src/commands/manifest/output-requirements.mts +!/src/commands/manifest/parse-build-tool-opts.mts +!/src/commands/manifest/parse-build-tool-opts.test.mts +!/src/commands/manifest/run-cdxgen.mts +!/src/commands/manifest/run-manifest-facts.mts +!/src/commands/manifest/run-manifest-facts.test.mts +!/src/commands/manifest/scripts/assemble.mts +!/src/commands/manifest/scripts/assemble.test.mts +!/src/commands/manifest/scripts/build-tool.mts +!/src/commands/manifest/scripts/build-tool.test.mts +!/src/commands/manifest/scripts/dotnet-tool/.gitignore +!/src/commands/manifest/scripts/dotnet-tool/FactsRunner.cs +!/src/commands/manifest/scripts/dotnet-tool/NuGetShim.cs +!/src/commands/manifest/scripts/dotnet-tool/Patterns.cs +!/src/commands/manifest/scripts/dotnet-tool/Program.cs +!/src/commands/manifest/scripts/dotnet-tool/Reader.cs +!/src/commands/manifest/scripts/dotnet-tool/RecordsWriter.cs +!/src/commands/manifest/scripts/dotnet-tool/Restorer.cs +!/src/commands/manifest/scripts/dotnet-tool/SolutionContext.cs +!/src/commands/manifest/scripts/dotnet-tool/ToolOptions.cs +!/src/commands/manifest/scripts/dotnet-tool/build-tool.sh +!/src/commands/manifest/scripts/dotnet-tool/packages.lock.json +!/src/commands/manifest/scripts/dotnet-tool/socket-facts-dotnet.csproj +!/src/commands/manifest/scripts/exclude-paths-glob.mts +!/src/commands/manifest/scripts/exclude-paths-glob.test.mts +!/src/commands/manifest/scripts/facts.mts +!/src/commands/manifest/scripts/maven-extension/.gitignore +!/src/commands/manifest/scripts/maven-extension/.mvn/wrapper/maven-wrapper.properties +!/src/commands/manifest/scripts/maven-extension/build-jar.sh +!/src/commands/manifest/scripts/maven-extension/mvnw +!/src/commands/manifest/scripts/maven-extension/mvnw.cmd +!/src/commands/manifest/scripts/maven-extension/pom.xml +!/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsEventSpy.java +!/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsLifecycleParticipant.java +!/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaWorkspacesLifecycleParticipant.java +!/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/CollectionObserver.java +!/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java +!/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java +!/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketWorkspacesRecordsEngine.java +!/src/commands/manifest/scripts/records.mts +!/src/commands/manifest/scripts/resolution-report-gradle.mts +!/src/commands/manifest/scripts/resolution-report-ivy.mts +!/src/commands/manifest/scripts/resolution-report-maven.mts +!/src/commands/manifest/scripts/resolution-report-nuget.mts +!/src/commands/manifest/scripts/resolution-report-nuget.test.mts +!/src/commands/manifest/scripts/resolution-report-render.mts +!/src/commands/manifest/scripts/resolution-report-render.test.mts +!/src/commands/manifest/scripts/resolution-report.mts +!/src/commands/manifest/scripts/run.mts +!/src/commands/manifest/scripts/run.test.mts +!/src/commands/manifest/scripts/sidecar.mts +!/src/commands/manifest/scripts/sidecar.test.mts +!/src/commands/manifest/scripts/socket-facts.init.gradle +!/src/commands/manifest/scripts/socket-facts.plugin.scala +!/src/commands/manifest/scripts/socket-workspaces.init.gradle +!/src/commands/manifest/scripts/socket-workspaces.plugin.scala +!/src/commands/manifest/scripts/test/README.md +!/src/commands/manifest/scripts/test/assert-reachability.py +!/src/commands/manifest/scripts/test/compat-cache.sh +!/src/commands/manifest/scripts/test/dotnet-compat/.gitignore +!/src/commands/manifest/scripts/test/dotnet-compat/assert-dotnet-list.py +!/src/commands/manifest/scripts/test/dotnet-compat/assert-facts.py +!/src/commands/manifest/scripts/test/dotnet-compat/basic/Basic.sln +!/src/commands/manifest/scripts/test/dotnet-compat/basic/shared/Linked.cs +!/src/commands/manifest/scripts/test/dotnet-compat/basic/src/App/App.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/basic/src/App/Program.cs +!/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Extra/E.cs +!/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Extra/Extra.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Lib/L.cs +!/src/commands/manifest/scripts/test/dotnet-compat/basic/src/Lib/Lib.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/basic/test/App.Tests/App.Tests.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/basic/test/App.Tests/T.cs +!/src/commands/manifest/scripts/test/dotnet-compat/basic/tools/Nuke/Nuke.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/cpm/Cpm.slnx +!/src/commands/manifest/scripts/test/dotnet-compat/cpm/Directory.Build.props +!/src/commands/manifest/scripts/test/dotnet-compat/cpm/Directory.Packages.props +!/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Api/A.cs +!/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Api/Api.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Core/C.cs +!/src/commands/manifest/scripts/test/dotnet-compat/cpm/src/Core/Core.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/dev/Dev.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/dev/Program.cs +!/src/commands/manifest/scripts/test/dotnet-compat/evaluation/Build.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/evaluation/Lib/Class.cs +!/src/commands/manifest/scripts/test/dotnet-compat/evaluation/Lib/Lib.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/App.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/App.sln +!/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/app/core/Core.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/exclude-base/shared/Shared.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/failures/broken/Broken.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/failures/missing/Missing.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/langs/Fs/First.fs +!/src/commands/manifest/scripts/test/dotnet-compat/langs/Fs/Fs.fsproj +!/src/commands/manifest/scripts/test/dotnet-compat/langs/Fs/Library.fs +!/src/commands/manifest/scripts/test/dotnet-compat/langs/Vb/V.vb +!/src/commands/manifest/scripts/test/dotnet-compat/langs/Vb/Vb.vbproj +!/src/commands/manifest/scripts/test/dotnet-compat/lib.sh +!/src/commands/manifest/scripts/test/dotnet-compat/make-stub-feed.py +!/src/commands/manifest/scripts/test/dotnet-compat/multi/One.sln +!/src/commands/manifest/scripts/test/dotnet-compat/multi/One.slnx +!/src/commands/manifest/scripts/test/dotnet-compat/multi/One/One.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/multi/Stray.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/multi/Two.sln +!/src/commands/manifest/scripts/test/dotnet-compat/multi/Two/Two.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/nuget.config +!/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/Class1.cs +!/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/Legacy.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/Properties/AssemblyInfo.cs +!/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Legacy/packages.config +!/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Mixed.sln +!/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Modern/M.cs +!/src/commands/manifest/scripts/test/dotnet-compat/packages-config/Modern/Modern.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/pair/A.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/pair/B.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/rid/R.cs +!/src/commands/manifest/scripts/test/dotnet-compat/rid/Rid.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/sibling/sln/All.sln +!/src/commands/manifest/scripts/test/dotnet-compat/sibling/src/A/A.cs +!/src/commands/manifest/scripts/test/dotnet-compat/sibling/src/A/A.csproj +!/src/commands/manifest/scripts/test/dotnet-compat/smoke-test-exclude-base.sh +!/src/commands/manifest/scripts/test/dotnet-compat/smoke-test-workspaces.sh +!/src/commands/manifest/scripts/test/dotnet-compat/smoke-test.sh +!/src/commands/manifest/scripts/test/gradle-compat/.gitignore +!/src/commands/manifest/scripts/test/gradle-compat/README.md +!/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/build.gradle +!/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/core/build.gradle +!/src/commands/manifest/scripts/test/gradle-compat/exclude-base/app/settings.gradle +!/src/commands/manifest/scripts/test/gradle-compat/exclude-base/shared/build.gradle +!/src/commands/manifest/scripts/test/gradle-compat/failures/app/build.gradle +!/src/commands/manifest/scripts/test/gradle-compat/failures/build.gradle +!/src/commands/manifest/scripts/test/gradle-compat/failures/gradle/verification-metadata.xml +!/src/commands/manifest/scripts/test/gradle-compat/failures/lib/build.gradle +!/src/commands/manifest/scripts/test/gradle-compat/failures/settings.gradle +!/src/commands/manifest/scripts/test/gradle-compat/failures/transforms.gradle +!/src/commands/manifest/scripts/test/gradle-compat/project/build.gradle +!/src/commands/manifest/scripts/test/gradle-compat/project/settings.gradle +!/src/commands/manifest/scripts/test/gradle-compat/smoke-test-exclude-base.sh +!/src/commands/manifest/scripts/test/gradle-compat/smoke-test-failures.sh +!/src/commands/manifest/scripts/test/gradle-compat/smoke-test-workspaces.sh +!/src/commands/manifest/scripts/test/gradle-compat/smoke-test.sh +!/src/commands/manifest/scripts/test/make-stub-repo.sh +!/src/commands/manifest/scripts/test/maven-compat/.gitignore +!/src/commands/manifest/scripts/test/maven-compat/assert-fail-closed.py +!/src/commands/manifest/scripts/test/maven-compat/assert-no-invented-failures.py +!/src/commands/manifest/scripts/test/maven-compat/assert-records.py +!/src/commands/manifest/scripts/test/maven-compat/descriptor-failure/a/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/descriptor-failure/b/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/descriptor-failure/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/duplicate-failure/m1/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/duplicate-failure/m2/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/duplicate-failure/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/core/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/exclude-base/shared/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/graph-failure/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/project/app/src/main/java/demo/App.java +!/src/commands/manifest/scripts/test/maven-compat/project/lib/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/project/lib/src/main/java/demo/Lib.java +!/src/commands/manifest/scripts/test/maven-compat/project/mid/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/project/mid/src/main/java/demo/Mid.java +!/src/commands/manifest/scripts/test/maven-compat/project/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/repo-inheritance/liba/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/repo-inheritance/libb/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/repo-inheritance/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/smoke-test-descriptor-failure.sh +!/src/commands/manifest/scripts/test/maven-compat/smoke-test-duplicate-failure.sh +!/src/commands/manifest/scripts/test/maven-compat/smoke-test-exclude-base.sh +!/src/commands/manifest/scripts/test/maven-compat/smoke-test-graph-failure.sh +!/src/commands/manifest/scripts/test/maven-compat/smoke-test-repo-inheritance.sh +!/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh +!/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh +!/src/commands/manifest/scripts/test/run-compat.sh +!/src/commands/manifest/scripts/test/sbt-compat/.gitignore +!/src/commands/manifest/scripts/test/sbt-compat/artifact-failure/build.sbt +!/src/commands/manifest/scripts/test/sbt-compat/exclude-base/app/build.sbt +!/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt +!/src/commands/manifest/scripts/test/sbt-compat/project/Base.scala +!/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt +!/src/commands/manifest/scripts/test/sbt-compat/project/src/main/scala/App.scala +!/src/commands/manifest/scripts/test/sbt-compat/relocation/build.sbt +!/src/commands/manifest/scripts/test/sbt-compat/smoke-test-exclude-base.sh +!/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh +!/src/commands/manifest/scripts/test/sbt-compat/smoke-test-relocation.sh +!/src/commands/manifest/scripts/test/sbt-compat/smoke-test-workspaces.sh +!/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh +!/src/commands/manifest/setup-manifest-config.mts +!/src/commands/manifest/setup-manifest-config.test.mts +!/src/commands/manifest/setup-recursive-manifest-config.mts +!/src/commands/manifest/setup-recursive-manifest-config.test.mts +!/src/commands/npm/cmd-npm-malware.test.mts +!/src/commands/npm/cmd-npm.mts +!/src/commands/npm/cmd-npm.test.mts +!/src/commands/npm/socket-npm-integration.test.mts +!/src/commands/npx/cmd-npx-malware.test.mts +!/src/commands/npx/cmd-npx.mts +!/src/commands/npx/cmd-npx.test.mts +!/src/commands/oops/cmd-oops.mts +!/src/commands/oops/cmd-oops.test.mts +!/src/commands/optimize/add-overrides.mts +!/src/commands/optimize/apply-optimization.mts +!/src/commands/optimize/cmd-optimize-pnpm-versions.test.mts +!/src/commands/optimize/cmd-optimize.mts +!/src/commands/optimize/cmd-optimize.test.mts +!/src/commands/optimize/deps-includes-by-agent.mts +!/src/commands/optimize/get-dependency-entries.mts +!/src/commands/optimize/get-overrides-by-agent.mts +!/src/commands/optimize/handle-optimize.mts +!/src/commands/optimize/lockfile-includes-by-agent.mts +!/src/commands/optimize/ls-by-agent.mts +!/src/commands/optimize/output-optimize-result.mts +!/src/commands/optimize/shared.mts +!/src/commands/optimize/types.mts +!/src/commands/optimize/update-lockfile.mts +!/src/commands/optimize/update-manifest-by-agent.mts +!/src/commands/optimize/update-manifest-by-agent.test.mts +!/src/commands/organization/cmd-organization-dependencies.mts +!/src/commands/organization/cmd-organization-dependencies.test.mts +!/src/commands/organization/cmd-organization-list.mts +!/src/commands/organization/cmd-organization-list.test.mts +!/src/commands/organization/cmd-organization-policy-license.mts +!/src/commands/organization/cmd-organization-policy-license.test.mts +!/src/commands/organization/cmd-organization-policy-security.mts +!/src/commands/organization/cmd-organization-policy-security.test.mts +!/src/commands/organization/cmd-organization-policy.mts +!/src/commands/organization/cmd-organization-policy.test.mts +!/src/commands/organization/cmd-organization-quota.mts +!/src/commands/organization/cmd-organization-quota.test.mts +!/src/commands/organization/cmd-organization.mts +!/src/commands/organization/cmd-organization.test.mts +!/src/commands/organization/fetch-dependencies.mts +!/src/commands/organization/fetch-license-policy.mts +!/src/commands/organization/fetch-organization-list.mts +!/src/commands/organization/fetch-quota.mts +!/src/commands/organization/fetch-security-policy.mts +!/src/commands/organization/handle-dependencies.mts +!/src/commands/organization/handle-dependencies.test.mts +!/src/commands/organization/handle-license-policy.mts +!/src/commands/organization/handle-organization-list.mts +!/src/commands/organization/handle-quota.mts +!/src/commands/organization/handle-quota.test.mts +!/src/commands/organization/handle-security-policy.mts +!/src/commands/organization/output-dependencies.mts +!/src/commands/organization/output-license-policy.mts +!/src/commands/organization/output-organization-list.mts +!/src/commands/organization/output-quota.mts +!/src/commands/organization/output-security-policy.mts +!/src/commands/package/cmd-package-score.mts +!/src/commands/package/cmd-package-score.test.mts +!/src/commands/package/cmd-package-shallow.mts +!/src/commands/package/cmd-package-shallow.test.mts +!/src/commands/package/cmd-package.mts +!/src/commands/package/cmd-package.test.mts +!/src/commands/package/fetch-purl-deep-score.mts +!/src/commands/package/fetch-purls-shallow-score.mts +!/src/commands/package/fixtures/go_deep.json +!/src/commands/package/fixtures/go_shallow.json +!/src/commands/package/fixtures/maven_deep.json +!/src/commands/package/fixtures/maven_shallow.json +!/src/commands/package/fixtures/npm_deep.json +!/src/commands/package/fixtures/npm_malware.json +!/src/commands/package/fixtures/npm_shallow.json +!/src/commands/package/fixtures/nuget_deep.json +!/src/commands/package/fixtures/nuget_shallow.json +!/src/commands/package/fixtures/python_deep.json +!/src/commands/package/fixtures/python_dupes.json +!/src/commands/package/fixtures/python_shallow.json +!/src/commands/package/fixtures/ruby_deep.json +!/src/commands/package/fixtures/ruby_shallow.json +!/src/commands/package/handle-purl-deep-score.mts +!/src/commands/package/handle-purls-shallow-score.mts +!/src/commands/package/output-purls-deep-score.mts +!/src/commands/package/output-purls-deep-score.test.mts +!/src/commands/package/output-purls-shallow-malware.test.mts +!/src/commands/package/output-purls-shallow-score.mts +!/src/commands/package/output-purls-shallow-score.test.mts +!/src/commands/package/parse-package-specifiers.mts +!/src/commands/package/parse-package-specifiers.test.mts +!/src/commands/patch/cmd-patch.mts +!/src/commands/pnpm/cmd-pnpm-malware.test.mts +!/src/commands/pnpm/cmd-pnpm.mts +!/src/commands/pnpm/cmd-pnpm.test.mts +!/src/commands/raw-npm/cmd-raw-npm.mts +!/src/commands/raw-npm/cmd-raw-npm.test.mts +!/src/commands/raw-npm/run-raw-npm.mts +!/src/commands/raw-npx/cmd-raw-npx.mts +!/src/commands/raw-npx/cmd-raw-npx.test.mts +!/src/commands/raw-npx/run-raw-npx.mts +!/src/commands/repository/cmd-repository-create.mts +!/src/commands/repository/cmd-repository-create.test.mts +!/src/commands/repository/cmd-repository-del.mts +!/src/commands/repository/cmd-repository-del.test.mts +!/src/commands/repository/cmd-repository-list.mts +!/src/commands/repository/cmd-repository-list.test.mts +!/src/commands/repository/cmd-repository-smoke.test.mts +!/src/commands/repository/cmd-repository-update.mts +!/src/commands/repository/cmd-repository-update.test.mts +!/src/commands/repository/cmd-repository-view.mts +!/src/commands/repository/cmd-repository-view.test.mts +!/src/commands/repository/cmd-repository.mts +!/src/commands/repository/cmd-repository.test.mts +!/src/commands/repository/fetch-create-repo.mts +!/src/commands/repository/fetch-delete-repo.mts +!/src/commands/repository/fetch-list-all-repos.mts +!/src/commands/repository/fetch-list-repos.mts +!/src/commands/repository/fetch-update-repo.mts +!/src/commands/repository/fetch-view-repo.mts +!/src/commands/repository/handle-create-repo.mts +!/src/commands/repository/handle-delete-repo.mts +!/src/commands/repository/handle-list-repos.mts +!/src/commands/repository/handle-update-repo.mts +!/src/commands/repository/handle-view-repo.mts +!/src/commands/repository/output-create-repo.mts +!/src/commands/repository/output-delete-repo.mts +!/src/commands/repository/output-list-repos.mts +!/src/commands/repository/output-update-repo.mts +!/src/commands/repository/output-view-repo.mts +!/src/commands/repository/types.mts +!/src/commands/scan/cmd-scan-create.mts +!/src/commands/scan/cmd-scan-create.test.mts +!/src/commands/scan/cmd-scan-del.mts +!/src/commands/scan/cmd-scan-del.test.mts +!/src/commands/scan/cmd-scan-diff.mts +!/src/commands/scan/cmd-scan-diff.test.mts +!/src/commands/scan/cmd-scan-github.mts +!/src/commands/scan/cmd-scan-github.test.mts +!/src/commands/scan/cmd-scan-list.mts +!/src/commands/scan/cmd-scan-list.test.mts +!/src/commands/scan/cmd-scan-metadata.mts +!/src/commands/scan/cmd-scan-metadata.test.mts +!/src/commands/scan/cmd-scan-reach.e2e.test.mts +!/src/commands/scan/cmd-scan-reach.mts +!/src/commands/scan/cmd-scan-reach.test.mts +!/src/commands/scan/cmd-scan-report.mts +!/src/commands/scan/cmd-scan-report.test.mts +!/src/commands/scan/cmd-scan-setup.mts +!/src/commands/scan/cmd-scan-setup.test.mts +!/src/commands/scan/cmd-scan-view.mts +!/src/commands/scan/cmd-scan-view.test.mts +!/src/commands/scan/cmd-scan.mts +!/src/commands/scan/cmd-scan.test.mts +!/src/commands/scan/create-scan-from-github.mts +!/src/commands/scan/create-scan-from-github.test.mts +!/src/commands/scan/exclude-paths.mts +!/src/commands/scan/exclude-paths.test.mts +!/src/commands/scan/fetch-create-org-full-scan.mts +!/src/commands/scan/fetch-delete-org-full-scan.mts +!/src/commands/scan/fetch-diff-scan.mts +!/src/commands/scan/fetch-list-scans.mts +!/src/commands/scan/fetch-report-data.mts +!/src/commands/scan/fetch-scan-metadata.mts +!/src/commands/scan/fetch-scan.mts +!/src/commands/scan/fetch-scan.test.mts +!/src/commands/scan/fetch-supported-scan-file-names.mts +!/src/commands/scan/finalize-tier1-scan.mts +!/src/commands/scan/generate-report.mts +!/src/commands/scan/generate-report.test.mts +!/src/commands/scan/generate-uv-member-sboms.e2e.test.mts +!/src/commands/scan/generate-uv-member-sboms.mts +!/src/commands/scan/generate-uv-member-sboms.test.mts +!/src/commands/scan/handle-create-github-scan.mts +!/src/commands/scan/handle-create-new-scan.mts +!/src/commands/scan/handle-create-new-scan.test.mts +!/src/commands/scan/handle-delete-scan.mts +!/src/commands/scan/handle-diff-scan.mts +!/src/commands/scan/handle-list-scans.mts +!/src/commands/scan/handle-scan-config.mts +!/src/commands/scan/handle-scan-metadata.mts +!/src/commands/scan/handle-scan-reach.mts +!/src/commands/scan/handle-scan-reach.test.mts +!/src/commands/scan/handle-scan-report.mts +!/src/commands/scan/handle-scan-view.mts +!/src/commands/scan/output-create-new-scan.mts +!/src/commands/scan/output-create-new-scan.test.mts +!/src/commands/scan/output-delete-scan.mts +!/src/commands/scan/output-diff-scan.mts +!/src/commands/scan/output-list-scans.mts +!/src/commands/scan/output-scan-config-result.mts +!/src/commands/scan/output-scan-github.mts +!/src/commands/scan/output-scan-metadata.mts +!/src/commands/scan/output-scan-reach.mts +!/src/commands/scan/output-scan-reach.test.mts +!/src/commands/scan/output-scan-report.mts +!/src/commands/scan/output-scan-report.test.mts +!/src/commands/scan/output-scan-view.mts +!/src/commands/scan/perform-reachability-analysis.mts +!/src/commands/scan/perform-reachability-analysis.test.mts +!/src/commands/scan/reach-advisories.mts +!/src/commands/scan/reach-advisories.test.mts +!/src/commands/scan/reachability-flags.mts +!/src/commands/scan/reachability-flags.test.mts +!/src/commands/scan/reachability-units.mts +!/src/commands/scan/reachability-units.test.mts +!/src/commands/scan/run-dynamic-sbom-inference.mts +!/src/commands/scan/run-dynamic-sbom-inference.test.mts +!/src/commands/scan/setup-scan-config.mts +!/src/commands/scan/stream-scan.mts +!/src/commands/scan/suggest-org-slug.mts +!/src/commands/scan/suggest_branch_slug.mts +!/src/commands/scan/suggest-to-persist-orgslug.mts +!/src/commands/scan/suggest_target.mts +!/src/commands/scan/types.mts +!/src/commands/scan/validate-reachability-target.mts +!/src/commands/scan/warn-socket-json-only-flags.mts +!/src/commands/threat-feed/cmd-threat-feed.mts +!/src/commands/threat-feed/cmd-threat-feed.test.mts +!/src/commands/threat-feed/fetch-threat-feed.mts +!/src/commands/threat-feed/handle-threat-feed.mts +!/src/commands/threat-feed/output-threat-feed.mts +!/src/commands/threat-feed/types.mts +!/src/commands/uninstall/cmd-uninstall-completion.mts +!/src/commands/uninstall/cmd-uninstall-completion.test.mts +!/src/commands/uninstall/cmd-uninstall.mts +!/src/commands/uninstall/cmd-uninstall.test.mts +!/src/commands/uninstall/handle-uninstall-completion.mts +!/src/commands/uninstall/output-uninstall-completion.mts +!/src/commands/uninstall/teardown-tab-completion.mts +!/src/commands/wrapper/add-socket-wrapper.mts +!/src/commands/wrapper/check-socket-wrapper-setup.mts +!/src/commands/wrapper/cmd-wrapper.mts +!/src/commands/wrapper/cmd-wrapper.test.mts +!/src/commands/wrapper/postinstall-wrapper.mts +!/src/commands/wrapper/remove-socket-wrapper.mts +!/src/commands/yarn/cmd-yarn-malware.test.mts +!/src/commands/yarn/cmd-yarn.mts +!/src/commands/yarn/cmd-yarn.test.mts +!/src/constants.mts +!/src/external/blessed-contrib/lib/layout/grid.mjs +!/src/external/blessed-contrib/lib/widget/charts/bar.mjs +!/src/external/blessed-contrib/lib/widget/charts/line.mjs +!/src/external/blessed-contrib/lib/widget/table.mjs +!/src/flags.mts +!/src/instrument-with-sentry.mts +!/src/npm-cli.mts +!/src/npx-cli.mts +!/src/pnpm-cli.mts +!/src/registry-constants.mts +!/src/sea/README.md +!/src/sea/adapters/cdxgen-worker.mjs +!/src/sea/adapters/cdxgen.mts +!/src/sea/adapters/synp-worker.mjs +!/src/sea/adapters/synp.mts +!/src/sea/bootstrap.mts +!/src/sea/build-sea.mts +!/src/sea/licenses/cdxgen-third-party/boolbase-1.0.0-ISC.txt +!/src/sea/licenses/cdxgen-third-party/err-code-2.0.3-MIT.txt +!/src/sea/licenses/cdxgen-third-party/imurmurhash-0.1.4-MIT.txt +!/src/sea/licenses/cdxgen-third-party/spdx-exceptions-2.5.0-CC-BY-3.0.txt +!/src/sea/licenses/cdxgen-third-party/spdx-license-ids-3.0.22-CC0-1.0.txt +!/src/sea/licenses/core-third-party/ansi-term-0.0.2-ISC.txt +!/src/sea/licenses/core-third-party/drawille-canvas-blessed-contrib-0.1.3-MIT.txt +!/src/sea/licenses/core-third-party/npmcli-agent-3.0.0-ISC.txt +!/src/sea/licenses/core-third-party/npmcli-agent-4.0.0-ISC.txt +!/src/sea/licenses/core-third-party/sigstore-verify-3.1.1-Apache-2.0.txt +!/src/sea/licenses/core-third-party/x256-0.0.2-MIT.txt +!/src/sea/licenses/socket-patch-MIT.txt +!/src/sea/build/lock.mts +!/src/sea/build/publish.mts +!/src/sea/rollup-provenance.mjs +!/src/sea/runtime.mts +!/src/sea/download-bytes.mts +!/src/sea/stage-synp.mts +!/src/sea/stage-uv.mts +!/src/sea/version.mts +!/src/sea/uv-pins.json +!/src/shadow/common.mts +!/src/shadow/npm-base.mts +!/src/shadow/npm-base.test.mts +!/src/shadow/npm/arborist-helpers.mts +!/src/shadow/npm/arborist/index.mts +!/src/shadow/npm/arborist/lib/arborist/index.mts +!/src/shadow/npm/arborist/types.mts +!/src/shadow/npm/bin.mts +!/src/shadow/npm/bin.test.mts +!/src/shadow/npm/inject.mts +!/src/shadow/npm/install.mts +!/src/shadow/npm/paths.mts +!/src/shadow/npx/bin.mts +!/src/shadow/npx/bin.test.mts +!/src/shadow/pnpm/bin.mts +!/src/shadow/pnpm/bin.test.mts +!/src/shadow/stdio-ipc.mts +!/src/shadow/yarn/bin.mts +!/src/shadow/yarn/bin.test.mts +!/src/test/cli.test.mts +!/src/test/json-output-validation.mts +!/src/test/mocks/socket-auth.mts +!/src/types.mts +!/src/utils/advisory-ids.mts +!/src/utils/agent.mts +!/src/utils/alert/artifact.mts +!/src/utils/alert/fix.mts +!/src/utils/alert/severity.mts +!/src/utils/alerts-map.mts +!/src/utils/alerts-map.test.mts +!/src/utils/api.mts +!/src/utils/api.test.mts +!/src/utils/arrays.mts +!/src/utils/bin.mts +!/src/utils/check-input.mts +!/src/utils/cmd.mts +!/src/utils/coana-release.mts +!/src/utils/coana-release.test.mts +!/src/utils/coana.mts +!/src/utils/coana.test.mts +!/src/utils/color-or-markdown.mts +!/src/utils/completion.mts +!/src/utils/config.mts +!/src/utils/config.test.mts +!/src/utils/cve-to-ghsa.mts +!/src/utils/debug.mts +!/src/utils/determine-org-slug.mts +!/src/utils/dlx-detection.mts +!/src/utils/dlx.mts +!/src/utils/dlx.test.mts +!/src/utils/ecosystem.mts +!/src/utils/editable-json.mts +!/src/utils/env.mts +!/src/utils/errors.mts +!/src/utils/errors.test.mts +!/src/utils/escape-regexp.mts +!/src/utils/extract-names.mts +!/src/utils/extract-names.test.mts +!/src/utils/fail-msg-with-badge.mts +!/src/utils/filter-config.mts +!/src/utils/fs.mts +!/src/utils/get-or-insert.mts +!/src/utils/get-output-kind.mts +!/src/utils/git.mts +!/src/utils/git.test.mts +!/src/utils/github-errors.mts +!/src/utils/github-errors.test.mts +!/src/utils/github.mts +!/src/utils/glob-content-dedup.test.mts +!/src/utils/glob.mts +!/src/utils/glob.test.mts +!/src/utils/globs.mts +!/src/utils/logger.mts +!/src/utils/map-to-object.mts +!/src/utils/map-to-object.test.mts +!/src/utils/markdown.mts +!/src/utils/markdown.test.mts +!/src/utils/meow-with-subcommands.mts +!/src/utils/lockfile.mts +!/src/utils/ms-at-home.mts +!/src/utils/npm-package-arg.mts +!/src/utils/npm-paths.mts +!/src/utils/npm-paths.test.mts +!/src/utils/npm-config.mts +!/src/utils/npm-spec.mts +!/src/utils/objects.mts +!/src/utils/is-object-object.mjs +!/src/utils/is-object-object.d.mts +!/src/utils/organization.mts +!/src/utils/output-formatting.mts +!/src/utils/package-environment.mts +!/src/utils/package-manager.mts +!/src/utils/packages.mts +!/src/utils/path-resolve.mts +!/src/utils/path-resolve.test.mts +!/src/utils/path.mts +!/src/utils/pnpm-paths.mts +!/src/utils/pnpm-scanning.test.mts +!/src/utils/pnpm.mts +!/src/utils/promises.mts +!/src/utils/prompts.mts +!/src/utils/purl-legacy-npm-names.json +!/src/utils/purl-to-ghsa.mts +!/src/utils/purl.mts +!/src/utils/registry-manifest.json +!/src/utils/requirements.mts +!/src/utils/sdk.mts +!/src/utils/sdk.test.mts +!/src/utils/self-update.mts +!/test/unit/utils/self-update.test.mts +!/src/utils/semver.mts +!/src/utils/serialize-result-json.mts +!/src/utils/shadow-links.mts +!/src/utils/socket-json.mts +!/src/utils/socket-json.test.mts +!/src/utils/socket-package-alert.mts +!/src/utils/socket-url.mts +!/src/utils/sorts.mts +!/src/utils/spawn-sync.mts +!/src/utils/spawn.mts +!/src/utils/spec.mts +!/src/utils/spinner.mts +!/src/utils/streams.mts +!/src/utils/strings.mts +!/src/utils/telemetry/integration.mts +!/src/utils/telemetry/integration.test.mts +!/src/utils/telemetry/service.mts +!/src/utils/telemetry/service.test.mts +!/src/utils/telemetry/types.mts +!/src/utils/terminal-link.mts +!/src/utils/tildify.mts +!/src/utils/translations.mts +!/src/utils/url.mts +!/src/utils/walk-nested-map.mts +!/src/utils/walk-nested-map.test.mts +!/src/utils/words.mts +!/src/utils/yarn-paths.mts +!/src/utils/yarn-version.mts +!/src/vendor/coana-cache.mjs +!/src/yarn-cli.mts +!/taze.config.mts +!/test/blessed-node24-strict.test.mts +!/test/dist-runtime-requires.test.mts +!/test/external-bare-requires.test.mts +!/test/fixtures/commands/cdxgen/package.json +!/test/fixtures/commands/fix/e2e-test-js/package-lock.json +!/test/fixtures/commands/fix/e2e-test-js/package.json +!/test/fixtures/commands/fix/e2e-test-multipm/npm-app/package-lock.json +!/test/fixtures/commands/fix/e2e-test-multipm/npm-app/package.json +!/test/fixtures/commands/fix/e2e-test-multipm/pnpm-app/package.json +!/test/fixtures/commands/fix/e2e-test-multipm/pnpm-app/pnpm-lock.yaml +!/test/fixtures/commands/fix/e2e-test-py/requirements.txt +!/test/fixtures/commands/fix/npm/monorepo/package-lock.json +!/test/fixtures/commands/fix/npm/monorepo/package.json +!/test/fixtures/commands/fix/npm/monorepo/packages/app/package.json +!/test/fixtures/commands/fix/npm/monorepo/packages/lib/package.json +!/test/fixtures/commands/fix/npm/vulnerable-deps/package-lock.json +!/test/fixtures/commands/fix/npm/vulnerable-deps/package.json +!/test/fixtures/commands/fix/pnpm/monorepo/package.json +!/test/fixtures/commands/fix/pnpm/monorepo/packages/app/package.json +!/test/fixtures/commands/fix/pnpm/monorepo/packages/lib/package.json +!/test/fixtures/commands/fix/pnpm/monorepo/pnpm-lock.yaml +!/test/fixtures/commands/fix/pnpm/vulnerable-deps/package.json +!/test/fixtures/commands/fix/pnpm/vulnerable-deps/pnpm-lock.yaml +!/test/fixtures/commands/fix/yarn/monorepo/package.json +!/test/fixtures/commands/fix/yarn/monorepo/packages/app/package.json +!/test/fixtures/commands/fix/yarn/monorepo/packages/lib/package.json +!/test/fixtures/commands/fix/yarn/monorepo/yarn.lock +!/test/fixtures/commands/fix/yarn/vulnerable-deps/package.json +!/test/fixtures/commands/fix/yarn/vulnerable-deps/yarn.lock +!/test/fixtures/commands/json/socket.json +!/test/fixtures/commands/manifest/conda/environment.yml +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/build.gradle +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/dual-marker-dir/pom.xml +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/nested-gradle/build.gradle +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleA/pom.xml +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/independent-submodule/pom.xml +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/moduleB/pom.xml +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/reactor/pom.xml +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/build.gradle.kts +!/test/fixtures/commands/manifest/dynamic-sbom-inference/monorepo/standalone-gradle/settings.gradle.kts +!/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/app-dev.pom.xml +!/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/bare/pom +!/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/other.pom.xml +!/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/pom.xml +!/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/pom.xml.bak +!/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/upper/POM.XML +!/test/fixtures/commands/manifest/dynamic-sbom-inference/pom-names/vendored/lib-1.0.pom +!/test/fixtures/commands/manifest/python/requirements.txt +!/test/fixtures/commands/npm/lacking-typosquat/package-lock.json +!/test/fixtures/commands/npm/lacking-typosquat/package.json +!/test/fixtures/commands/npm/npm10/package-lock.json +!/test/fixtures/commands/npm/npm10/package.json +!/test/fixtures/commands/npm/npm11/package-lock.json +!/test/fixtures/commands/npm/npm11/package.json +!/test/fixtures/commands/npm/npm9/package-lock.json +!/test/fixtures/commands/npm/npm9/package.json +!/test/fixtures/commands/optimize/npm/package-lock.json +!/test/fixtures/commands/optimize/npm/package.json +!/test/fixtures/commands/optimize/pnpm/package.json +!/test/fixtures/commands/optimize/pnpm/pnpm-lock.yaml +!/test/fixtures/commands/optimize/pnpm8/package.json +!/test/fixtures/commands/optimize/pnpm8/pnpm-lock.yaml +!/test/fixtures/commands/optimize/pnpm9/package.json +!/test/fixtures/commands/optimize/pnpm9/pnpm-lock.yaml +!/test/fixtures/commands/optimize/yarn/package.json +!/test/fixtures/commands/optimize/yarn/yarn.lock +!/test/fixtures/commands/patch/npm/.socket/blobs/76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856 +!/test/fixtures/commands/patch/npm/.socket/manifest.json +!/test/fixtures/commands/patch/npm/package-lock.json +!/test/fixtures/commands/patch/npm/package.json +!/test/fixtures/commands/patch/pnpm/.socket/blobs/76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856 +!/test/fixtures/commands/patch/pnpm/.socket/manifest.json +!/test/fixtures/commands/patch/pnpm/package.json +!/test/fixtures/commands/patch/pnpm/pnpm-lock.yaml +!/test/fixtures/commands/patch/yarn/.socket/blobs/76682a9fc3bbe62975176e2541f39a8168877d828d5cad8b56461fc36ac2b856 +!/test/fixtures/commands/patch/yarn/.socket/manifest.json +!/test/fixtures/commands/patch/yarn/package.json +!/test/fixtures/commands/patch/yarn/yarn.lock +!/test/fixtures/commands/scan/npm-test-workspace-mono/.gitignore +!/test/fixtures/commands/scan/npm-test-workspace-mono/package-lock.json +!/test/fixtures/commands/scan/npm-test-workspace-mono/package.json +!/test/fixtures/commands/scan/npm-test-workspace-mono/packages/package-a/package.json +!/test/fixtures/commands/scan/npm-test-workspace-mono/packages/package-b/index.js +!/test/fixtures/commands/scan/npm-test-workspace-mono/packages/package-b/package.json +!/test/fixtures/commands/scan/plain-requirements-txt/requirements.txt +!/test/fixtures/commands/scan/simple-npm/index.js +!/test/fixtures/commands/scan/simple-npm/package.json +!/test/fixtures/commands/scan/uv-workspace/packages/api/pyproject.toml +!/test/fixtures/commands/scan/uv-workspace/packages/other/pyproject.toml +!/test/fixtures/commands/scan/uv-workspace/packages/shared/pyproject.toml +!/test/fixtures/commands/scan/uv-workspace/pyproject.toml +!/test/fixtures/commands/scan/uv-workspace/uv.lock +!/test/fixtures/manifest-bazel/bzlmod-only/MODULE.bazel +!/test/fixtures/manifest-bazel/custom-name-bzlmod/MODULE.bazel +!/test/fixtures/manifest-bazel/legacy-only/WORKSPACE +!/test/fixtures/manifest-bazel/legacy-with-load/WORKSPACE +!/test/fixtures/manifest-bazel/legacy-with-load/maven_deps.bzl +!/test/fixtures/manifest-bazel/migration/MODULE.bazel +!/test/fixtures/manifest-bazel/migration/WORKSPACE +!/test/fixtures/manifest-bazel/multi-repo-bzlmod/MODULE.bazel +!/test/fixtures/manifest-bazel/native/bzlmod/.bazelversion +!/test/fixtures/manifest-bazel/native/bzlmod/BUILD.bazel +!/test/fixtures/manifest-bazel/native/bzlmod/MODULE.bazel +!/test/fixtures/manifest-bazel/native/maven.expected.json +!/test/fixtures/manifest-bazel/native/workspace/.bazelversion +!/test/fixtures/manifest-bazel/native/workspace/BUILD.bazel +!/test/fixtures/manifest-bazel/native/workspace/WORKSPACE +!/test/fixtures/manifest-bazel/python-pypi/requirements.expected.txt +!/test/glob.test.mts +!/test/json-output-validation.mts +!/test/manifest-reachability-assertions.test.mts +!/test/maven-compat-smoke-assertions.test.mts +!/test/mock-auth.mts +!/test/mock-auth.test.mts +!/test/mock-malware-api.mts +!/test/mock-malware-api.test.mts +!/test/package-environment.npm-version.test.mts +!/test/pin-readme-assets.test.mts +!/test/reachability-options.mts +!/test/release-add-unreleased.test.mts +!/test/release-availability.test.mts +!/test/release-approve-npm.test.mts +!/test/release-artifact-download.test.mts +!/test/release-prepare.test.mts +!/test/release-history.test.mts +!/test/release-open-pr.test.mts +!/test/release-npm-policy.test.mts +!/test/release-registry.test.mts +!/test/release-version.test.mts +!/test/release-workflow.test.mts +!/test/run-with-config.mts +!/test/script-run-main.test.mts +!/test/script-scope.test.mts +!/test/script-update.test.mts +!/test/setup-node.test.mts +!/test/setup-uv.test.mts +!/test/smoke.sh +!/test/upload-artifact.test.mts +!/test/unit/scripts/test/apple-signing.test.mts +!/test/utils.mts +!/test/utils.test.mts +!/test/workflow-actions.test.mts +!/translations.json +!/tsconfig.json + +# Local and generated files remain excluded even when they match an allowlist path. /.env /.env.local -/.nvm -/.pnpm-store -/.rollup.cache -/.type-coverage -/.vscode -/coverage -/external -/npm-debug.log -**/.cache -**/dist -**/node_modules +/.env.precommit +/.nvm/ +/.pnpm-store/ +/.rollup.cache/ +/.type-coverage/ +/build/ +/coverage/ +/coverage-e2e/ +/coverage-unit/ +/external/ +**/.cache/ +**/dist/ +**/node_modules/ *.d.ts *.d.ts.map *.tsbuildinfo -test/fixtures/commands/fix/e2e-test-js-temp-* -test/fixtures/commands/fix/e2e-test-py-temp-* - -/.claude/* -!/.claude/agents/ -!/.claude/commands/ -!/.claude/hooks/ -!/.claude/ops/ -!/.claude/settings.json -!/.claude/skills/ - -!/.vscode/extensions.json - -# Generated by the JVM compat fixtures (src/commands/manifest/scripts/test). -# The stub repos and caches are rebuilt on every run; smoke-test-workspaces.sh -# still keeps its Gradle user home inside the fixture directory. +/.repo-map/ +/upstream/ +/docs/fleet/ +/scripts/fleet/ +/test/fleet/ +/.claude/ops/ +/.claude/worktrees/ +/.github/copilot-instructions.md /src/commands/manifest/scripts/test/gradle-compat/.gradle-home/ /src/commands/manifest/scripts/test/gradle-compat/**/.gradle/ /src/commands/manifest/scripts/test/gradle-compat/project/build/ @@ -53,10 +1403,6 @@ test/fixtures/commands/fix/e2e-test-py-temp-* /src/commands/manifest/scripts/test/sbt-compat/relocation/records.tsv /src/commands/manifest/scripts/test/sbt-compat/relocation/scala-version.sbt /src/commands/manifest/scripts/test/sbt-compat/relocation/target/ - -# Generated when the dynamic SBOM inference tests walk their fixtures -# (test/fixtures/commands/manifest/dynamic-sbom-inference). Those fixtures are -# build-tool marker files only, so any build output under them is debris. /test/fixtures/commands/manifest/dynamic-sbom-inference/**/.gradle/ /test/fixtures/commands/manifest/dynamic-sbom-inference/**/build/ /test/fixtures/commands/manifest/dynamic-sbom-inference/**/target/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000..c236ef7a7c --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +# Avoid advisory-feed lookups as a side effect of npm install. +audit=false diff --git a/CLAUDE.md b/CLAUDE.md index 99320a5cb5..c886450321 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,10 +122,10 @@ Each command follows a consistent pattern: ### Configuration Files - **`biome.json`** - Biome formatter and linter configuration -- **`vitest.config.mts`** - Vitest test runner configuration +- **`.config/vitest.config.mts`** - Vitest test runner configuration - **`eslint.config.js`** - ESLint configuration - **`tsconfig.json`** - Main TypeScript configuration -- **`tsconfig.dts.json`** - TypeScript configuration for type definitions +- **`.config/tsconfig.dts.json`** - TypeScript configuration for type definitions - **`knip.json`** - Knip unused code detection configuration ### Shadow Binaries @@ -162,7 +162,7 @@ Validate all three bindings after a workflow or environment rename. - Dispatch with `mode: release-pr` and `dry-run: true` (the default) to see which version the next release would get. Nothing is written. - Dispatch with `mode: release-pr` and `dry-run: false` to open the release - PR. `scripts/release/bump.mts` picks the version and writes `package.json` + + PR. `scripts/release/prepare.mts` picks the version and writes `package.json` + `CHANGELOG.md`, and `scripts/release/open-release-pr.mts` commits them via the release App onto `npm-publish-v` and opens a `chore(release): X.Y.Z` PR into `v1.x`. Review it and squash-merge it. diff --git a/README.md b/README.md index 62f54c2d8f..5b5e47e27d 100644 --- a/README.md +++ b/README.md @@ -152,16 +152,19 @@ Supports version 2 format with `projectIgnorePaths` for excluding files from rep ### Environment variables +- `NO_UPDATE_NOTIFIER` - Disable the startup update check and notice +- `SOCKET_CLI_ACCEPT_RISKS` - Accept npm/npx risks - `SOCKET_CLI_API_TOKEN` - Socket API token - `SOCKET_CLI_CONFIG` - JSON configuration object +- `SOCKET_CLI_FORCE_UPDATE_NOTIFIER` - Bypass the startup update-check cache - `SOCKET_CLI_GITHUB_API_URL` - GitHub API base URL +- `SOCKET_CLI_GITHUB_TOKEN` - GitHub token with repo access (alias: `GITHUB_TOKEN`) - `SOCKET_CLI_GIT_USER_EMAIL` - Git user email (default: `github-actions[bot]@users.noreply.github.com`) - `SOCKET_CLI_GIT_USER_NAME` - Git user name (default: `github-actions[bot]`) -- `SOCKET_CLI_GITHUB_TOKEN` - GitHub token with repo access (alias: `GITHUB_TOKEN`) - `SOCKET_CLI_NO_API_TOKEN` - Disable default API token - `SOCKET_CLI_NPM_PATH` - Path to npm directory - `SOCKET_CLI_ORG_SLUG` - Socket organization slug -- `SOCKET_CLI_ACCEPT_RISKS` - Accept npm/npx risks +- `SOCKET_CLI_SOCKET_PATCH_PATH` - Path to an executable socket-patch binary for standalone builds - `SOCKET_CLI_VIEW_ALL_RISKS` - Show all npm/npx risks ### See also diff --git a/eslint.config.js b/eslint.config.js index fd6393a58f..237fb1976e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -142,7 +142,7 @@ const sharedRulesForNode = { 'test', 'test.describe', ], - version: constants.maintainedNodeVersions.current, + version: '22.19.0', }, ], 'n/prefer-node-protocol': 'error', @@ -216,17 +216,34 @@ module.exports = [ allowDefaultProject: [ // Allow configs. '*.config.mts', + '.config/*.config.mts', + '.github/actions/repo/cache-downloads/*.mts', // Allow paths like src/utils/*.test.mts. 'src/*/*.test.mts', // Allow paths like src/commands/optimize/*.test.mts. 'src/*/*/*.test.mts', // Allow paths like src/commands/manifest/bazel/*.test.mts. 'src/*/*/*/*.test.mts', + 'test/unit/*.test.mts', 'test/*.mts', 'test/src/utils/*.test.mts', 'test/src/commands/logout/*.test.mts', + 'test/unit/utils/*.test.mts', + 'test/unit/github/actions/repo/cache-downloads/*.test.mts', + 'test/lib/native/*.mts', + 'test/lib/optimize-svg.mts', + 'test/lib/terminal/*.mts', + 'test/unit/lib/*.test.mts', + 'test/unit/lib/native/*.test.mts', + 'test/unit/lib/terminal/*.test.mts', + 'test/unit/sea/*.test.mts', + 'test/unit/sea/build/*.test.mts', + 'test/native/commands/*.native.test.mts', + 'test/unit/scripts/test/*.test.mts', + 'test/unit/scripts/*.test.mts', // Allow loose one-off scripts. 'scripts/*.mts', + 'scripts/test/*.mts', ], defaultProject: 'tsconfig.json', tsconfigRootDir: rootPath, diff --git a/package.json b/package.json index 3d4e1ff2cf..e18571658a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "socket", - "version": "1.15.0", + "version": "2.1.0-rc.0", + "publishConfig": { + "tag": "rc-2.1.0" + }, "description": "CLI for Socket.dev", "contentPolicy": { "class": "dual-use" @@ -38,16 +41,22 @@ "build": "pnpm build:dist", "build:dist": "pnpm build:dist:src && pnpm build:dist:types", "build:dist:src": "run-p -c clean:dist clean:external && dotenvx -q run -f .env.local -- rollup -c .config/rollup.dist.config.mjs", - "build:dist:types": "pnpm clean:dist:types && tsgo --project tsconfig.dts.json", + "build:dist:types": "pnpm clean:dist:types && tsgo --project .config/tsconfig.dts.json", "build:dotnet-tool": "bash src/commands/manifest/scripts/dotnet-tool/build-tool.sh", "build:maven-extension": "bash src/commands/manifest/scripts/maven-extension/build-jar.sh", "build:sea": "node src/sea/build-sea.mts", + "build:package-matrix": "node scripts/build-package-matrix.mts", + "test:native": "node scripts/test/native.mts", + "test:native:build": "node scripts/test/native.mts --build", + "snapshots": "node scripts/test/snapshots.mts", + "snapshots:update": "node scripts/test/snapshots.mts --update", + "snapshots:update:dep": "node scripts/test/snapshots.mts --update --dep", "build:sea:internal:bootstrap": "rollup -c .config/rollup.sea.config.mjs", "check": "node scripts/check.mts", "check:lint": "dotenvx -q run -f .env.local -- eslint --report-unused-disable-directives .", "check:tsc": "run-s check:tsc:*", "check:tsc:src": "tsgo", - "check:tsc:scripts": "tsgo --project tsconfig.scripts.json", + "check:tsc:scripts": "tsgo --project .config/tsconfig.scripts.json", "check-ci": "pnpm check:lint", "ci:setup-uv": "node scripts/ci/setup-uv.mts", "coverage": "run-s coverage:*", @@ -81,24 +90,28 @@ "prepare": "dotenvx -q run -f .env.local -- husky", "bs": "dotenvx -q run -f .env.local -- pnpm build:dist:src; pnpm exec socket --", "s": "dotenvx -q run -f .env.local -- pnpm exec socket --", - "e2e-tests": "dotenvx -q run -f .env.test -- vitest run --config vitest.e2e.config.mts", - "test": "pnpm run check --all && run-s test:*", + "e2e-tests": "dotenvx -q run -f .env.test -- vitest run --config .config/vitest.e2e.config.mts", + "integration-tests": "node scripts/test/integration.mts", + "test": "pnpm run check --all && run-s test:prepare test:unit integration-tests e2e-tests", "test:prepare": "dotenvx -q run -f .env.test -- pnpm build && del-cli 'test/**/node_modules'", - "test:unit": "dotenvx -q run -f .env.test -- vitest run", - "test:unit:update": "dotenvx -q run -f .env.test -- vitest run --update", - "test:unit:coverage": "dotenvx -q run -f .env.test -- vitest run --coverage", - "test-ci": "run-s test:*", + "test:unit": "dotenvx -q run -f .env.test -- vitest run --config .config/vitest.config.mts", + "test:unit:update": "dotenvx -q run -f .env.test -- vitest run --config .config/vitest.config.mts --update", + "test:unit:coverage": "dotenvx -q run -f .env.test -- vitest run --config .config/vitest.config.mts --coverage", + "test-ci": "run-s test:prepare test:unit integration-tests", "testu": "dotenvx -q run -f .env.testu -- run-s test:prepare; pnpm test:unit:update --", "testuf": "dotenvx -q run -f .env.testu -- pnpm test:unit:update --", "update": "node scripts/update.mts", "release:preflight": "node scripts/release/preflight.mts", + "approve:npm": "node scripts/release/approve-npm.mts", "check:actions": "node scripts/ci/check-actions.mts" }, "dependencies": { "@socketsecurity/socket-patch": "2.0.0", + "cross-spawn": "7.0.6", "form-data": "4.0.6" }, "devDependencies": { + "@actions/cache": "6.3.0", "@babel/core": "7.29.6", "@babel/plugin-proposal-export-default-from": "7.27.1", "@babel/plugin-transform-export-namespace-from": "7.27.1", @@ -134,8 +147,11 @@ "@socketsecurity/config": "3.0.1", "@socketsecurity/registry": "1.1.17", "@socketsecurity/sdk": "4.0.3", + "@termless/core": "0.11.0", + "@termless/xtermjs": "0.11.0", "@types/blessed": "0.1.25", "@types/cmd-shim": "5.0.2", + "@types/cross-spawn": "6.0.6", "@types/js-yaml": "4.0.9", "@types/mdast": "4.0.4", "@types/micromatch": "4.0.9", @@ -180,6 +196,7 @@ "micromatch": "4.0.8", "mock-fs": "5.5.0", "nock": "14.0.10", + "node-pty": "1.2.0-beta.18", "npm-package-arg": "13.0.0", "npm-run-all2": "8.0.4", "open": "10.2.0", @@ -189,6 +206,7 @@ "postject": "1.0.0-alpha.6", "rollup": "4.50.1", "semver": "7.7.2", + "svgo": "4.1.0", "synp": "1.9.14", "tar": "7.5.16", "taze": "19.6.0", @@ -227,6 +245,9 @@ ] }, "pnpm": { + "onlyBuiltDependencies": [ + "node-pty" + ], "overrides": { "@octokit/graphql": "$@octokit/graphql", "@octokit/request-error": "$@octokit/request-error", @@ -259,6 +280,7 @@ "isarray": "npm:@socketregistry/isarray@^1.0.7", "lodash": "4.17.21", "meow": "13.2.0", + "minimatch@10.2.6>brace-expansion": "5.0.12", "npm-package-arg": "$npm-package-arg", "packageurl-js": "npm:@socketregistry/packageurl-js@^1.0.9", "path-parse": "npm:@socketregistry/path-parse@^1.0.7", @@ -295,8 +317,8 @@ "bresenham@0.0.3": "patches/bresenham@0.0.3.patch", "lodash@4.17.21": "patches/lodash@4.17.21.patch", "rollup@4.50.1": "patches/rollup@4.50.1.patch", - "tiny-updater@3.5.3": "patches/tiny-updater@3.5.3.patch", - "@socketsecurity/registry@1.1.17": "patches/@socketsecurity__registry@1.1.17.patch" + "@socketsecurity/registry@1.1.17": "patches/@socketsecurity__registry@1.1.17.patch", + "tiny-updater@3.5.3": "patches/tiny-updater@3.5.3.patch" } }, "typeCoverage": { diff --git a/patches/@socketsecurity__registry@1.1.17.patch b/patches/@socketsecurity__registry@1.1.17.patch index 02ab7c999d..77e723a823 100644 --- a/patches/@socketsecurity__registry@1.1.17.patch +++ b/patches/@socketsecurity__registry@1.1.17.patch @@ -1,8 +1,7 @@ diff --git a/lib/packages.d.ts b/lib/packages.d.ts -index 44f7e66fdbc7a99838936910e906bb204f7106eb..83fb429de31700d19c6e0453653e3acae2db98f2 100644 --- a/lib/packages.d.ts +++ b/lib/packages.d.ts -@@ -16,6 +16,7 @@ declare namespace Packages { +@@ -16,6 +16,7 @@ > export class EditablePackageJson extends NPMCliPackageJson { content: Readonly @@ -10,3 +9,48 @@ index 44f7e66fdbc7a99838936910e906bb204f7106eb..83fb429de31700d19c6e0453653e3aca override update: (content: Partial) => this // @ts-ignore TypeScript doesn't like an override with a different return type. override save: (options?: SaveOptions | undefined) => Promise +diff --git a/lib/packages.js b/lib/packages.js +--- a/lib/packages.js ++++ b/lib/packages.js +@@ -222,9 +222,10 @@ + return false + } + +- const format = indent === undefined ? ' ' : indent +- const eol = newline === undefined ? '\n' : newline +- const fileContent = `${JSON.stringify( ++ const format = this._readFileJson?.[identSymbol] ?? indent ?? ' ' ++ const eol = this._readFileJson?.[newlineSymbol] ?? newline ?? '\n' ++ const bom = this._readFileContent.charCodeAt(0) === 0xfeff ? '\uFEFF' : '' ++ const fileContent = bom + `${JSON.stringify( + content, + null, + format +@@ -266,9 +267,10 @@ + return false + } + +- const format = indent === undefined ? ' ' : indent +- const eol = newline === undefined ? '\n' : newline +- const fileContent = `${JSON.stringify( ++ const format = this._readFileJson?.[identSymbol] ?? indent ?? ' ' ++ const eol = this._readFileJson?.[newlineSymbol] ?? newline ?? '\n' ++ const bom = this._readFileContent.charCodeAt(0) === 0xfeff ? '\uFEFF' : '' ++ const fileContent = bom + `${JSON.stringify( + content, + null, + format +@@ -315,9 +317,10 @@ + return false + } + +- const format = indent === undefined ? ' ' : indent +- const eol = newline === undefined ? '\n' : newline +- const fileContent = `${JSON.stringify( ++ const format = this._readFileJson?.[identSymbol] ?? indent ?? ' ' ++ const eol = this._readFileJson?.[newlineSymbol] ?? newline ?? '\n' ++ const bom = this._readFileContent.charCodeAt(0) === 0xfeff ? '\uFEFF' : '' ++ const fileContent = bom + `${JSON.stringify( + content, + null, + format diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 354dec21c4..6253e964d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,7 @@ overrides: isarray: npm:@socketregistry/isarray@^1.0.7 lodash: 4.17.21 meow: 13.2.0 + minimatch@10.2.6>brace-expansion: 5.0.12 npm-package-arg: 13.0.0 packageurl-js: npm:@socketregistry/packageurl-js@^1.0.9 path-parse: npm:@socketregistry/path-parse@^1.0.7 @@ -63,7 +64,7 @@ patchedDependencies: hash: 4d412c02fa3df1b1f5b29b135bd3f0997f85248ceb3a578d01e7f40fba27c21b path: patches/@rollup__plugin-commonjs@28.0.6.patch '@socketsecurity/registry@1.1.17': - hash: 0f0c3a9374c7211d109655f6226d442bdf9ea59cdf52871f4e92b7ae6d726b8b + hash: 46f41d951e8ffbe5c0b41d6aa30a0281fe032e0bfd97d899a1f4766285bfb820 path: patches/@socketsecurity__registry@1.1.17.patch ansi-term@0.0.2: hash: 06bb5127b7689d6ab2ea833f9617b2c3fbe9fe0048ce1c6b59b81f7e25ccbccb @@ -112,10 +113,16 @@ importers: '@socketsecurity/socket-patch': specifier: 2.0.0 version: 2.0.0 + cross-spawn: + specifier: 7.0.6 + version: 7.0.6 form-data: specifier: 4.0.6 version: 4.0.6 devDependencies: + '@actions/cache': + specifier: 6.3.0 + version: 6.3.0 '@babel/core': specifier: 7.29.6 version: 7.29.6 @@ -217,16 +224,25 @@ importers: version: 3.0.1 '@socketsecurity/registry': specifier: 1.1.17 - version: 1.1.17(patch_hash=0f0c3a9374c7211d109655f6226d442bdf9ea59cdf52871f4e92b7ae6d726b8b) + version: 1.1.17(patch_hash=46f41d951e8ffbe5c0b41d6aa30a0281fe032e0bfd97d899a1f4766285bfb820) '@socketsecurity/sdk': specifier: 4.0.3 version: 4.0.3 + '@termless/core': + specifier: 0.11.0 + version: 0.11.0(node-pty@1.2.0-beta.18) + '@termless/xtermjs': + specifier: 0.11.0 + version: 0.11.0(@termless/core@0.11.0(node-pty@1.2.0-beta.18)) '@types/blessed': specifier: 0.1.25 version: 0.1.25 '@types/cmd-shim': specifier: 5.0.2 version: 5.0.2 + '@types/cross-spawn': + specifier: 6.0.6 + version: 6.0.6 '@types/js-yaml': specifier: 4.0.9 version: 4.0.9 @@ -359,6 +375,9 @@ importers: nock: specifier: 14.0.10 version: 14.0.10 + node-pty: + specifier: 1.2.0-beta.18 + version: 1.2.0-beta.18 npm-package-arg: specifier: 13.0.0 version: 13.0.0 @@ -386,6 +405,9 @@ importers: semver: specifier: 7.7.2 version: 7.7.2 + svgo: + specifier: 4.1.0 + version: 4.1.0 synp: specifier: 1.9.14 version: 1.9.14 @@ -417,7 +439,7 @@ importers: specifier: 3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.1)(jiti@2.6.1)(yaml@2.8.1) which: - specifier: =5.0.0 + specifier: '=5.0.0' version: 5.0.0 yaml: specifier: 2.8.1 @@ -434,6 +456,24 @@ importers: packages: + '@actions/cache@6.3.0': + resolution: {integrity: sha512-SJKaVMqTgetQgJF0wifi5N2+2mffCLy/D4w/3sJtRanBBGECdi1/OD0wswyLVmqEmPbYB/rVJk41Qn/xEbFjAw==} + + '@actions/core@3.0.1': + resolution: {integrity: sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==} + + '@actions/exec@3.0.0': + resolution: {integrity: sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==} + + '@actions/glob@0.7.0': + resolution: {integrity: sha512-+7s3wM+cXapDLmLL1NVWHawqcJOZzXZy2df/VhNn8DnZtS/x83iTCKaUn9F0llur4h3CII0AilvKKH4CMPL8Gw==} + + '@actions/http-client@4.0.1': + resolution: {integrity: sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==} + + '@actions/io@3.0.2': + resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -463,6 +503,65 @@ packages: resolution: {integrity: sha512-pJcqUz0FfpJP7yGLcCvlqm9mQuHFPF2xh8Af1B9Z9hr0dBxEJ8MPYrO9Nqst+TZFEskMyPE+x4KVgFoXc+OQgg==} engines: {node: '>=20'} + '@azure/abort-controller@2.2.0': + resolution: {integrity: sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==} + engines: {node: '>=22.0.0'} + + '@azure/core-auth@1.11.0': + resolution: {integrity: sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==} + engines: {node: '>=22.0.0'} + + '@azure/core-client@1.11.2': + resolution: {integrity: sha512-ajuIU1aMoTs/ERYrKnIqQrV5Vz3RL8mwDC5RdedIXUvR7pi6cWzeYmt0LfNz6+W91CuCO4WJG0zA6+PhU1X+hw==} + engines: {node: '>=22.0.0'} + + '@azure/core-http-compat@2.5.0': + resolution: {integrity: sha512-BoSmXPx2er1Ai+wKlDvj29jIQespCNBwEmKyZVHO2kEFsWbGjAjwMCGzug3DJM5/QYIV3vej0S1zcU5bq9fa8w==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@azure/core-client': ^1.10.0 + '@azure/core-rest-pipeline': ^1.22.0 + + '@azure/core-lro@2.7.2': + resolution: {integrity: sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==} + engines: {node: '>=18.0.0'} + + '@azure/core-paging@1.7.0': + resolution: {integrity: sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g==} + engines: {node: '>=22.0.0'} + + '@azure/core-rest-pipeline@1.23.0': + resolution: {integrity: sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==} + engines: {node: '>=20.0.0'} + + '@azure/core-rest-pipeline@1.25.1': + resolution: {integrity: sha512-fTIA3CAm/cdODqiXkO6jYOIkU2Ylld7vq65KGc70gTwl12sLQsC8v7I5t6tp2N2CfUONzZBXyeFw7KEu6Ks2zg==} + engines: {node: '>=22.0.0'} + + '@azure/core-tracing@1.4.0': + resolution: {integrity: sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==} + engines: {node: '>=22.0.0'} + + '@azure/core-util@1.14.0': + resolution: {integrity: sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==} + engines: {node: '>=22.0.0'} + + '@azure/core-xml@1.6.1': + resolution: {integrity: sha512-u+SvC+010CAdbqfGJki2Ppk8LVReOpSRM/xbNqHe53IXgXEWkq4lJkCw3cBvHN8OtGAusQ2eI/AGfFmreaHFRw==} + engines: {node: '>=22.0.0'} + + '@azure/logger@1.4.0': + resolution: {integrity: sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==} + engines: {node: '>=22.0.0'} + + '@azure/storage-blob@12.31.0': + resolution: {integrity: sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg==} + engines: {node: '>=20.0.0'} + + '@azure/storage-common@12.5.0': + resolution: {integrity: sha512-bttzuhQiCIwrkzjPDA+AtAR7dg19L/CC6ztcqJ5LfvWpXuys9mHp0UQ0udYnoUvv9SCT9KTR5kqFvFr0e6k0lQ==} + engines: {node: '>=22.0.0'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -1133,6 +1232,81 @@ packages: resolution: {integrity: sha512-sURvQbbKsq5f8INV54YJgJEdk8oxBanqkTiXXd33rKmofFCwZLhLRszPduMZ9TA9b8/1CHc/IJmOlBHJk2Q5AQ==} engines: {node: '>=18'} + '@napi-rs/canvas-android-arm64@1.0.11': + resolution: {integrity: sha512-dtJS4FQEfH9OwSYLtMrEnj7T8DzBbJc/97LI/OkiWOYpW/iW5t6R/gcxVrUXS8l45gCVpwMufgucRe/pe0ISqA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@1.0.11': + resolution: {integrity: sha512-J/JylWoJTG8OQqZaiqly4IODyAxNofUlZo0wtSBJtHk6AdymbdIkPynAM1JvQaZpPaG5h+msk9QuK8y4i1ooqw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@1.0.11': + resolution: {integrity: sha512-nlecCk3xy06eAGC7krZtzNDm9UNTHKuHQYK8ZYegdjzgSL8anGt/eLJZm86OWaNgZkwQwvDqfSmG8Nqe6MPZNQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.11': + resolution: {integrity: sha512-DKImmG+sTPolE0kGPsYPBxTtOjb3INALp32O2lW2CfSr0bAzSEKKuu+GrQbtilClyoPOahUErS3OrznFDraoWA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@1.0.11': + resolution: {integrity: sha512-55sykACGENj5j1rhj+81AO8JbR9HYgGjRZ7/H+pNr4VE1DvINKYNMTcutk8ZmWxk276jAQvUKJ3+Kdj9GXU3lQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@1.0.11': + resolution: {integrity: sha512-6Jz6jys17yXydO+WdJgu7JhoB4K7/w9sWHByzzTmLjQghUx3TDMWvLv+UcCdnWhg4oprET67EMymnrETdeGNAg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.11': + resolution: {integrity: sha512-lf2XeEebWiUYVOVEjLXxF7dfXrBBSEOBn9AqBh39VYXrPt5cDqI+1t58IFSjn8npqkg5jIOs8TQbjKABbFW1gQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@1.0.11': + resolution: {integrity: sha512-iDapw7nFSE5tsmQxcH2+KotJhyfVPmHd6vUOQN/Em+8WNbkZJwJ/rDowlJMFnMl0SYSDSfgKCNUGi6Sn5++Smg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@1.0.11': + resolution: {integrity: sha512-26q26AyITo3CGZX4HWFIMgBMA1lu3YN07gxzBm4tW6Hglq/OVyTP50kDrmWuKybCX6dh3m8Ly+c7YhI+sqCgCQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@1.0.11': + resolution: {integrity: sha512-cUO96IdsfvIj2S+2PNcDSkTcLhjPj8pIOEWRV1Kj70MOP4wnE9zSk7N4C1EKHlFGRcgdR5AkbnBZj0dfHK9nbQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@1.0.11': + resolution: {integrity: sha512-DFYp/ZQRdKunI/LPc0UkRXG1vlNjJKOJf8O8Ctb8+wm2Nt2ELg9Eamc31D2VoakkfGo8L+z0Iejz6WtFbQwyGw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@1.0.11': + resolution: {integrity: sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1151,6 +1325,9 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@nodable/entities@3.1.0': + resolution: {integrity: sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1563,9 +1740,95 @@ packages: resolution: {integrity: sha512-blFO4Ws97tWv/SNE6N39ZdGmZBrocXnBOfVp0ln4kELmns4pGPZizqyRtR8EjfOLMLstbmNCTReBoDvLz1isVg==} engines: {node: '>=18.12'} + '@protobuf-ts/runtime-rpc@2.11.1': + resolution: {integrity: sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ==} + + '@protobuf-ts/runtime@2.11.1': + resolution: {integrity: sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==} + '@quansync/fs@0.1.5': resolution: {integrity: sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA==} + '@resvg/resvg-js-android-arm-eabi@2.6.2': + resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@resvg/resvg-js-android-arm64@2.6.2': + resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@resvg/resvg-js-darwin-arm64@2.6.2': + resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@resvg/resvg-js-darwin-x64@2.6.2': + resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js@2.6.2': + resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} + engines: {node: '>= 10'} + '@rollup/plugin-babel@6.0.4': resolution: {integrity: sha512-YF7Y52kFdFT/xVSuVdjkV5ZdX/3YtmX0QulG+x0taQOtJdHYzVU61aSSkAgVJ7NOv6qPkIYiJSgSWWN/DM5sGw==} engines: {node: '>=14.0.0'} @@ -1908,6 +2171,28 @@ packages: resolution: {integrity: sha512-X1Iui3FUNZP18EUvysTHxt+Avu2nlVzyf90YM8OYgP6SGzTzzX/0JgObfO1AQQDzuZtNNz29bVh8h5R97JrjxA==} engines: {node: '>=8'} + '@termless/core@0.11.0': + resolution: {integrity: sha512-/Yqqcg5rcLHfMnFOstGD4kZB5+XygVtHVGRpjKcG8li72iwaFYuWe9q0bO/SKMa65LOXGfjsouRRkA7tdnBv+g==} + engines: {bun: '>=1.0.0', node: '>=23.6.0'} + peerDependencies: + '@termless/ghostty': ^0.11.0 + node-pty: '>=1.0.0' + peerDependenciesMeta: + '@termless/ghostty': + optional: true + node-pty: + optional: true + + '@termless/xtermjs@0.11.0': + resolution: {integrity: sha512-0GL6F/65WIE11WKMBcw8nCb2fU+8yLfQJ1QE/0jH4NaFd7UROTbR8pdwzkaAZ2u9YVGXA1GFYAr+jBV/5EF7Uw==} + engines: {bun: '>=1.0.0', node: '>=23.6.0'} + peerDependencies: + '@termless/core': ^0.11.0 + silvery: '>=0.27.0 <1.0.0' + peerDependenciesMeta: + silvery: + optional: true + '@tufjs/canonical-json@2.0.0': resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} engines: {node: ^16.14.0 || >=18.0.0} @@ -1916,6 +2201,9 @@ packages: resolution: {integrity: sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==} engines: {node: ^20.17.0 || >=22.9.0} + '@twemoji/svg@15.0.0': + resolution: {integrity: sha512-ZSPef2B6nBaYnfgdTbAy4jgW95o7pi2xPGwGCU+WMTxo7J6B1lMPTWwSq/wTuiMq+N0khQ90CcvYp1wFoQpo/w==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1934,6 +2222,9 @@ packages: '@types/cmd-shim@5.0.2': resolution: {integrity: sha512-Pnee6lEDnxqVmV0SBKGmAFKCmdZtI7sIYI3qCo5iNIZ1SYNspDFwWVJll8F3zvl0Ap/a/XllHiaV8sA9UTjdeA==} + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -2117,6 +2408,10 @@ packages: resolution: {integrity: sha512-gHhW7qbRRtbkxv5pEmmIGYUMGBkBZsfgDABLr5izOZY1qDP7ranAhIgfjjFF0gvYotYvW8dO4bArytwMqKysdg==} hasBin: true + '@typespec/ts-http-runtime@0.3.10': + resolution: {integrity: sha512-vddvvtZ7sejSlCv+OIkn2ztjB1O31Fc2JsxI/YK725r3elXXlF/J3o3lbXJFRhoeWRWK1tjdZ+GTi6Fdtrp+EA==} + engines: {node: '>=22.0.0'} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} cpu: [arm] @@ -2258,6 +2553,12 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@xterm/addon-unicode11@0.9.0': + resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==} + + '@xterm/headless@6.0.0': + resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} + '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} @@ -2303,6 +2604,10 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agent-base@9.0.0: + resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} + engines: {node: '>= 20'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -2362,6 +2667,9 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2397,6 +2705,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2439,6 +2751,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2691,15 +3007,34 @@ packages: css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -3048,6 +3383,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + execa@2.1.0: resolution: {integrity: sha512-Y/URAVapfbYy2Xp/gb6A0E7iR8xeqOCXsuuaoMn7A5PzrXUK84E1gyiEfq0wQd/GHA6GsoHWwhNq8anb0mleIw==} engines: {node: ^8.12.0 || >=9.7.0} @@ -3089,6 +3428,13 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-builder@1.3.1: + resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==} + + fast-xml-parser@5.12.0: + resolution: {integrity: sha512-/pXxRBtwxY9w8z7YbgsvIPPcPvFDsTbIkWwTQJhzNJPokj+TbN6ftmcv4j/pEQDvOCNnKGDzuk5Bo2oogIOiFg==} + hasBin: true + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -3198,6 +3544,9 @@ packages: get-tsconfig@4.12.0: resolution: {integrity: sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==} + gifenc@1.0.3: + resolution: {integrity: sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -3309,6 +3658,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-proxy-agent@9.1.0: + resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} + engines: {node: '>= 20'} + http2-wrapper@2.2.1: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} @@ -3317,6 +3670,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + https-proxy-agent@9.1.0: + resolution: {integrity: sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==} + engines: {node: '>= 20'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -3450,6 +3807,9 @@ packages: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} + is-unsafe@2.0.3: + resolution: {integrity: sha512-ZBllczCCO267vgdFb6Tq3pv+LhHaCiGMdr+ZsuL+OopruAg7S+3ZDs1vD0RPC0uSrkjzbraxaGjtX1YBxjPeFQ==} + is-wsl@3.1.0: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} @@ -3715,6 +4075,12 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -3869,6 +4235,10 @@ packages: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -4000,6 +4370,9 @@ packages: resolution: {integrity: sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==} engines: {node: '>=10'} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-addon-api@8.5.0: resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} engines: {node: ^18 || ^20 || >= 21} @@ -4020,6 +4393,9 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + node-pty@1.2.0-beta.18: + resolution: {integrity: sha512-3Rej3788OnasAwoSmTZhlCeUraJ9CQldtL5b571b9+3Nv47+PaxA5VPIGOOs+16OYQePTrC1Lrs8a9/df5WPRg==} + node-releases@2.0.23: resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} @@ -4219,6 +4595,9 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4259,6 +4638,10 @@ packages: resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4325,6 +4708,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.65.0: + resolution: {integrity: sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.65.0: + resolution: {integrity: sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==} + engines: {node: '>=20'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -4396,6 +4789,15 @@ packages: resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==} engines: {node: '>=18'} + proxy-agent-negotiate@1.1.0: + resolution: {integrity: sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==} + engines: {node: '>= 20'} + peerDependencies: + kerberos: ^2.0.0 + peerDependenciesMeta: + kerberos: + optional: true + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -4532,6 +4934,10 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -4733,6 +5139,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.5.1: + resolution: {integrity: sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==} + supports-color@2.0.0: resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==} engines: {node: '>=0.8.0'} @@ -4753,6 +5162,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} + engines: {node: '>=16'} + hasBin: true + synp@1.9.14: resolution: {integrity: sha512-0e4u7KtrCrMqvuXvDN4nnHSEQbPlONtJuoolRWzut0PfuT2mEOvIFnYFHEpn5YPIOv7S5Ubher0b04jmYRQOzQ==} hasBin: true @@ -4871,6 +5285,10 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4976,6 +5394,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + upng-js@2.1.0: + resolution: {integrity: sha512-d3xzZzpMP64YkjP5pr8gNyvBt7dLk/uGI67EctzDuVp4lCZyVMo0aJO6l/VDlgbInJYDY6cnClLoBp29eKWI6g==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -5177,6 +5598,10 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -5245,6 +5670,42 @@ packages: snapshots: + '@actions/cache@6.3.0': + dependencies: + '@actions/core': 3.0.1 + '@actions/exec': 3.0.0 + '@actions/glob': 0.7.0 + '@actions/http-client': 4.0.1 + '@actions/io': 3.0.2 + '@azure/core-rest-pipeline': 1.23.0 + '@azure/storage-blob': 12.31.0 + '@protobuf-ts/runtime-rpc': 2.11.1 + semver: 7.7.2 + transitivePeerDependencies: + - kerberos + - supports-color + + '@actions/core@3.0.1': + dependencies: + '@actions/exec': 3.0.0 + '@actions/http-client': 4.0.1 + + '@actions/exec@3.0.0': + dependencies: + '@actions/io': 3.0.2 + + '@actions/glob@0.7.0': + dependencies: + '@actions/core': 3.0.1 + minimatch: 10.2.6 + + '@actions/http-client@4.0.1': + dependencies: + tunnel: 0.0.6 + undici: 6.21.3 + + '@actions/io@3.0.2': {} + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -5289,6 +5750,146 @@ snapshots: - supports-color optional: true + '@azure/abort-controller@2.2.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.11.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - kerberos + - supports-color + + '@azure/core-client@1.11.2': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-rest-pipeline': 1.23.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + tslib: 2.8.1 + transitivePeerDependencies: + - kerberos + - supports-color + + '@azure/core-http-compat@2.5.0(@azure/core-client@1.11.2)(@azure/core-rest-pipeline@1.23.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-client': 1.11.2 + '@azure/core-rest-pipeline': 1.23.0 + + '@azure/core-http-compat@2.5.0(@azure/core-client@1.11.2)(@azure/core-rest-pipeline@1.25.1)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-client': 1.11.2 + '@azure/core-rest-pipeline': 1.25.1 + + '@azure/core-lro@2.7.2': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + tslib: 2.8.1 + transitivePeerDependencies: + - kerberos + - supports-color + + '@azure/core-paging@1.7.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-rest-pipeline@1.23.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + '@typespec/ts-http-runtime': 0.3.10 + tslib: 2.8.1 + transitivePeerDependencies: + - kerberos + - supports-color + + '@azure/core-rest-pipeline@1.25.1': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + '@typespec/ts-http-runtime': 0.3.10 + tslib: 2.8.1 + transitivePeerDependencies: + - kerberos + - supports-color + + '@azure/core-tracing@1.4.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.14.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@typespec/ts-http-runtime': 0.3.10 + tslib: 2.8.1 + transitivePeerDependencies: + - kerberos + - supports-color + + '@azure/core-xml@1.6.1': + dependencies: + fast-xml-parser: 5.12.0 + tslib: 2.8.1 + + '@azure/logger@1.4.0': + dependencies: + '@typespec/ts-http-runtime': 0.3.10 + tslib: 2.8.1 + transitivePeerDependencies: + - kerberos + - supports-color + + '@azure/storage-blob@12.31.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-client': 1.11.2 + '@azure/core-http-compat': 2.5.0(@azure/core-client@1.11.2)(@azure/core-rest-pipeline@1.23.0) + '@azure/core-lro': 2.7.2 + '@azure/core-paging': 1.7.0 + '@azure/core-rest-pipeline': 1.23.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/core-xml': 1.6.1 + '@azure/logger': 1.4.0 + '@azure/storage-common': 12.5.0(@azure/core-client@1.11.2) + events: 3.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - kerberos + - supports-color + + '@azure/storage-common@12.5.0(@azure/core-client@1.11.2)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-http-compat': 2.5.0(@azure/core-client@1.11.2)(@azure/core-rest-pipeline@1.25.1) + '@azure/core-rest-pipeline': 1.25.1 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + events: 3.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@azure/core-client' + - kerberos + - supports-color + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -6000,6 +6601,54 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 + '@napi-rs/canvas-android-arm64@1.0.11': + optional: true + + '@napi-rs/canvas-darwin-arm64@1.0.11': + optional: true + + '@napi-rs/canvas-darwin-x64@1.0.11': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.11': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@1.0.11': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@1.0.11': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.11': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@1.0.11': + optional: true + + '@napi-rs/canvas-linux-x64-musl@1.0.11': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@1.0.11': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@1.0.11': + optional: true + + '@napi-rs/canvas@1.0.11': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.11 + '@napi-rs/canvas-darwin-arm64': 1.0.11 + '@napi-rs/canvas-darwin-x64': 1.0.11 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.11 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.11 + '@napi-rs/canvas-linux-arm64-musl': 1.0.11 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.11 + '@napi-rs/canvas-linux-x64-gnu': 1.0.11 + '@napi-rs/canvas-linux-x64-musl': 1.0.11 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.11 + '@napi-rs/canvas-win32-x64-msvc': 1.0.11 + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.5.0 @@ -6022,6 +6671,8 @@ snapshots: '@noble/hashes@1.8.0': {} + '@nodable/entities@3.1.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6501,10 +7152,68 @@ snapshots: '@pnpm/util.lex-comparator@3.0.2': {} + '@protobuf-ts/runtime-rpc@2.11.1': + dependencies: + '@protobuf-ts/runtime': 2.11.1 + + '@protobuf-ts/runtime@2.11.1': {} + '@quansync/fs@0.1.5': dependencies: quansync: 0.2.11 + '@resvg/resvg-js-android-arm-eabi@2.6.2': + optional: true + + '@resvg/resvg-js-android-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-x64@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js@2.6.2': + optionalDependencies: + '@resvg/resvg-js-android-arm-eabi': 2.6.2 + '@resvg/resvg-js-android-arm64': 2.6.2 + '@resvg/resvg-js-darwin-arm64': 2.6.2 + '@resvg/resvg-js-darwin-x64': 2.6.2 + '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 + '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 + '@resvg/resvg-js-linux-arm64-musl': 2.6.2 + '@resvg/resvg-js-linux-x64-gnu': 2.6.2 + '@resvg/resvg-js-linux-x64-musl': 2.6.2 + '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 + '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 + '@resvg/resvg-js-win32-x64-msvc': 2.6.2 + optional: true + '@rollup/plugin-babel@6.0.4(@babel/core@7.29.6)(rollup@4.50.1(patch_hash=071f391315feb3e71235ac70bfbf18a993f10a53259f3ec37507a614a5645f9f))': dependencies: '@babel/core': 7.29.6 @@ -6698,7 +7407,7 @@ snapshots: pony-cause: 2.1.11 yaml: 2.8.1 - '@socketsecurity/registry@1.1.17(patch_hash=0f0c3a9374c7211d109655f6226d442bdf9ea59cdf52871f4e92b7ae6d726b8b)': {} + '@socketsecurity/registry@1.1.17(patch_hash=46f41d951e8ffbe5c0b41d6aa30a0281fe032e0bfd97d899a1f4766285bfb820)': {} '@socketsecurity/sdk@4.0.3': {} @@ -6749,6 +7458,22 @@ snapshots: '@stroncium/procfs@1.2.1': {} + '@termless/core@0.11.0(node-pty@1.2.0-beta.18)': + optionalDependencies: + '@napi-rs/canvas': 1.0.11 + '@resvg/resvg-js': 2.6.2 + '@twemoji/svg': 15.0.0 + gifenc: 1.0.3 + node-pty: 1.2.0-beta.18 + playwright: 1.65.0 + upng-js: 2.1.0 + + '@termless/xtermjs@0.11.0(@termless/core@0.11.0(node-pty@1.2.0-beta.18))': + dependencies: + '@termless/core': 0.11.0(node-pty@1.2.0-beta.18) + '@xterm/addon-unicode11': 0.9.0 + '@xterm/headless': 6.0.0 + '@tufjs/canonical-json@2.0.0': {} '@tufjs/models@4.1.0': @@ -6756,6 +7481,9 @@ snapshots: '@tufjs/canonical-json': 2.0.0 minimatch: 10.2.4 + '@twemoji/svg@15.0.0': + optional: true + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -6777,6 +7505,10 @@ snapshots: '@types/cmd-shim@5.0.2': {} + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 24.3.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -6994,6 +7726,15 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20250912.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20250912.1 + '@typespec/ts-http-runtime@0.3.10': + dependencies: + http-proxy-agent: 9.1.0 + https-proxy-agent: 9.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - kerberos + - supports-color + '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -7114,6 +7855,10 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@xterm/addon-unicode11@0.9.0': {} + + '@xterm/headless@6.0.0': {} + '@yarnpkg/lockfile@1.1.0': {} '@zkochan/js-yaml@0.0.10': @@ -7145,6 +7890,8 @@ snapshots: agent-base@7.1.4: {} + agent-base@9.0.0: {} + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -7202,6 +7949,8 @@ snapshots: ansis@4.2.0: {} + anynum@1.0.1: {} + argparse@2.0.1: {} assertion-error@2.0.1: {} @@ -7242,6 +7991,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: optional: true @@ -7317,6 +8068,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -7612,10 +8367,34 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css-what@6.2.2: {} + css-what@7.0.0: {} + cssesc@3.0.0: {} + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + debug@2.6.9: dependencies: ms: 2.0.0 @@ -8008,6 +8787,8 @@ snapshots: eventemitter3@5.0.1: {} + events@3.3.0: {} + execa@2.1.0: dependencies: cross-spawn: 7.0.6 @@ -8059,6 +8840,20 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-builder@1.3.1: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.12.0: + dependencies: + '@nodable/entities': 3.1.0 + fast-xml-builder: 1.3.1 + is-unsafe: 2.0.3 + path-expression-matcher: 1.6.2 + strnum: 2.5.1 + xml-naming: 0.3.0 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -8165,6 +8960,9 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + gifenc@1.0.3: + optional: true + github-from-package@0.0.0: optional: true @@ -8295,6 +9093,15 @@ snapshots: transitivePeerDependencies: - supports-color + http-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + http2-wrapper@2.2.1: dependencies: quick-lru: 5.1.1 @@ -8307,6 +9114,15 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + human-signals@2.1.0: {} husky@9.1.7: {} @@ -8403,6 +9219,8 @@ snapshots: is-stream@4.0.1: {} + is-unsafe@2.0.3: {} + is-wsl@3.1.0: dependencies: is-inside-container: 1.0.0 @@ -8780,6 +9598,10 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + media-typer@1.1.0: optional: true @@ -9024,6 +9846,10 @@ snapshots: dependencies: brace-expansion: 2.0.2(patch_hash=eac47f4a81cd7be766bd391c6bf91ac462816eb2f3c5f99270419ac752d6f02d) + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + minimatch@3.1.2: dependencies: brace-expansion: 2.0.2(patch_hash=eac47f4a81cd7be766bd391c6bf91ac462816eb2f3c5f99270419ac752d6f02d) @@ -9152,6 +9978,8 @@ snapshots: semver: 7.7.2 optional: true + node-addon-api@7.1.1: {} + node-addon-api@8.5.0: optional: true @@ -9192,6 +10020,10 @@ snapshots: - supports-color optional: true + node-pty@1.2.0-beta.18: + dependencies: + node-addon-api: 7.1.1 + node-releases@2.0.23: {} node-stream-zip@1.15.0: {} @@ -9453,6 +10285,9 @@ snapshots: transitivePeerDependencies: - supports-color + pako@1.0.11: + optional: true + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -9500,6 +10335,8 @@ snapshots: path-exists@5.0.0: {} + path-expression-matcher@1.6.2: {} + path-key@3.1.1: {} path-name@1.0.0: {} @@ -9556,6 +10393,14 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 + playwright-core@1.65.0: + optional: true + + playwright@1.65.0: + dependencies: + playwright-core: 1.65.0 + optional: true + pluralize@8.0.0: {} png-js@0.1.1: {} @@ -9625,6 +10470,8 @@ snapshots: transitivePeerDependencies: - supports-color + proxy-agent-negotiate@1.1.0: {} + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -9786,6 +10633,8 @@ snapshots: sax@1.4.1: {} + sax@1.6.1: {} + semver@7.7.2: {} setprototypeof@1.2.0: @@ -9980,6 +10829,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@2.5.1: + dependencies: + anynum: 1.0.1 + supports-color@2.0.0: {} supports-color@5.5.0: @@ -9997,6 +10850,16 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svgo@4.1.0: + dependencies: + commander: 11.1.0 + css-select: 6.0.0 + css-tree: 3.2.1 + css-what: 7.0.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.1 + synp@1.9.14: dependencies: '@yarnpkg/lockfile': 1.1.0 @@ -10151,6 +11014,8 @@ snapshots: safe-buffer: '@socketregistry/safe-buffer@1.0.9' optional: true + tunnel@0.0.6: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -10297,6 +11162,11 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + upng-js@2.1.0: + dependencies: + pako: 1.0.11 + optional: true + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -10491,6 +11361,8 @@ snapshots: dependencies: sax: 1.4.1 + xml-naming@0.3.0: {} + xml2js@0.6.2: dependencies: sax: 1.4.1 diff --git a/scripts/build-package-matrix.mts b/scripts/build-package-matrix.mts new file mode 100644 index 0000000000..4e7f558a2e --- /dev/null +++ b/scripts/build-package-matrix.mts @@ -0,0 +1,400 @@ +#!/usr/bin/env node +/* eslint-disable no-await-in-loop -- Package variants and staged files must be processed serially. */ +import { spawnSync } from 'node:child_process' +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { gunzipSync } from 'node:zlib' + +import crossSpawn from 'cross-spawn' +import semver from 'semver' +import { parse as parseYaml } from 'yaml' + +import { isMainModule } from './lib/is-main-module.mts' +import { type ScriptMeta, runMain } from './lib/run-main.mts' +import { SEA_RELEASE_POLICY, SEA_VERSION } from '../src/sea/version.mts' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const DEFAULT_OUTPUT = path.join(ROOT, 'build/package-matrix') +const PRE_RELEASE_PREFIX = `${SEA_RELEASE_POLICY.versionHint}-${SEA_RELEASE_POLICY.identifier}.` +const VARIANTS = [ + { expectedName: 'socket', version: '1.14.1' }, + { expectedName: 'socket' }, + { + expectedName: '@socketsecurity/cli', + version: '1.14.1', + env: { INLINED_SOCKET_CLI_LEGACY_BUILD: '1' }, + }, + { + expectedName: '@socketsecurity/cli-with-sentry', + version: '1.14.1', + env: { INLINED_SOCKET_CLI_SENTRY_BUILD: '1' }, + }, +] as const + +type Variant = { + expectedName: string + version: string + artifact: string + env?: Record +} + +export function currentPrereleaseVersion(version: string): string { + const parsed = semver.parse(version) + if ( + !parsed || + SEA_RELEASE_POLICY.mode !== 'prerelease-only' || + parsed.major !== Number(SEA_RELEASE_POLICY.versionHint.split('.')[0]) || + parsed.minor !== Number(SEA_RELEASE_POLICY.versionHint.split('.')[1]) || + parsed.patch !== Number(SEA_RELEASE_POLICY.versionHint.split('.')[2]) || + parsed.prerelease[0] !== SEA_RELEASE_POLICY.identifier || + parsed.prerelease.length !== 2 || + !Number.isInteger(parsed.prerelease[1]) + ) { + throw new Error( + `Expected SEA version ${PRE_RELEASE_PREFIX}; found ${version}`, + ) + } + return parsed.version +} + +export function makeVariants(version: string): Variant[] { + return VARIANTS.map(variant => { + const resolvedVersion = 'version' in variant ? variant.version : version + return { + ...variant, + version: resolvedVersion, + artifact: `${variant.expectedName.replaceAll('/', '-')}-${resolvedVersion}.tgz`, + } + }) +} + +export function updateRootManifest(raw: string, version: string): string { + const manifest = JSON.parse(raw) as { name?: string; version?: string } + if (!manifest.name || !manifest.version) { + throw new Error('Root manifest is missing name or version') + } + const updated = raw + .replace(/^ {2}"name": "[^"]+",$/m, ' "name": "socket",') + .replace(/^ {2}"version": "[^"]+",$/m, ` "version": "${version}",`) + const checked = JSON.parse(updated) as { name: string; version: string } + if (checked.name !== 'socket' || checked.version !== version) { + throw new Error( + 'Could not set root package name and version for this build', + ) + } + return updated +} + +export function validatePrivateVirtualStore( + workspace: string, + virtualStoreDir: unknown, +): void { + if (typeof virtualStoreDir !== 'string') { + throw new Error('pnpm did not report its virtual store directory') + } + const actual = path.resolve(workspace, 'node_modules', virtualStoreDir) + const expected = path.resolve(workspace, 'node_modules/.pnpm') + if (actual !== expected) { + throw new Error( + `pnpm virtual store escaped the private workspace: ${actual}`, + ) + } +} + +export function createPrivatePnpmEnvironment( + workspace: string, + source: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + const privateKeys = new Set([ + 'npm_config_enable_global_virtual_store', + 'npm_config_virtual_store_dir', + 'pnpm_config_enable_global_virtual_store', + 'pnpm_config_virtual_store_dir', + ]) + const environment = Object.fromEntries( + Object.entries(source).filter( + ([key]) => !privateKeys.has(key.toLowerCase()), + ), + ) + return { + ...environment, + npm_config_enable_global_virtual_store: 'false', + npm_config_virtual_store_dir: path.join(workspace, 'node_modules/.pnpm'), + pnpm_config_enable_global_virtual_store: 'false', + pnpm_config_virtual_store_dir: path.join(workspace, 'node_modules/.pnpm'), + } +} + +export async function verifyPrivateVirtualStore( + workspace: string, +): Promise { + const modulesPath = path.join(workspace, 'node_modules/.modules.yaml') + const modules = parseYaml(await fs.readFile(modulesPath, 'utf8')) as { + virtualStoreDir?: unknown + } + validatePrivateVirtualStore(workspace, modules.virtualStoreDir) +} + +export function readPackagedManifest(bytes: Buffer): Record { + // npm pack produces a gzip compressed POSIX tar archive. + const tar = gunzipSync(bytes) + for (let offset = 0; offset + 512 <= tar.length; ) { + const header = tar.subarray(offset, offset + 512) + if (header.every(byte => byte === 0)) { + break + } + const name = header.toString('utf8', 0, 100).replace(/\0.*$/, '') + const prefix = header.toString('utf8', 345, 500).replace(/\0.*$/, '') + const entry = prefix ? `${prefix}/${name}` : name + const sizeText = header + .toString('ascii', 124, 136) + .replace(/\0.*$/, '') + .trim() + if (!/^[0-7]+$/.test(sizeText)) { + throw new Error('Invalid tar entry size') + } + const size = Number.parseInt(sizeText, 8) + const dataStart = offset + 512 + const dataEnd = dataStart + size + if (dataEnd > tar.length) { + throw new Error('Truncated tar entry') + } + if (entry === 'package/package.json') { + return JSON.parse(tar.toString('utf8', dataStart, dataEnd)) as Record< + string, + unknown + > + } + offset = dataStart + Math.ceil(size / 512) * 512 + } + throw new Error('No package/package.json entry in npm tarball') +} + +export function validatePackagedManifest( + manifest: Record, + variant: Variant, +): void { + const standardBins = { + socket: 'bin/cli.js', + 'socket-npm': 'bin/npm-cli.js', + 'socket-npx': 'bin/npx-cli.js', + 'socket-pnpm': 'bin/pnpm-cli.js', + 'socket-yarn': 'bin/yarn-cli.js', + } + const expectedBins = + variant.expectedName === '@socketsecurity/cli' && + variant.env?.['INLINED_SOCKET_CLI_LEGACY_BUILD'] + ? { cli: 'bin/cli.js', ...standardBins } + : variant.expectedName === '@socketsecurity/cli-with-sentry' + ? { + 'cli-with-sentry': 'bin/cli.js', + 'socket-with-sentry': 'bin/cli.js', + 'socket-npm-with-sentry': 'bin/npm-cli.js', + 'socket-npx-with-sentry': 'bin/npx-cli.js', + 'socket-pnpm-with-sentry': 'bin/pnpm-cli.js', + 'socket-yarn-with-sentry': 'bin/yarn-cli.js', + } + : standardBins + if ( + manifest['name'] !== variant.expectedName || + manifest['version'] !== variant.version + ) { + throw new Error( + `Tarball metadata mismatch: expected ${variant.expectedName}@${variant.version}, got ${String(manifest['name'])}@${String(manifest['version'])}`, + ) + } + if (JSON.stringify(manifest['bin']) !== JSON.stringify(expectedBins)) { + throw new Error(`Tarball CLI bin map mismatch: ${variant.artifact}`) + } +} + +export function run( + cwd: string, + command: string, + args: string[], + env: NodeJS.ProcessEnv, +): void { + const result = crossSpawn.sync(command, args, { + cwd, + env, + stdio: 'inherit', + shell: false, + }) + if (result.error) { + throw result.error + } + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with ${result.status}`) + } +} + +async function copyTrackedSource(stage: string): Promise { + const result = spawnSync( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], + { + cwd: ROOT, + encoding: 'buffer', + }, + ) + if (result.error) { + throw result.error + } + if (result.status !== 0) { + throw new Error('git ls-files failed while staging package build') + } + const paths = result.stdout.toString().split('\0').filter(Boolean) + for (const relative of new Set(paths)) { + const source = path.join(ROOT, relative) + const destination = path.join(stage, relative) + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.cp(source, destination, { + dereference: false, + preserveTimestamps: true, + }) + } +} + +async function assertOutputAvailable(output: string): Promise { + try { + const entries = await fs.readdir(output) + if (entries.length) { + throw new Error(`Output directory is not empty: ${output}`) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } +} + +async function verifyPackagedVariant( + tarball: string, + variant: Variant, +): Promise { + const manifest = readPackagedManifest(await fs.readFile(tarball)) + validatePackagedManifest(manifest, variant) +} + +async function main(): Promise { + const args = process.argv.slice(2) + if (args.length !== 0 && (args.length !== 2 || args[0] !== '--output-dir')) { + throw new Error( + 'Usage: pnpm build:package-matrix [--output-dir ]', + ) + } + const output = args.length ? path.resolve(ROOT, args[1]!) : DEFAULT_OUTPUT + await assertOutputAvailable(output) + const variants = makeVariants(currentPrereleaseVersion(SEA_VERSION)) + const temporary = await fs.mkdtemp( + path.join(os.tmpdir(), 'socket-package-matrix-'), + ) + const workspace = path.join(temporary, 'workspace') + const stagedOutput = path.join(temporary, 'artifacts') + const privateEnv = createPrivatePnpmEnvironment(workspace, process.env) + await fs.mkdir(stagedOutput) + try { + await copyTrackedSource(workspace) + // Build scripts require .env.local. The tracked template has no secrets and + // supplies only the non-sensitive build settings needed in this workspace. + await fs.copyFile( + path.join(workspace, '.env.dist'), + path.join(workspace, '.env.local'), + ) + const packagePath = path.join(workspace, 'package.json') + const lockPath = path.join(workspace, 'pnpm-lock.yaml') + const basePackage = await fs.readFile(packagePath, 'utf8') + const baseLock = await fs.readFile(lockPath, 'utf8') + run( + workspace, + 'pnpm', + [ + 'install', + '--frozen-lockfile', + '--ignore-scripts', + '--config.enable-global-virtual-store=false', + '--virtual-store-dir=node_modules/.pnpm', + ], + privateEnv, + ) + await verifyPrivateVirtualStore(workspace) + run(workspace, 'pnpm', ['run', 'build:dotnet-tool'], privateEnv) + await verifyPrivateVirtualStore(workspace) + run(workspace, 'pnpm', ['run', 'build:maven-extension'], privateEnv) + await verifyPrivateVirtualStore(workspace) + for (const variant of variants) { + await fs.writeFile( + packagePath, + updateRootManifest(basePackage, variant.version), + ) + await fs.writeFile(lockPath, baseLock) + const env = { + ...privateEnv, + INLINED_SOCKET_CLI_PUBLISHED_BUILD: '1', + INLINED_SOCKET_CLI_LEGACY_BUILD: '0', + INLINED_SOCKET_CLI_SENTRY_BUILD: '0', + ...variant.env, + } + run(workspace, 'pnpm', ['run', 'build:dist'], env) + await verifyPrivateVirtualStore(workspace) + const before = new Set(await fs.readdir(stagedOutput)) + run(workspace, 'pnpm', ['pack', '--pack-destination', stagedOutput], env) + await verifyPrivateVirtualStore(workspace) + const added = (await fs.readdir(stagedOutput)).filter( + name => !before.has(name), + ) + if (added.length !== 1 || !added[0]!.endsWith('.tgz')) { + throw new Error(`Expected one tarball for ${variant.expectedName}`) + } + const produced = path.join(stagedOutput, added[0]!) + const target = path.join(stagedOutput, variant.artifact) + await fs.rename(produced, target) + await verifyPackagedVariant(target, variant) + const cli = path.join(workspace, 'bin/cli.js') + const result = spawnSync(process.execPath, [cli, '--version'], { + cwd: workspace, + env, + encoding: 'utf8', + }) + if (result.error) { + throw result.error + } + if (result.status !== 0 || result.stdout.trim() !== variant.version) { + throw new Error( + `CLI --version did not report ${variant.version}: ${result.stdout}${result.stderr}`, + ) + } + console.log(`Built ${variant.artifact}`) + } + await assertOutputAvailable(output) + await fs.mkdir(path.dirname(output), { recursive: true }) + const publishStage = path.join( + path.dirname(output), + `.${path.basename(output)}-${process.pid}-${Date.now()}`, + ) + try { + await fs.cp(stagedOutput, publishStage, { recursive: true }) + await fs.rename(publishStage, output) + } catch (error) { + await fs.rm(publishStage, { recursive: true, force: true }) + throw error + } + console.log(`Package builds are in ${output}`) + } finally { + await fs.rm(temporary, { recursive: true, force: true }) + } +} + +const SCRIPT_META: ScriptMeta = { + describe: + 'builds and validates four Socket CLI npm package variants in a private workspace', + help: `Usage: pnpm run build:package-matrix [--output-dir ] + + --output-dir write the completed tarball set to an empty directory`, +} + +if (isMainModule(import.meta.url)) { + runMain(main, SCRIPT_META) +} diff --git a/scripts/check.mts b/scripts/check.mts index 0483a8c848..7f7456e995 100644 --- a/scripts/check.mts +++ b/scripts/check.mts @@ -150,7 +150,10 @@ export function buildSteps(forwardedArgs: readonly string[]): CheckStep[] { runStep('lint', process.execPath, ['scripts/lint.mts', ...forwardedArgs]), () => runStep('tsc:src', 'tsgo', []), () => - runStep('tsc:scripts', 'tsgo', ['--project', 'tsconfig.scripts.json']), + runStep('tsc:scripts', 'tsgo', [ + '--project', + '.config/tsconfig.scripts.json', + ]), ] } diff --git a/scripts/constants.js b/scripts/constants.js index c45abc7672..15bd909906 100644 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -49,7 +49,7 @@ const SOCKET_CLI_SENTRY_PACKAGE_NAME = '@socketsecurity/cli-with-sentry' const LAZY_ENV = () => { const { env } = process - const { envAsBoolean } = require('@socketsecurity/registry/lib/env') + const { envAsBoolean, envAsString } = require('@socketsecurity/registry/lib/env') return Object.freeze({ // Lazily access registryConstants.ENV. ...registryConstants.ENV, @@ -61,6 +61,7 @@ const LAZY_ENV = () => { [INLINED_SOCKET_CLI_PUBLISHED_BUILD]: envAsBoolean( env[INLINED_SOCKET_CLI_PUBLISHED_BUILD], ), + [INLINED_SOCKET_CLI_VERSION]: envAsString(env[INLINED_SOCKET_CLI_VERSION]), // Flag set to determine if this is the Sentry build. [INLINED_SOCKET_CLI_SENTRY_BUILD]: envAsBoolean( env[INLINED_SOCKET_CLI_SENTRY_BUILD], @@ -75,9 +76,15 @@ const lazyBlessedPath = () => path.join(constants.externalPath, 'blessed') const lazyConfigPath = () => path.join(constants.rootPath, '.config') -const lazyDistPath = () => path.join(constants.rootPath, 'dist') +const lazyDistPath = () => + process.env.SOCKET_SEA_DIST_DIR + ? path.resolve(process.env.SOCKET_SEA_DIST_DIR) + : path.join(constants.rootPath, 'dist') -const lazyExternalPath = () => path.join(constants.rootPath, 'external') +const lazyExternalPath = () => + process.env.SOCKET_SEA_EXTERNAL_DIR + ? path.resolve(process.env.SOCKET_SEA_EXTERNAL_DIR) + : path.join(constants.rootPath, 'external') const lazyRootPackageJsonPath = () => path.join(constants.rootPath, 'package.json') diff --git a/scripts/release/add-unreleased.mts b/scripts/release/add-unreleased.mts index 9676e27ec3..f1146f5e6b 100644 --- a/scripts/release/add-unreleased.mts +++ b/scripts/release/add-unreleased.mts @@ -2,7 +2,7 @@ /** * @file Accrue one note under CHANGELOG.md's `## [Unreleased]` heading from the * command line, so automation that bumps a dependency here writes its note - * the way a human does. The counterpart to `bump.mts`, which promotes the + * the way a human does. The counterpart to `prepare.mts`, which promotes the * block at release time. * * This exists so a caller never has to reimplement the shape. diff --git a/scripts/release/approve-npm.mts b/scripts/release/approve-npm.mts new file mode 100644 index 0000000000..5e5d1c32e1 --- /dev/null +++ b/scripts/release/approve-npm.mts @@ -0,0 +1,259 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process' +import { parseArgs } from 'node:util' + +import { RELEASE_PACKAGES } from './packages.mts' +import { npmDistTagForRelease } from './validate-npm-policy.mts' +import { readReleasePolicy } from '../../src/sea/version.mts' +import { isMainModule } from '../lib/is-main-module.mts' +import { runMain } from '../lib/run-main.mts' + +const STAGE_ID = + /^[a-f\d]{8}-[a-f\d]{4}-[1-8][a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/iu +const PNPM_STAGE_VERSION = '11.17.0' + +interface GitHubRepositoryContext { + readonly nameWithOwner: string + readonly defaultBranch: string +} + +function run(command: string, args: string[], inherit = false): string { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'], + timeout: 120_000, + }) + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(' ')} failed: ${result.stderr ?? ''}`, + ) + } + return result.stdout?.trim() ?? '' +} + +export function pnpmStageInvocation( + version: string, + args: string[], +): { command: string; args: string[] } { + const major = Number.parseInt(version.split('.')[0] ?? '', 10) + return major >= 11 + ? { command: 'pnpm', args } + : { + command: 'corepack', + args: [`pnpm@${PNPM_STAGE_VERSION}`, '--pm-on-fail=ignore', ...args], + } +} + +function runPnpmStage(args: string[], inherit = false): string { + const currentVersion = run('pnpm', ['--version']) + const invocation = pnpmStageInvocation(currentVersion, args) + return run(invocation.command, invocation.args, inherit) +} + +function validateStage( + value: unknown, + id: string, +): { name: string; version: string } { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('npm returned an invalid stage.') + } + const stage = value as Record + const name = stage['packageName'] + const version = stage['version'] + if ( + stage['id'] !== id || + typeof name !== 'string' || + typeof version !== 'string' || + !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/u.test( + version, + ) + ) { + throw new Error('npm stage ID, package, or version is invalid.') + } + if (!RELEASE_PACKAGES.includes(name as (typeof RELEASE_PACKAGES)[number])) { + throw new Error(`Unexpected npm package in stage: ${name}`) + } + if (version.includes('-') && name !== 'socket') { + throw new Error('Prerelease stages may only contain the socket package.') + } + return { name, version } +} + +export type StageAction = 'wait' | 'approve' | 'finalize' | 'burned' + +export function stageActionForStatus(status: unknown): StageAction { + if (status === 'validating') { + return 'wait' + } + if (status === 'staged') { + return 'approve' + } + if (status === 'published') { + return 'finalize' + } + if (status === 'blocked' || status === 'deleted') { + return 'burned' + } + throw new Error(`npm stage is ${String(status)}; approval stopped.`) +} + +function readGitHubRepositoryContext(): GitHubRepositoryContext { + const context = JSON.parse( + run('gh', ['repo', 'view', '--json', 'nameWithOwner,defaultBranchRef']), + ) as { + nameWithOwner?: string + defaultBranchRef?: { name?: string } + } + const { nameWithOwner } = context + const defaultBranch = context.defaultBranchRef?.name + if ( + !nameWithOwner || + !/^[^/\s]+\/[^/\s]+$/u.test(nameWithOwner) || + !defaultBranch || + !/^[A-Za-z0-9._/-]+$/u.test(defaultBranch) || + defaultBranch.startsWith('-') + ) { + throw new Error('GitHub returned an invalid repository or default branch.') + } + return { nameWithOwner, defaultBranch } +} + +function dispatchGitHubRelease( + version: string, + repository: GitHubRepositoryContext, +): void { + const tag = `v${version}` + const args = [ + 'workflow', + 'run', + 'release-github.yml', + '--repo', + repository.nameWithOwner, + '--ref', + repository.defaultBranch, + '--field', + `tag=${tag}`, + ] + try { + run('gh', args) + } catch (error) { + process.stderr.write( + `${String(error)}\nRetry finalization with: gh ${args.join(' ')}\n`, + ) + throw new Error('GitHub release dispatch failed after npm approval.') + } + process.stdout.write( + `GitHub release finalization queued for ${tag}. Retry this stage ID if finalization fails.\n`, + ) +} + +async function approve(id: string): Promise { + if (!STAGE_ID.test(id)) { + throw new Error('Invalid npm stage UUID.') + } + const policy = readReleasePolicy() + const deadline = Date.now() + 30 * 60_000 + let delay = 2_000 + let stage: { name: string; version: string } + let status: unknown + let shouldApprove = false + while (true) { + const raw = runPnpmStage([ + 'stage', + 'view', + id, + '--json', + '--registry=https://registry.npmjs.org', + ]) + let value: unknown + try { + value = JSON.parse(raw) as unknown + } catch { + throw new Error('npm stage view did not return JSON.') + } + stage = validateStage(value, id) + status = (value as Record)['status'] + const action = stageActionForStatus(status) + npmDistTagForRelease(stage.version, policy) + if (action === 'approve') { + shouldApprove = true + break + } + if (action === 'finalize') { + break + } + if (action === 'burned') { + throw new Error( + `npm stage is ${String(status)}; version ${stage.version} is burned. Keep tag v${stage.version} and prepare the next version.`, + ) + } + if (Date.now() >= deadline) { + throw new Error( + 'Timed out waiting for npm validation; the stage remains available to retry.', + ) + } + // eslint-disable-next-line no-await-in-loop -- Poll stage state before approving it. + await new Promise(resolve => + setTimeout(resolve, Math.min(delay, deadline - Date.now())), + ) + delay = Math.min(delay * 2, 30_000) + } + const repository = readGitHubRepositoryContext() + if (shouldApprove) { + const taggedManifest = JSON.parse( + run('gh', [ + 'api', + `repos/${repository.nameWithOwner}/contents/package.json?ref=${encodeURIComponent(`refs/tags/v${stage.version}`)}`, + ]), + ) as { encoding?: string; content?: string } + if (taggedManifest.encoding !== 'base64' || !taggedManifest.content) { + throw new Error( + `GitHub could not read package.json at tag v${stage.version}.`, + ) + } + const repositoryManifest = JSON.parse( + Buffer.from(taggedManifest.content, 'base64').toString('utf8'), + ) as { name?: string; version?: string } + if ( + repositoryManifest.name !== 'socket' || + repositoryManifest.version !== stage.version + ) { + throw new Error( + `Tag v${stage.version} does not contain ${stage.name}@${stage.version}.`, + ) + } + runPnpmStage( + ['stage', 'approve', id, '--registry=https://registry.npmjs.org'], + true, + ) + process.stdout.write(`Approved ${stage.name}@${stage.version}.\n`) + } else { + process.stdout.write( + `npm stage ${id} is already published; retrying GitHub release finalization.\n`, + ) + } + + dispatchGitHubRelease(stage.version, repository) +} + +async function main(args = process.argv.slice(2)): Promise { + const { positionals } = parseArgs({ + args, + options: {}, + strict: true, + allowPositionals: true, + }) + if (positionals.length !== 1 || !positionals[0]) { + throw new Error('Usage: pnpm run approve:npm ') + } + await approve(positionals[0]) +} + +if (isMainModule(import.meta.url)) { + runMain(main, { + describe: 'Approves a validated npm stage by ID.', + help: 'Usage: pnpm run approve:npm ', + }) +} + +export { approve, readGitHubRepositoryContext, validateStage } diff --git a/scripts/release/availability.mts b/scripts/release/availability.mts new file mode 100644 index 0000000000..3a3590a5a7 --- /dev/null +++ b/scripts/release/availability.mts @@ -0,0 +1,158 @@ +#!/usr/bin/env node +import { releasePackagesForVersion } from './packages.mts' +import { isMainModule } from '../lib/is-main-module.mts' +import { runMain } from '../lib/run-main.mts' + +const RELEASE_VERSION = + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/u + +export interface AvailabilityOptions { + readonly fetcher?: typeof fetch + readonly now?: () => number + readonly signal?: AbortSignal + readonly wait?: ( + milliseconds: number, + signal?: AbortSignal, + ) => Promise + readonly timeoutMs?: number +} + +function waitForDelay( + milliseconds: number, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + const finish = (): void => { + signal?.removeEventListener('abort', abort) + resolve() + } + const abort = (): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', abort) + reject(signal?.reason) + } + const timer = setTimeout(finish, milliseconds) + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) {abort()} + }) +} + +function retryDelay(response: Response, fallback: number, now: number): number { + const value = response.headers.get('retry-after') + if (!value) {return fallback} + const seconds = Number(value) + if (Number.isFinite(seconds)) { + return seconds > 0 ? seconds * 1000 : fallback + } + const date = Date.parse(value) + return Number.isFinite(date) ? Math.max(1_000, date - now) : fallback +} + +export async function waitForNpmVersion( + name: string, + version: string, + options: AvailabilityOptions = {}, +): Promise { + if (!RELEASE_VERSION.test(version)) { + throw new TypeError(`Invalid npm release version: ${version}`) + } + const fetcher = options.fetcher ?? fetch + const now = options.now ?? Date.now + const wait = options.wait ?? waitForDelay + const deadline = now() + (options.timeoutMs ?? 30 * 60_000) + const url = `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(version)}` + let delay = 2_000 + + while (true) { + const remaining = deadline - now() + if (remaining <= 0) { + throw new Error( + `Timed out waiting for npm to expose ${name}@${version}; no GitHub release was created. Retry finalization with the same stage ID.`, + ) + } + const timeout = AbortSignal.timeout(Math.min(30_000, remaining)) + const signal = options.signal + ? AbortSignal.any([options.signal, timeout]) + : timeout + let response: Response + try { + // eslint-disable-next-line no-await-in-loop -- Poll until npm reports the package as public. + response = await fetcher(url, { + method: 'HEAD', + redirect: 'manual', + signal, + }) + } catch (error) { + if (options.signal?.aborted) { + throw error + } + // eslint-disable-next-line no-await-in-loop -- Back off before retrying the registry request. + await wait(Math.min(delay, deadline - now()), options.signal) + delay = Math.min(delay * 2, 60_000) + continue + } + if (response.status === 200) { + return + } + if ( + response.status !== 404 && + response.status !== 429 && + (response.status < 500 || response.status > 599) + ) { + throw new Error(`npm availability check returned HTTP ${response.status}.`) + } + const waitMs = retryDelay(response, delay, now()) + // eslint-disable-next-line no-await-in-loop -- Respect retry-after and back off for pending npm versions. + await wait(Math.min(waitMs, deadline - now()), options.signal) + delay = Math.min(delay * 2, 60_000) + } +} + +export async function waitForReleasePackages( + version: string, + options: AvailabilityOptions = {}, +): Promise { + const packages = releasePackagesForVersion(version) + const controller = new AbortController() + const signal = options.signal + ? AbortSignal.any([controller.signal, options.signal]) + : controller.signal + let firstFailure: unknown + try { + await Promise.all( + packages.map(async name => { + try { + await waitForNpmVersion(name, version, { + ...options, + signal, + }) + } catch (error) { + firstFailure ??= error + controller.abort(error) + throw error + } + }), + ) + } catch (error) { + throw firstFailure ?? error + } + return packages +} + +async function main(args = process.argv.slice(2)): Promise { + const version = args[0] + if (args.length !== 1 || !version) { + throw new Error('Usage: node scripts/release/availability.mts ') + } + const packages = await waitForReleasePackages(version) + process.stdout.write( + `npm exposes ${packages.map(name => `${name}@${version}`).join(', ')}.\n`, + ) +} + +if (isMainModule(import.meta.url)) { + runMain(main, { + describe: 'Waits for every package in a release to become public on npm.', + help: 'Usage: node scripts/release/availability.mts ', + }) +} diff --git a/scripts/release/history.mts b/scripts/release/history.mts index cc0fed0e74..9a64ef59fa 100644 --- a/scripts/release/history.mts +++ b/scripts/release/history.mts @@ -1,11 +1,7 @@ import { execFile as execFileCallback } from 'node:child_process' import { promisify } from 'node:util' -import { - COMMIT_LOG_FORMAT, - maxReleaseVersion, - releaseVersionsForLine, -} from './version.mts' +import { COMMIT_LOG_FORMAT, releaseVersionsForLine } from './version.mts' const execFile = promisify(execFileCallback) @@ -77,30 +73,50 @@ async function findSquashLandedReleases( export async function readReleaseHistory( cwd: string, manifestVersion: string, + releaseMajor?: number, + publishedVersion?: string, ): Promise { const [allTags, landedTags] = await Promise.all([ readReleaseGit(cwd, ['tag', '--list', 'v*']), readReleaseGit(cwd, ['tag', '--merged', 'HEAD', '--list', 'v*']), ]) + const allTagNames = allTags.trim().split('\n').filter(Boolean) + const landedTagNames = landedTags.trim().split('\n').filter(Boolean) const reservedVersions = releaseVersionsForLine( - allTags.trim().split('\n'), + allTagNames, manifestVersion, + releaseMajor, ) const reachableTags = releaseVersionsForLine( - landedTags.trim().split('\n'), + landedTagNames, manifestVersion, + releaseMajor, ) + const publishedTag = publishedVersion ? `v${publishedVersion}` : undefined const squashLanded = await findSquashLandedReleases( cwd, - reservedVersions.filter(tag => !reachableTags.includes(tag)), + [...reservedVersions, ...(publishedTag ? [publishedTag] : [])].filter( + tag => !landedTagNames.includes(tag), + ), ) const tagVersions = [...reachableTags, ...squashLanded.keys()] - const anchorVersion = maxReleaseVersion(tagVersions) - const anchorTag = anchorVersion ? `v${anchorVersion}` : undefined + const anchorTag = publishedTag + if (anchorTag && !allTagNames.includes(anchorTag)) { + throw new Error( + `Published release ${publishedVersion} has no matching git tag. Refusing to generate a partial changelog.`, + ) + } + const anchorLanded = anchorTag + ? (squashLanded.get(anchorTag) ?? + (landedTagNames.includes(anchorTag) ? anchorTag : undefined)) + : undefined + if (anchorTag && !anchorLanded) { + throw new Error( + `Published release ${publishedVersion} is not reachable from this branch. Refusing to generate a partial changelog.`, + ) + } return { - anchorRef: anchorTag - ? (squashLanded.get(anchorTag) ?? anchorTag) - : undefined, + anchorRef: anchorLanded, reservedVersions, tagVersions, } @@ -117,3 +133,15 @@ export async function readReleaseCommits( `--format=${COMMIT_LOG_FORMAT}`, ]) } + +export async function readTaggedPrereleaseVersions( + cwd: string, + versionHint: string, + identifier: string, +): Promise { + const prefix = `${versionHint}-${identifier}.` + return (await readReleaseGit(cwd, ['tag', '--list', `v${prefix}*`])) + .split('\n') + .filter(Boolean) + .map(tag => tag.replace(/^v/u, '')) +} diff --git a/scripts/release/open-release-pr.mts b/scripts/release/open-release-pr.mts index df05ae16dd..88cd76eab8 100644 --- a/scripts/release/open-release-pr.mts +++ b/scripts/release/open-release-pr.mts @@ -1,9 +1,8 @@ #!/usr/bin/env node /** - * @file Commit the bump that `bump.mts` wrote into the working tree via the - * release App and open the release PR into the release line. The - * publish-npm workflow runs this in a job with no dependency install, so the - * App token never shares a job with third-party code. + * @file Commits the files from `prepare.mts` with the release App and opens the + * release PR. This job installs no dependencies, so installed code never + * shares a job with the App token. * * Usage: * node scripts/release/open-release-pr.mts --version 1.5.1 @@ -65,7 +64,7 @@ export function assertBumpOnly(changedFiles: readonly string[]): void { '[open-release-pr] the bump must change exactly CHANGELOG.md and package.json.\n' + " Where: the working tree after applying the derivation job's patch.\n" + ` Saw: ${sorted.join(', ') || '(no changes)'}.\n` + - ' Fix: re-dispatch with mode release-pr. If it repeats, inspect bump.mts.', + ' Fix: re-dispatch with mode release-pr. If it repeats, inspect prepare.mts.', ) } } @@ -145,7 +144,7 @@ const SCRIPT_META: ScriptMeta = { 'commits the bump via the release App and opens the release PR into the release line', help: `Usage: node scripts/release/open-release-pr.mts --version - --version the version bump.mts derived and wrote into the tree + --version the version prepare.mts derived and wrote into the tree The publish-npm workflow runs this in its release-pr mode, after applying the bump to a clean checkout. It needs RELEASE_APP_TOKEN with contents:write diff --git a/scripts/release/packages.mts b/scripts/release/packages.mts new file mode 100644 index 0000000000..7e76262fa2 --- /dev/null +++ b/scripts/release/packages.mts @@ -0,0 +1,9 @@ +export const RELEASE_PACKAGES = [ + 'socket', + '@socketsecurity/cli', + '@socketsecurity/cli-with-sentry', +] as const + +export function releasePackagesForVersion(version: string): readonly string[] { + return version.includes('-') ? ['socket'] : RELEASE_PACKAGES +} diff --git a/scripts/release/preflight.mts b/scripts/release/preflight.mts index 70581ffd9c..daf5f721b9 100644 --- a/scripts/release/preflight.mts +++ b/scripts/release/preflight.mts @@ -3,6 +3,7 @@ import process from 'node:process' import { assertReleaseVersionsAvailable } from './registry.mts' +import { readReleasePolicy } from '../../src/sea/version.mts' import { isMainModule } from '../lib/is-main-module.mts' import { runMain } from '../lib/run-main.mts' @@ -12,20 +13,24 @@ async function main(): Promise { const argv = process.argv.slice(2) const versionIndex = argv.indexOf('--version') const version = versionIndex === -1 ? '' : (argv[versionIndex + 1] ?? '') - const result = await assertReleaseVersionsAvailable(version) + const result = await assertReleaseVersionsAvailable( + version, + fetch, + readReleasePolicy(), + ) process.stdout.write( argv.includes('--json') ? `${JSON.stringify(result)}\n` - : `[release] ${result.version} is unpublished for all ${result.packages.length} packages.\n`, + : `[release] ${result.version} is unpublished for ${result.packages.length} package(s).\n`, ) } const SCRIPT_META: ScriptMeta = { describe: - 'confirms the exact release version is unpublished for all three CLI packages', + 'confirms the exact release version is unpublished for packages in the selected release track', help: `Usage: pnpm run release:preflight --version [--json] - --version exact stable version produced by the bump step + --version exact version produced by the bump step --json print the confirmed version and package names as JSON Only HTTP 404 confirms an absent version. Registry failures stop the release.`, diff --git a/scripts/release/bump.mts b/scripts/release/prepare.mts similarity index 55% rename from scripts/release/bump.mts rename to scripts/release/prepare.mts index 21b9a2e74e..3661d6ec9b 100644 --- a/scripts/release/bump.mts +++ b/scripts/release/prepare.mts @@ -1,12 +1,11 @@ #!/usr/bin/env node /** - * @file The CI bump stage. Derives the next version from the commits landed - * since the last release and writes package.json + CHANGELOG.md into the - * working tree. `open-release-pr.mts` commits the pair and opens the release - * PR from a separate job that never installs dependencies. + * @file Prepares the next version and changelog from landed commits. A separate + * job commits these files and opens the PR without running installed code + * alongside the release App token. * * Usage: - * node scripts/release/bump.mts [--dry-run] [--release-as major|minor|patch] + * node scripts/release/prepare.mts [--dry-run] [--release-as major|minor|patch] */ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs' @@ -20,9 +19,21 @@ import { promoteChangelog, repoBaseUrl, } from './changelog.mts' -import { readReleaseCommits, readReleaseHistory } from './history.mts' -import { readPublishedVersion } from './registry.mts' -import { deriveNextVersion, parseConventionalCommits } from './version.mts' +import { + readReleaseCommits, + readReleaseHistory, + readTaggedPrereleaseVersions, +} from './history.mts' +import { readPublishedVersion, readPublishedVersions } from './registry.mts' +import { + deriveNextVersion, + maxPrereleaseVersion, + maxReleaseVersion, + nextPrereleaseVersion, + parseConventionalCommits, + releaseVersionsForLine, +} from './version.mts' +import { readReleasePolicy } from '../../src/sea/version.mts' import { isMainModule } from '../lib/is-main-module.mts' import { runMain } from '../lib/run-main.mts' @@ -43,7 +54,7 @@ interface PackageJsonShape { } function log(message: string): void { - process.stdout.write(`[bump] ${message}\n`) + process.stdout.write(`[prepare] ${message}\n`) } function readPackageJson(): { parsed: PackageJsonShape; raw: string } { @@ -62,8 +73,8 @@ export function writeManifestVersion(raw: string, version: string): string { // that no-op apart from a missing field. if (!VERSION_FIELD_PATTERN.test(raw)) { throw new Error( - '[bump] could not rewrite the package.json version field.\n' + - ' Where: the root package.json, at bump time.\n' + + '[prepare] could not rewrite the package.json version field.\n' + + ' Where: the root package.json, during release preparation.\n' + ' Saw: no top-level `"version": "…"` line; wanted exactly one to replace.\n' + ' Fix: restore the version field, then re-dispatch.', ) @@ -98,32 +109,107 @@ function parseArgs(argv: readonly string[]): { async function main(): Promise { const { dryRun, releaseAs } = parseArgs(process.argv.slice(2)) + const releaseMajorValue = process.env['SOCKET_CLI_RELEASE_MAJOR'] + const releaseMajor = releaseMajorValue + ? Number.parseInt(releaseMajorValue, 10) + : undefined + if ( + releaseMajorValue && + (releaseMajor === undefined || + !Number.isInteger(releaseMajor) || + releaseMajor < 1 || + String(releaseMajor) !== releaseMajorValue) + ) { + throw new Error( + `SOCKET_CLI_RELEASE_MAJOR must be a positive integer; received ${releaseMajorValue}`, + ) + } const manifest = readPackageJson() + const releasePolicy = readReleasePolicy(rootPath) const manifestVersion = manifest.parsed.version ?? '0.0.0' - const [publishedVersion, history] = await Promise.all([ - manifest.parsed.name - ? readPublishedVersion(manifest.parsed.name) - : undefined, - readReleaseHistory(rootPath, manifestVersion), - ]) + const selectedReleaseMajor = + releaseMajor ?? Number(manifestVersion.split('.')[0]) + const prereleaseTrack = + releasePolicy.mode === 'prerelease-only' && + selectedReleaseMajor === Number(releasePolicy.versionHint.split('.')[0]) + ? releasePolicy + : undefined + const [publishedVersion, publishedVersions, taggedPrereleases] = + await Promise.all([ + manifest.parsed.name + ? readPublishedVersion(manifest.parsed.name) + : undefined, + manifest.parsed.name + ? readPublishedVersions(manifest.parsed.name) + : Promise.resolve([]), + prereleaseTrack + ? readTaggedPrereleaseVersions( + rootPath, + prereleaseTrack.versionHint, + prereleaseTrack.identifier, + ) + : Promise.resolve([]), + ]) + const trackPublishedVersion = prereleaseTrack + ? maxPrereleaseVersion( + publishedVersions, + `${prereleaseTrack.versionHint}-${prereleaseTrack.identifier}.`, + ) + : undefined + const stableLineBaseline = maxReleaseVersion( + releaseVersionsForLine( + publishedVersions, + manifestVersion, + prereleaseTrack ? 1 : selectedReleaseMajor, + ), + ) + const latestStableBaseline = prereleaseTrack + ? maxReleaseVersion(publishedVersions) + : undefined + const changelogBaseline = + trackPublishedVersion ?? stableLineBaseline ?? latestStableBaseline + if (publishedVersions.length > 0 && !changelogBaseline) { + throw new Error( + `[prepare] npm has published versions, but none can anchor the ${selectedReleaseMajor}.x changelog. Refusing to use all history.`, + ) + } + const history = await readReleaseHistory( + rootPath, + manifestVersion, + releaseMajor, + changelogBaseline, + ) log( `npm latest ${publishedVersion ?? '(none)'}; ` + `${history.tagVersions.length} landed release tag(s); ` + - `${history.reservedVersions.length} reserved tag(s); manifest ${manifestVersion}.`, + `${history.reservedVersions.length} reserved tag(s); ` + + `manifest ${manifestVersion}; release major ${releaseMajor ?? 'from manifest'}.`, ) const commitsRaw = await readReleaseCommits(rootPath, history.anchorRef) const commits = parseConventionalCommits(commitsRaw) - const derived = deriveNextVersion({ + const stableDerived = deriveNextVersion({ commits, manifestVersion, + releaseMajor, publishedVersion, releaseAs, reservedVersions: history.reservedVersions, tagVersions: history.tagVersions, }) - if (derived.level === 'major' && !releaseAs) { + const derived = prereleaseTrack + ? { + ...stableDerived, + version: nextPrereleaseVersion(prereleaseTrack, [ + manifestVersion, + ...publishedVersions, + ...taggedPrereleases, + ]), + reason: `prerelease-only policy (${prereleaseTrack.identifier})`, + } + : stableDerived + if (!prereleaseTrack && derived.level === 'major' && !releaseAs) { throw new Error( - `[bump] a breaking change is in the release range, so the level is major.\n` + + `[prepare] a breaking change is in the release range, so the level is major.\n` + ` Where: the commits between v${derived.base} and HEAD.\n` + ` Saw: a derived MAJOR bump; wanted a human to name it — a major is never derived.\n` + ` Fix: re-dispatch with release-as set to major to confirm, or minor/patch to override.`, @@ -167,14 +253,17 @@ async function main(): Promise { const SCRIPT_META: ScriptMeta = { describe: - 'derives the next release version from the landed commits and writes package.json + CHANGELOG.md', - help: `Usage: node scripts/release/bump.mts [flags] + 'prepares the next release version and changelog from landed commits', + help: `Usage: node scripts/release/prepare.mts [flags] --dry-run derive and print the version without writing package.json or CHANGELOG.md --release-as major|minor|patch force the bump level instead of deriving it from the conventional commits + SOCKET_CLI_RELEASE_MAJOR select a release major when it differs from + the root package version + The publish-npm workflow runs this in its release-pr mode. It only edits the working tree, so a local run is safe to inspect and discard.`, } diff --git a/scripts/release/registry.mts b/scripts/release/registry.mts index 36e068d2c6..fd3c272605 100644 --- a/scripts/release/registry.mts +++ b/scripts/release/registry.mts @@ -1,10 +1,11 @@ import semver from 'semver' -export const RELEASE_PACKAGES = [ - 'socket', - '@socketsecurity/cli', - '@socketsecurity/cli-with-sentry', -] as const +import { RELEASE_PACKAGES } from './packages.mts' +import { matchesReleasePolicy } from '../../src/sea/version.mts' + +import type { ReleasePolicy } from '../../src/sea/version.mts' + +export { RELEASE_PACKAGES } const REGISTRY_URL = 'https://registry.npmjs.org' @@ -105,6 +106,26 @@ export async function readPublishedVersion( ) } +export async function readPublishedVersions( + name: string, + fetcher: typeof fetch = fetch, +): Promise { + const document = await readRegistryDocument(name, undefined, fetcher) + if (!document) { + return [] + } + const versions = document['versions'] + if (!versions || typeof versions !== 'object' || Array.isArray(versions)) { + throw new ReleaseRegistryError( + 'REGISTRY_INVALID_RESPONSE', + name, + 'a missing or invalid versions map', + 200, + ) + } + return Object.keys(versions) +} + export interface ReleaseRegistryPreflight { readonly packages: readonly string[] readonly version: string @@ -113,17 +134,32 @@ export interface ReleaseRegistryPreflight { export async function assertReleaseVersionsAvailable( version: string, fetcher: typeof fetch = fetch, + policy?: ReleasePolicy, ): Promise { - if (semver.valid(version) !== version || semver.prerelease(version)) { + const prereleasePolicy = + policy?.mode === 'prerelease-only' ? policy : undefined + const onPrereleaseMajor = + prereleasePolicy !== undefined && + semver.parse(version)?.major === + Number(prereleasePolicy.versionHint.split('.')[0]) + const allowedVersion = prereleasePolicy + ? onPrereleaseMajor + ? matchesReleasePolicy(version, prereleasePolicy) + : semver.parse(version)?.major === 1 && + semver.valid(version) === version && + !semver.prerelease(version) + : semver.valid(version) === version && !semver.prerelease(version) + if (!allowedVersion) { throw new TypeError( '[release] invalid release version.\n' + ' Where: release:preflight --version.\n' + - ' Saw: a missing or non-release version; wanted an exact stable semver.\n' + + ` Saw: ${version}; wanted a version allowed by the release policy.\n` + ' Fix: pass --version with the bump step version.', ) } + const packages = onPrereleaseMajor ? ['socket'] : [...RELEASE_PACKAGES] const results = await Promise.allSettled( - RELEASE_PACKAGES.map(async name => { + packages.map(async name => { const document = await readRegistryDocument(name, version, fetcher) if (!document) { return @@ -149,5 +185,5 @@ export async function assertReleaseVersionsAvailable( throw result.reason } } - return { packages: [...RELEASE_PACKAGES], version } + return { packages, version } } diff --git a/scripts/release/release-branch.mts b/scripts/release/release-branch.mts index 7aed5d1d46..6c9ab142cd 100644 --- a/scripts/release/release-branch.mts +++ b/scripts/release/release-branch.mts @@ -130,7 +130,7 @@ export function releasePullRequestBody( '', '1. Review the CHANGELOG section and squash-merge this PR.', `2. Dispatch **Publish to npm registry** on \`${releaseLine}\` with \`dry-run: false\`. It tags the merge commit, cuts the GitHub release, and stages all three packages.`, - '3. Approve each staged package with `pnpm stage approve`.', + '3. Approve each staged package with `pnpm run approve:npm `.', '', `If ${releaseLine} moves before this merges, re-dispatch with \`mode: release-pr\` to rebuild the bump on the new tip.`, ].join('\n') diff --git a/scripts/release/validate-npm-policy.mts b/scripts/release/validate-npm-policy.mts new file mode 100644 index 0000000000..a1e8b3b261 --- /dev/null +++ b/scripts/release/validate-npm-policy.mts @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import { appendFileSync } from 'node:fs' + +import { + type ReleasePolicy, + matchesReleasePolicy, + readReleasePolicy, +} from '../../src/sea/version.mts' +import { isMainModule } from '../lib/is-main-module.mts' +import { runMain } from '../lib/run-main.mts' + +const STABLE_VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u +const DIST_TAG = /^[A-Za-z][A-Za-z0-9._-]*$/u + +export function validateNpmPolicy( + version: string, + distTag: string, + policy: ReleasePolicy, +): void { + if (!DIST_TAG.test(distTag)) { + throw new Error('npm dist-tag is invalid.') + } + if (policy.mode === 'stable-from-hint') { + if (!matchesReleasePolicy(version, policy)) { + throw new Error( + 'Stable release policy does not allow a prerelease version.', + ) + } + if (distTag !== policy.distTag) { + throw new Error(`Release policy requires the ${policy.distTag} dist-tag.`) + } + return + } + const policyMajor = Number(policy.versionHint.split('.')[0]) + const releaseMajor = Number(version.split('.')[0]) + if (releaseMajor === policyMajor) { + if (!matchesReleasePolicy(version, policy)) { + throw new Error( + `Version must stay on the ${policy.versionHint}-${policy.identifier}.N prerelease track.`, + ) + } + if (distTag !== policy.distTag) { + throw new Error(`Release policy requires the ${policy.distTag} dist-tag.`) + } + return + } + if ( + releaseMajor !== 1 || + !STABLE_VERSION.test(version) || + distTag !== 'latest' + ) { + throw new Error( + 'Only the configured prerelease track or a stable v1.x release on the latest dist-tag may be published.', + ) + } +} + +export function npmDistTagForRelease( + version: string, + policy: ReleasePolicy, +): string { + if (policy.mode === 'stable-from-hint') { + if (!matchesReleasePolicy(version, policy)) { + throw new Error( + 'Stable release policy does not allow a prerelease version.', + ) + } + return policy.distTag + } + const policyMajor = Number(policy.versionHint.split('.')[0]) + if (Number(version.split('.')[0]) === policyMajor) { + if (!matchesReleasePolicy(version, policy)) { + throw new Error( + `Version must stay on the ${policy.versionHint}-${policy.identifier}.N prerelease track.`, + ) + } + return policy.distTag + } + if (version.startsWith('1.') && STABLE_VERSION.test(version)) { + return 'latest' + } + throw new Error( + 'Only the configured prerelease track or a stable v1.x release may be published.', + ) +} + +function main(): void { + const version = process.argv[2] + if (!version) { + throw new Error( + 'Usage: node scripts/release/validate-npm-policy.mts ', + ) + } + const policy = readReleasePolicy() + const distTag = npmDistTagForRelease(version, policy) + validateNpmPolicy(version, distTag, policy) + const outputPath = process.env['GITHUB_OUTPUT'] + if (outputPath) { + appendFileSync(outputPath, `dist-tag=${distTag}\n`) + } + process.stdout.write(`Validated ${version} for npm dist-tag ${distTag}.\n`) +} + +if (isMainModule(import.meta.url)) { + runMain(main, { + describe: 'Validates the npm version and dist-tag against release policy.', + help: 'Usage: node scripts/release/validate-npm-policy.mts ', + }) +} diff --git a/scripts/release/version.mts b/scripts/release/version.mts index 81cd2b934d..b9cdfcd7f0 100644 --- a/scripts/release/version.mts +++ b/scripts/release/version.mts @@ -1,7 +1,7 @@ /** * @file Derive the next release version from the Conventional Commits landed * since the last release. Everything here is PURE over its inputs — no git, - * no registry, no filesystem — so `bump.mts` collects the facts and this + * no registry, no filesystem — so `prepare.mts` collects the facts and this * module decides the number. That split is what makes the decision unit * testable. * @@ -20,8 +20,29 @@ import semver from 'semver' +import type { ReleasePolicy } from '../../src/sea/version.mts' + export type BumpLevel = 'major' | 'minor' | 'patch' +export function nextPrereleaseVersion( + policy: Extract, + knownVersions: readonly string[], +): string { + const prefix = `${policy.versionHint}-${policy.identifier}.` + let highest = -1 + for (let i = 0; i < knownVersions.length; i += 1) { + const version = knownVersions[i]! + if (!version.startsWith(prefix)) { + continue + } + const sequence = version.slice(prefix.length) + if (/^(?:0|[1-9]\d*)$/u.test(sequence)) { + highest = Math.max(highest, Number(sequence)) + } + } + return `${prefix}${highest + 1}` +} + export interface ConventionalCommit { readonly breaking: boolean readonly description: string @@ -152,12 +173,31 @@ export function maxReleaseVersion( return best } +/** Highest published version with the exact configured prerelease prefix. */ +export function maxPrereleaseVersion( + versions: readonly string[], + prefix: string, +): string | undefined { + let best: string | undefined + for (let i = 0; i < versions.length; i += 1) { + const version = versions[i]! + if (!version.startsWith(prefix) || !semver.valid(version)) { + continue + } + if (!best || semver.gt(version, best)) { + best = version + } + } + return best +} + export function releaseVersionsForLine( versions: readonly string[], manifestVersion: string, + releaseMajor?: number, ): string[] { - const line = semver.coerce(manifestVersion) - if (!line) { + const major = releaseMajor ?? semver.coerce(manifestVersion)?.major + if (major === undefined) { throw new TypeError( '[version] cannot identify the release line.\n' + ' Where: the root package.json version.\n' + @@ -165,14 +205,22 @@ export function releaseVersionsForLine( ' Fix: restore the release line version in package.json.', ) } + if ( + !Number.isInteger(major) || + major < 0 || + (releaseMajor !== undefined && major < 1) + ) { + throw new TypeError(`Invalid release major: ${String(major)}`) + } return versions.filter(version => { const parsed = semver.parse(version) - return parsed?.major === line.major && parsed.prerelease.length === 0 + return parsed?.major === major && parsed.prerelease.length === 0 }) } export interface BumpBaseConfig { readonly manifestVersion: string + readonly releaseMajor?: number | undefined readonly publishedVersion?: string | undefined // Landed release tags belonging to this major line. readonly tagVersions?: readonly string[] | undefined @@ -186,9 +234,8 @@ export interface BumpBaseConfig { * authority has anything to say. * * The registry's `latest` is a repo-wide signal, not a per-line one, so it only - * counts when it shares a major with the manifest. That keeps a - * maintenance branch anchored to its own line even while a newer major owns the - * dist-tag, and still lets `latest` cover a release whose tag went missing. + * counts when it shares the selected release major. That keeps a maintenance + * branch anchored to its own line even while a newer major owns the dist-tag. */ export function resolveBumpBase(config: BumpBaseConfig): string { const cfg = { __proto__: null, ...config } as BumpBaseConfig @@ -200,9 +247,12 @@ export function resolveBumpBase(config: BumpBaseConfig): string { ...(cfg.publishedVersion ? [cfg.publishedVersion] : []), ], cfg.manifestVersion, + cfg.releaseMajor, ), ) ?? - semver.valid(semver.coerce(cfg.manifestVersion) ?? '') ?? + (cfg.releaseMajor + ? `${cfg.releaseMajor}.0.0` + : semver.valid(semver.coerce(cfg.manifestVersion) ?? '')) ?? '0.0.0' ) } @@ -210,6 +260,7 @@ export function resolveBumpBase(config: BumpBaseConfig): string { export interface DeriveNextVersionConfig { readonly commits: readonly ConventionalCommit[] readonly manifestVersion: string + readonly releaseMajor?: number | undefined readonly publishedVersion?: string | undefined readonly releaseAs?: string | undefined readonly reservedVersions?: readonly string[] | undefined @@ -242,6 +293,7 @@ export function deriveNextVersion( const cfg = { __proto__: null, ...config } as DeriveNextVersionConfig const base = resolveBumpBase({ manifestVersion: cfg.manifestVersion, + releaseMajor: cfg.releaseMajor, publishedVersion: cfg.publishedVersion, tagVersions: cfg.tagVersions, }) @@ -265,7 +317,11 @@ export function deriveNextVersion( ? `derived ${derived} from ${cfg.commits.length} conventional commit(s) since ${base}` : `no user-visible commits since ${base} — patch by default` const reserved = maxReleaseVersion( - releaseVersionsForLine(cfg.reservedVersions ?? [], cfg.manifestVersion), + releaseVersionsForLine( + cfg.reservedVersions ?? [], + cfg.manifestVersion, + cfg.releaseMajor, + ), ) const candidate = semver.inc(base, level)! const version = diff --git a/scripts/test/apple-signing.mts b/scripts/test/apple-signing.mts new file mode 100644 index 0000000000..8e7c4163d7 --- /dev/null +++ b/scripts/test/apple-signing.mts @@ -0,0 +1,397 @@ +import { spawnSync } from 'node:child_process' +import { createHash, randomBytes } from 'node:crypto' +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import type { SpawnSyncOptions } from 'node:child_process' + +export type SigningReceipt = { + mode: 'unsigned-local' | 'unsigned-ci' | 'developer-id' + sourceSha256: string + stagedSha256: string + teamId?: string + authority?: string +} + +export async function persistNativeSigningReceipt( + receipt: SigningReceipt, + receiptPath: string, +): Promise { + const safeReceipt = { + mode: receipt.mode, + sourceSha256: receipt.sourceSha256, + stagedSha256: receipt.stagedSha256, + } + await fs.mkdir(path.dirname(receiptPath), { recursive: true }) + await fs.writeFile(receiptPath, `${JSON.stringify(safeReceipt)}\n`, { + mode: 0o600, + }) +} +type CommandResult = { + status: number | null + stdout: string + stderr: string + error?: Error +} +type Runner = ( + command: string, + args: readonly string[], + options?: SpawnSyncOptions, +) => CommandResult +const run: Runner = (command, args, options = {}) => { + const result = spawnSync(command, [...args], { + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 65_536, + stdio: ['ignore', 'pipe', 'pipe'], + ...options, + }) + return { + status: result.status, + stdout: String(result.stdout ?? ''), + stderr: String(result.stderr ?? ''), + ...(result.error ? { error: result.error } : {}), + } +} +const digest = async (file: string) => + createHash('sha256') + .update(await fs.readFile(file)) + .digest('hex') +const genericFailure = ( + command: string, + args: readonly string[], + result: CommandResult, +) => { + if (result.error) { + throw new Error(`${command} ${args[0] ?? ''} could not run.`) + } + if (result.status !== 0) { + throw new Error(`${command} ${args[0] ?? ''} failed.`) + } +} + +function safeCodesignFailure( + args: readonly string[], + result: CommandResult, + secretValues: readonly string[], +): Error { + let diagnostic = result.stderr || result.stdout + for (const secret of secretValues) { + if (secret.length > 0) { + diagnostic = diagnostic.replaceAll(secret, '[REDACTED]') + } + } + diagnostic = Array.from(diagnostic) + .filter(character => { + const code = character.charCodeAt(0) + return code >= 0x20 && code !== 0x7f + }) + .join('') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 1200) + const status = + result.status === null ? 'no exit status' : `exit ${result.status}` + return new Error( + `codesign ${args[0] ?? 'command'} failed (${status})${diagnostic ? `: ${diagnostic}` : '.'}`, + ) +} + +export async function prepareNativeTestBinary(options: { + binaryPath: string + sourceSha256: string + platform?: string + env?: NodeJS.ProcessEnv + runner?: Runner +}): Promise { + const { binaryPath, sourceSha256 } = options + const platform = options.platform ?? process.platform + const env = options.env ?? process.env + const runner = options.runner ?? run + if ((await digest(binaryPath)) !== sourceSha256) { + throw new Error('Staged native binary changed before signing.') + } + const identity = env['APPLE_DEVELOPER_ID_IDENTITY']?.trim() + const teamId = env['APPLE_TEAM_ID']?.trim() + const certificate = env['APPLE_DEVELOPER_ID_APPLICATION_CERTS_P12'] + const password = env['APPLE_DEVELOPER_ID_APPLICATION_CERTS_P12_PASS'] + const configured = Boolean(identity || teamId || certificate || password) + const required = env['SOCKET_CLI_NATIVE_TEST_REQUIRE_APPLE_SIGNATURE'] === '1' + if (configured && (!teamId || !certificate?.trim() || !password?.trim())) { + throw new Error( + 'Apple signing credentials are incomplete (APPLE_DEVELOPER_ID_APPLICATION_CERTS_P12, APPLE_DEVELOPER_ID_APPLICATION_CERTS_P12_PASS, and APPLE_TEAM_ID are required).', + ) + } + if (required && !configured) { + throw new Error('Required Apple signing credentials are missing.') + } + if (!configured) { + return { + mode: + env['CI'] === 'true' || env['CI'] === '1' + ? 'unsigned-ci' + : 'unsigned-local', + sourceSha256, + stagedSha256: sourceSha256, + } + } + if (platform !== 'darwin') { + throw new Error( + 'Apple signing credentials were configured on a non-macOS host.', + ) + } + if (!/^[A-Z0-9]{10}$/.test(teamId!)) { + throw new Error('APPLE_TEAM_ID is invalid.') + } + + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'socket-native-sign-'), + ) + await fs.chmod(tempDir, 0o700) + const keychain = path.join(tempDir, 'signing.keychain-db') + const p12Path = path.join(tempDir, 'certificate.p12') + const entitlementsPath = path.join(tempDir, 'entitlements.plist') + const keychainPassword = randomBytes(32).toString('base64url') + const commandEnv = { ...env } + for (const key of Object.keys(commandEnv)) { + if (/^APPLE_/.test(key)) { + delete commandEnv[key] + } + } + const invoke = (command: string, args: string[]) => + runner(command, args, { + env: commandEnv, + timeout: 120_000, + maxBuffer: 65_536, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + let keychainCreated = false + let keychainSearchListChanged = false + let originalKeychains: string[] = [] + let authority = '' + let signingError: Error | undefined + try { + await fs.writeFile(p12Path, Buffer.from(certificate!, 'base64'), { + mode: 0o600, + }) + await fs.writeFile( + entitlementsPath, + [ + '', + '', + '', + 'com.apple.security.cs.allow-jit', + 'com.apple.security.cs.allow-unsigned-executable-memory', + '', + ].join('\n'), + { mode: 0o600 }, + ) + const originalListArgs = ['list-keychains', '-d', 'user'] + const originalList = invoke('security', originalListArgs) + genericFailure('security', originalListArgs, originalList) + originalKeychains = [ + ...originalList.stdout.matchAll(/^\s*"([^"]+)"\s*$/gm), + ].map(match => match[1]!) + if (originalList.stdout.trim() && originalKeychains.length === 0) { + throw new Error( + 'Could not parse the original Apple keychain search list.', + ) + } + keychainSearchListChanged = true + const createArgs = ['create-keychain', '-p', keychainPassword, keychain] + const create = invoke('security', createArgs) + genericFailure('security', createArgs, create) + keychainCreated = true + for (const args of [ + ['unlock-keychain', '-p', keychainPassword, keychain], + ['set-keychain-settings', '-lut', '3600', keychain], + [ + 'import', + p12Path, + '-k', + keychain, + '-P', + password!, + '-T', + '/usr/bin/codesign', + ], + [ + 'set-key-partition-list', + '-S', + 'apple-tool:,apple:,codesign:', + '-s', + '-k', + keychainPassword, + keychain, + ], + ]) { + genericFailure('security', args, invoke('security', args)) + } + const findArgs = ['find-identity', '-v', '-p', 'codesigning', keychain] + const list = invoke('security', findArgs) + genericFailure('security', findArgs, list) + const rows = [ + ...list.stdout.matchAll( + /^\s*\d+\)\s+([A-Fa-f0-9]{40})\s+"([^"]+)"\s*$/gm, + ), + ] + .map(([, hash, name]) => ({ hash: hash!, name: name! })) + .filter( + row => + row.name.startsWith('Developer ID Application: ') && + row.name.endsWith(`(${teamId})`), + ) + if (!identity && rows.length !== 1) { + throw new Error( + 'Temporary keychain does not contain one unambiguous Developer ID identity.', + ) + } + const candidates = identity + ? rows.filter( + row => + row.hash.toLowerCase() === identity.toLowerCase() || + row.name === identity, + ) + : rows + if (candidates.length !== 1) { + throw new Error( + 'Configured Developer ID identity is unavailable or ambiguous in temporary keychain.', + ) + } + const selected = candidates[0]! + const searchListArgs = [ + 'list-keychains', + '-d', + 'user', + '-s', + keychain, + ...originalKeychains, + ] + genericFailure( + 'security', + searchListArgs, + invoke('security', searchListArgs), + ) + const signArgs = [ + '--force', + '--sign', + selected.hash, + '--keychain', + keychain, + '--options', + 'runtime', + '--entitlements', + entitlementsPath, + '--timestamp', + binaryPath, + ] + const signed = invoke('codesign', signArgs) + if (signed.error || signed.status !== 0) { + throw safeCodesignFailure(signArgs, signed, [ + keychainPassword, + password!, + certificate!, + identity ?? '', + teamId!, + ]) + } + const verifyArgs = ['--verify', '--strict', binaryPath] + genericFailure('codesign', verifyArgs, invoke('codesign', verifyArgs)) + const displayArgs = [ + '--display', + '--verbose=4', + '--entitlements', + ':-', + binaryPath, + ] + const display = invoke('codesign', displayArgs) + genericFailure('codesign', displayArgs, display) + const evidence = `${display.stdout}\n${display.stderr}` + authority = + evidence.match(/^Authority=(Developer ID Application: .+)$/m)?.[1] ?? '' + const signedTeam = evidence.match(/^TeamIdentifier=(.+)$/m)?.[1] + const runtime = /^CodeDirectory .*flags=.*\(.*runtime.*\)/m.test(evidence) + const hasJit = + /com\.apple\.security\.cs\.allow-jit<\/key>\s*/.test( + evidence, + ) + const hasUnsignedMemory = + /com\.apple\.security\.cs\.allow-unsigned-executable-memory<\/key>\s*/.test( + evidence, + ) + if ( + /^Signature=adhoc$/m.test(evidence) || + authority !== selected.name || + signedTeam !== teamId || + !runtime || + !hasJit || + !hasUnsignedMemory + ) { + throw new Error( + 'Native binary does not have the configured hardened Developer ID Application signature.', + ) + } + } catch (error) { + signingError = error instanceof Error ? error : new Error(String(error)) + } + + let cleanupError: Error | undefined + if (keychainSearchListChanged) { + try { + const args = ['list-keychains', '-d', 'user', '-s', ...originalKeychains] + const result = invoke('security', args) + if (result.error || result.status !== 0) { + cleanupError = new Error( + 'Could not restore the Apple keychain search list.', + ) + } + } catch { + cleanupError = new Error( + 'Could not restore the Apple keychain search list.', + ) + } + } + if (keychainCreated) { + try { + const args = ['delete-keychain', keychain] + const result = invoke('security', args) + if (result.error || result.status !== 0) { + cleanupError ??= new Error( + 'Could not delete temporary Apple signing keychain.', + ) + } + } catch { + cleanupError ??= new Error( + 'Could not delete temporary Apple signing keychain.', + ) + } + } + try { + await fs.rm(tempDir, { recursive: true, force: true }) + } catch { + cleanupError ??= new Error( + 'Could not remove temporary Apple signing files.', + ) + } + if (signingError && cleanupError) { + throw new AggregateError( + [signingError, cleanupError], + 'Apple signing failed and temporary signing resources could not be fully cleaned up.', + ) + } + if (signingError) { + throw signingError + } + if (cleanupError) { + throw cleanupError + } + return { + mode: 'developer-id', + sourceSha256, + stagedSha256: await digest(binaryPath), + teamId: teamId!, + authority, + } +} diff --git a/scripts/test/integration.mts b/scripts/test/integration.mts new file mode 100644 index 0000000000..00f85d46b6 --- /dev/null +++ b/scripts/test/integration.mts @@ -0,0 +1,158 @@ +import { spawnSync } from 'node:child_process' +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { isMainModule } from '../lib/is-main-module.mts' +import { runMain } from '../lib/run-main.mts' + +import type { ScriptMeta } from '../lib/run-main.mts' +import type { SpawnSyncOptions } from 'node:child_process' + +const ROOT = path.resolve(import.meta.dirname, '../..') +const ISOLATED_ENV_KEYS = [ + 'HOME', + 'USERPROFILE', + 'LOCALAPPDATA', + 'APPDATA', + 'XDG_CACHE_HOME', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'npm_config_cache', + 'npm_config_cache_dir', + 'npm_config_store_dir', + 'npm_config_userconfig', + 'npm_config_globalconfig', + 'PNPM_HOME', + 'TMPDIR', + 'TMP', + 'TEMP', +] as const + +function run( + command: string, + args: readonly string[], + options: SpawnSyncOptions = {}, +): void { + const result = spawnSync(command, args, { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + ...options, + }) + if (result.error) { + throw result.error + } + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with ${result.status}`) + } +} + +function runPnpm(args: readonly string[]): void { + const pnpmScript = process.env['npm_execpath'] + if (pnpmScript && path.isAbsolute(pnpmScript)) { + if (/\.[cm]?js$/i.test(pnpmScript)) { + run(process.execPath, [pnpmScript, ...args]) + } else { + run(pnpmScript, args) + } + return + } + run( + process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', + args, + process.platform === 'win32' ? { shell: true } : {}, + ) +} + +async function isolateCaches(stageDir: string): Promise { + const home = path.join(stageDir, 'home') + const directories = { + HOME: home, + USERPROFILE: home, + LOCALAPPDATA: path.join(home, 'AppData', 'Local'), + APPDATA: path.join(home, 'AppData', 'Roaming'), + XDG_CACHE_HOME: path.join(stageDir, 'xdg-cache'), + XDG_CONFIG_HOME: path.join(stageDir, 'xdg-config'), + XDG_DATA_HOME: path.join(stageDir, 'xdg-data'), + npm_config_cache: path.join(stageDir, 'npm-cache'), + npm_config_cache_dir: path.join(stageDir, 'pnpm-cache'), + npm_config_store_dir: path.join(stageDir, 'pnpm-store'), + npm_config_userconfig: path.join(stageDir, 'npmrc'), + npm_config_globalconfig: path.join(stageDir, 'global-npmrc'), + PNPM_HOME: path.join(stageDir, 'pnpm-home'), + TMPDIR: path.join(stageDir, 'tmp'), + TMP: path.join(stageDir, 'tmp'), + TEMP: path.join(stageDir, 'tmp'), + } + for (const [key, value] of Object.entries(directories)) { + process.env[key] = value + } + await Promise.all( + Object.values(directories) + .filter(value => !value.endsWith('npmrc')) + .map(directory => fs.mkdir(directory, { recursive: true })), + ) + await Promise.all([ + fs.writeFile(directories.npm_config_userconfig, ''), + fs.writeFile(directories.npm_config_globalconfig, ''), + ]) +} + +async function main(): Promise { + const vitestArgs = process.argv.slice(2) + const stageDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'socket-integration-test-'), + ) + const previousEnv = Object.fromEntries( + ISOLATED_ENV_KEYS.map(key => [key, process.env[key]]), + ) as Record<(typeof ISOLATED_ENV_KEYS)[number], string | undefined> + + try { + // The package matrix leaves a legacy package's dist behind; integration + // coverage must always exercise the current root package build. + runPnpm(['build:dist']) + await isolateCaches(stageDir) + runPnpm([ + 'exec', + 'dotenvx', + '-q', + 'run', + '-f', + '.env.test', + '--', + 'vitest', + 'run', + '--config', + '.config/vitest.integration.config.mts', + ...vitestArgs, + ]) + return 0 + } finally { + for (const key of ISOLATED_ENV_KEYS) { + const value = previousEnv[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + await fs.rm(stageDir, { recursive: true, force: true }) + } +} + +const SCRIPT_META: ScriptMeta = { + describe: + 'builds the root distribution and runs serialized CLI integration tests', + help: `Usage: pnpm run integration-tests [vitest options] + + Builds the root Socket package first, then runs process-level integration + tests serially with isolated npm, pnpm, and Socket configuration caches. + + Pass Vitest options after the script name to filter tests.`, +} + +if (isMainModule(import.meta.url)) { + runMain(main, SCRIPT_META) +} diff --git a/scripts/test/native.mts b/scripts/test/native.mts new file mode 100644 index 0000000000..2cb1184b4c --- /dev/null +++ b/scripts/test/native.mts @@ -0,0 +1,368 @@ +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { + persistNativeSigningReceipt, + prepareNativeTestBinary, +} from './apple-signing.mts' +import { assertSnapshotDependenciesCurrent } from './snapshot-dependencies.mts' +import { isMainModule } from '../lib/is-main-module.mts' +import { runMain } from '../lib/run-main.mts' + +import type { ScriptMeta } from '../lib/run-main.mts' +import type { SpawnSyncOptions } from 'node:child_process' + +type NativeManifest = { + application: { name: string; version: string } + target: { triple: string } + payloads: Array<{ id: string; sha256?: string; version?: string }> + capabilities: { available: string[] } +} + +const ROOT = path.resolve(import.meta.dirname, '../..') +const PACKAGE_PATH = path.join(ROOT, 'package.json') +type PackageMetadata = { + name: string + version: string +} +const BUILD_DIR = path.join(ROOT, 'build/sea') +const TRIPLE = `${process.platform === 'win32' ? 'win' : process.platform}-${process.arch}` +const BUILT_BINARY = path.join( + BUILD_DIR, + `socket-${TRIPLE}${process.platform === 'win32' ? '.exe' : ''}`, +) +const BUILT_MANIFEST = path.join(BUILD_DIR, `socket-${TRIPLE}.manifest.json`) + +function run( + command: string, + args: readonly string[], + options: SpawnSyncOptions = {}, +): void { + const result = spawnSync(command, args, { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + ...options, + }) + if (result.error) { + throw result.error + } + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with ${result.status}`) + } +} + +async function verifyArtifact( + packageInfo: PackageMetadata, + binaryPath: string, + manifestPath: string, +) { + const [binaryStat, manifestText] = await Promise.all([ + fs.stat(binaryPath), + fs.readFile(manifestPath, 'utf8'), + ]) + if (!binaryStat.isFile()) { + throw new Error(`Native CLI is not a file: ${binaryPath}`) + } + if (process.platform !== 'win32' && (binaryStat.mode & 0o111) === 0) { + throw new Error(`Native CLI is not executable: ${binaryPath}`) + } + const manifest = JSON.parse(manifestText) as NativeManifest + if ( + manifest.application?.name !== packageInfo.name || + manifest.application?.version !== packageInfo.version + ) { + throw new Error( + `Native manifest is for ${manifest.application?.name}@${manifest.application?.version}; expected ${packageInfo.name}@${packageInfo.version}`, + ) + } + if (manifest.target?.triple !== TRIPLE) { + throw new Error( + `Native manifest target ${manifest.target?.triple} does not match host ${TRIPLE}`, + ) + } + if (!manifest.capabilities?.available?.includes('socket-core')) { + throw new Error('Native manifest does not advertise socket-core') + } + const executablePayload = manifest.payloads.find( + payload => payload.id === 'socket-vfs', + ) + if (!executablePayload?.sha256) { + throw new Error('Native manifest does not record the executable digest') + } + const executableDigest = createHash('sha256') + .update(await fs.readFile(binaryPath)) + .digest('hex') + if (executableDigest !== executablePayload.sha256) { + throw new Error('Native executable digest does not match its manifest') + } + return manifest +} + +function runPnpm(args: readonly string[], env = process.env): void { + const pnpmScript = process.env['npm_execpath'] + if (pnpmScript && path.isAbsolute(pnpmScript)) { + if (/\.[cm]?js$/i.test(pnpmScript)) { + run(process.execPath, [pnpmScript, ...args], { env }) + } else { + run(pnpmScript, args, { env }) + } + return + } + run(process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', args, { + ...(process.platform === 'win32' ? { shell: true } : {}), + env, + }) +} + +async function main(): Promise { + const packageInfo = JSON.parse( + await fs.readFile(PACKAGE_PATH, 'utf8'), + ) as PackageMetadata + const userArgs = process.argv.slice(2) + const build = userArgs.includes('--build') + const vitestArgs = userArgs.filter( + arg => arg !== '--build' && arg !== '--require-apple-signature', + ) + const updatesSnapshots = vitestArgs.some( + arg => arg === '-u' || arg === '--update', + ) + if ( + !updatesSnapshots && + vitestArgs.some(arg => arg.endsWith('cli.native.test.mts')) + ) { + await assertSnapshotDependenciesCurrent() + } + const requireSignature = + userArgs.includes('--require-apple-signature') || + process.env['SOCKET_CLI_NATIVE_TEST_REQUIRE_APPLE_SIGNATURE'] === '1' + const stageRoot = process.env['RUNNER_TEMP'] + ? path.resolve(process.env['RUNNER_TEMP']) + : os.tmpdir() + const stageDir = await fs.mkdtemp(path.join(stageRoot, 'sn-')) + const artifactsRoot = path.join(ROOT, 'build/native-test-artifacts') + await fs.mkdir(artifactsRoot, { recursive: true }) + const artifactsDir = process.env['SOCKET_CLI_NATIVE_TEST_ARTIFACTS'] + ? path.resolve(process.env['SOCKET_CLI_NATIVE_TEST_ARTIFACTS']) + : await fs.mkdtemp( + path.join(artifactsRoot, `${process.platform}-${process.arch}-`), + ) + await fs.mkdir(artifactsDir, { recursive: true }) + const receiptPath = path.join(artifactsDir, 'signing-receipt.json') + const binaryPath = path.join( + stageDir, + `socket-${TRIPLE}${process.platform === 'win32' ? '.exe' : ''}`, + ) + const manifestPath = path.join(stageDir, `socket-${TRIPLE}.manifest.json`) + const oldEnv = { + SOCKET_CLI_NATIVE_TEST_BINARY: process.env['SOCKET_CLI_NATIVE_TEST_BINARY'], + SOCKET_CLI_NATIVE_TEST_STAGE: process.env['SOCKET_CLI_NATIVE_TEST_STAGE'], + SOCKET_CLI_NATIVE_TEST_CAPABILITIES: + process.env['SOCKET_CLI_NATIVE_TEST_CAPABILITIES'], + SOCKET_CLI_NATIVE_TEST_VERSION: + process.env['SOCKET_CLI_NATIVE_TEST_VERSION'], + SOCKET_CLI_NATIVE_TEST_NODE_VERSION: + process.env['SOCKET_CLI_NATIVE_TEST_NODE_VERSION'], + SOCKET_CLI_NATIVE_TEST_UPDATE_SNAPSHOTS: + process.env['SOCKET_CLI_NATIVE_TEST_UPDATE_SNAPSHOTS'], + SOCKET_CLI_NATIVE_TEST_ARTIFACTS: + process.env['SOCKET_CLI_NATIVE_TEST_ARTIFACTS'], + SOCKET_CLI_NATIVE_TEST_RECEIPT_PATH: + process.env['SOCKET_CLI_NATIVE_TEST_RECEIPT_PATH'], + HOME: process.env['HOME'], + USERPROFILE: process.env['USERPROFILE'], + XDG_CACHE_HOME: process.env['XDG_CACHE_HOME'], + npm_config_cache: process.env['npm_config_cache'], + npm_config_store_dir: process.env['npm_config_store_dir'], + PNPM_HOME: process.env['PNPM_HOME'], + npm_config_cache_dir: process.env['npm_config_cache_dir'], + npm_config_userconfig: process.env['npm_config_userconfig'], + npm_config_globalconfig: process.env['npm_config_globalconfig'], + XDG_CONFIG_HOME: process.env['XDG_CONFIG_HOME'], + XDG_DATA_HOME: process.env['XDG_DATA_HOME'], + LOCALAPPDATA: process.env['LOCALAPPDATA'], + APPDATA: process.env['APPDATA'], + TMPDIR: process.env['TMPDIR'], + TMP: process.env['TMP'], + TEMP: process.env['TEMP'], + } + + try { + process.env['SOCKET_CLI_NATIVE_TEST_ARTIFACTS'] = artifactsDir + process.env['SOCKET_CLI_NATIVE_TEST_RECEIPT_PATH'] = receiptPath + if (build) { + runPnpm( + ['build:sea', '--', '--prototype'], + sanitizeNativeEnvironment(process.env), + ) + } + + // Snapshot the verified artifact into this run's private stage so another + // build cannot replace the executable after validation. + await Promise.all([ + fs.copyFile(BUILT_BINARY, binaryPath), + fs.copyFile(BUILT_MANIFEST, manifestPath), + ]) + if (process.platform !== 'win32') { + await fs.chmod(binaryPath, 0o700) + } + const manifest = await verifyArtifact(packageInfo, binaryPath, manifestPath) + const sourceDigest = manifest.payloads.find( + payload => payload.id === 'socket-vfs', + )!.sha256! + const signingEnv = { + ...process.env, + ...(requireSignature + ? { SOCKET_CLI_NATIVE_TEST_REQUIRE_APPLE_SIGNATURE: '1' } + : {}), + } + const receipt = await prepareNativeTestBinary({ + binaryPath, + sourceSha256: sourceDigest, + env: signingEnv, + }) + await persistNativeSigningReceipt(receipt, receiptPath) + + process.env['SOCKET_CLI_NATIVE_TEST_BINARY'] = binaryPath + process.env['SOCKET_CLI_NATIVE_TEST_STAGE'] = stageDir + process.env['SOCKET_CLI_NATIVE_TEST_CAPABILITIES'] = + manifest.capabilities.available.join(',') + process.env['SOCKET_CLI_NATIVE_TEST_VERSION'] = packageInfo.version + const nodeRuntime = manifest.payloads.find( + payload => payload.id === 'node-runtime', + ) + if (!nodeRuntime?.version) { + throw new Error( + 'Native manifest does not record the Node runtime version', + ) + } + process.env['SOCKET_CLI_NATIVE_TEST_NODE_VERSION'] = nodeRuntime.version + process.env['SOCKET_CLI_NATIVE_TEST_UPDATE_SNAPSHOTS'] = String( + updatesSnapshots, + ) + // Give this run private package-manager caches so npm npx and pnpm dlx + // never contend with each other or other local/test-runner activity. + process.env['HOME'] = path.join(stageDir, 'home') + process.env['USERPROFILE'] = path.join(stageDir, 'home') + process.env['XDG_CACHE_HOME'] = path.join(stageDir, 'xdg-cache') + process.env['npm_config_cache'] = path.join(stageDir, 'npm-cache') + process.env['npm_config_store_dir'] = path.join(stageDir, 'pnpm-store') + process.env['npm_config_cache_dir'] = path.join(stageDir, 'pnpm-cache') + process.env['npm_config_userconfig'] = path.join(stageDir, 'npmrc') + process.env['npm_config_globalconfig'] = path.join(stageDir, 'global-npmrc') + process.env['PNPM_HOME'] = path.join(stageDir, 'pnpm-home') + process.env['XDG_CONFIG_HOME'] = path.join(stageDir, 'xdg-config') + process.env['XDG_DATA_HOME'] = path.join(stageDir, 'xdg-data') + process.env['LOCALAPPDATA'] = path.join(stageDir, 'AppData', 'Local') + process.env['APPDATA'] = path.join(stageDir, 'AppData', 'Roaming') + process.env['TMPDIR'] = path.join(stageDir, 'tmp') + process.env['TMP'] = path.join(stageDir, 'tmp') + process.env['TEMP'] = path.join(stageDir, 'tmp') + await Promise.all( + [ + 'home', + 'xdg-cache', + 'npm-cache', + 'pnpm-store', + 'pnpm-cache', + 'pnpm-home', + 'xdg-config', + 'xdg-data', + 'AppData/Local', + 'AppData/Roaming', + 'tmp', + ].map(directory => + fs.mkdir(path.join(stageDir, directory), { recursive: true }), + ), + ) + await Promise.all([ + fs.writeFile(process.env['npm_config_userconfig']!, ''), + fs.writeFile(process.env['npm_config_globalconfig']!, ''), + ]) + const childEnv = sanitizeNativeEnvironment(process.env) + const version = spawnSync(binaryPath, ['--version'], { + cwd: ROOT, + env: childEnv, + encoding: 'utf8', + timeout: 30_000, + }) + if (version.error) { + throw version.error + } + if ( + version.status !== 0 || + !version.stdout.trim().startsWith(packageInfo.version) + ) { + throw new Error( + `Native --version check failed (status ${version.status}): ${version.stdout}${version.stderr}`, + ) + } + console.log(`Native test binary: ${binaryPath}`) + console.log( + `Native capabilities: ${manifest.capabilities.available.join(', ')}`, + ) + childEnv['SOCKET_CLI_NATIVE_TEST_SIGNING_RECEIPT'] = JSON.stringify(receipt) + runPnpm( + [ + 'exec', + 'vitest', + 'run', + '--config', + '.config/vitest.native.config.mts', + ...vitestArgs, + ], + childEnv, + ) + return 0 + } finally { + for (const [key, value] of Object.entries(oldEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + await fs.rm(stageDir, { recursive: true, force: true }) + } +} + +function sanitizeNativeEnvironment( + source: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + const env = { ...source } + for (const key of Object.keys(env)) { + if ( + /^(APPLE_|NPM_TOKEN$|NODE_AUTH_TOKEN$|GITHUB_TOKEN$|GH_TOKEN$|AWS_|AZURE_|GOOGLE_)/i.test( + key, + ) || + (/^(SOCKET_|SOCKETCLI_)/i.test(key) && + !/^SOCKET_CLI_NATIVE_TEST_/.test(key)) || + /(^|_)(TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|AUTHORIZATION)(_|$)/i.test( + key, + ) + ) { + delete env[key] + } + } + return env +} + +const SCRIPT_META: ScriptMeta = { + describe: 'runs integration tests against the host-native Socket SEA binary', + help: `Usage: pnpm run test:native [vitest options] + pnpm run test:native:build [vitest options] + + The native lane requires a matching build/sea/socket-- binary + and manifest. The :build variant creates the prototype first. Native test + package-manager caches are isolated and test files run serially. + + Pass Vitest options after the script name to filter test files or tests.`, +} + +if (isMainModule(import.meta.url)) { + runMain(main, SCRIPT_META) +} diff --git a/scripts/test/snapshot-dependencies.mts b/scripts/test/snapshot-dependencies.mts new file mode 100644 index 0000000000..d8463cd722 --- /dev/null +++ b/scripts/test/snapshot-dependencies.mts @@ -0,0 +1,99 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' + +const ROOT = path.resolve(import.meta.dirname, '../..') + +export interface SnapshotDependency { + readonly packageName: string + readonly source: string + readonly versionPattern: RegExp + readonly snapshots: readonly string[] + readonly testNamePattern: string +} + +export const SNAPSHOT_DEPENDENCIES = { + coana: { + packageName: '@coana-tech/cli', + source: 'src/constants.mts', + versionPattern: /const COANA_CLI_VERSION = ['"]([^'"]+)['"]/, + snapshots: [ + 'test/native-snapshots/socket-help-fix.help.svg', + 'test/native-snapshots/socket-help-scan-create.help.svg', + 'test/native-snapshots/socket-help-scan-reach.help.svg', + ], + testNamePattern: 'captures native help for (fix|scan create|scan reach)', + }, +} as const satisfies Record + +export type SnapshotDependencyName = keyof typeof SNAPSHOT_DEPENDENCIES + +export interface StaleSnapshotDependency { + readonly dependencyName: SnapshotDependencyName + readonly packageName: string + readonly version: string + readonly snapshots: readonly string[] +} + +export async function findStaleSnapshotDependencies(): Promise< + StaleSnapshotDependency[] +> { + const findings = await Promise.all( + Object.entries(SNAPSHOT_DEPENDENCIES).map(async ([name, dependency]) => { + const source = await fs.readFile( + path.join(ROOT, dependency.source), + 'utf8', + ) + const version = dependency.versionPattern.exec(source)?.[1] + if (!version) { + throw new Error( + `Could not read ${dependency.packageName} version from ${dependency.source}`, + ) + } + const snapshots = await Promise.all( + dependency.snapshots.map(async snapshot => ({ + path: snapshot, + contents: await fs.readFile(path.join(ROOT, snapshot), 'utf8'), + })), + ) + const staleSnapshots = snapshots + .filter(snapshot => !snapshot.contents.includes(version)) + .map(snapshot => snapshot.path) + if (staleSnapshots.length) { + return { + dependencyName: name as SnapshotDependencyName, + packageName: dependency.packageName, + version, + snapshots: staleSnapshots, + } + } + return undefined + }), + ) + const stale: StaleSnapshotDependency[] = [] + for (const finding of findings) { + if (finding) { + stale.push(finding) + } + } + return stale +} + +export function snapshotDependencyUpdateHint( + stale: readonly StaleSnapshotDependency[], +): string { + return stale + .map( + dependency => + `Snapshot output for ${dependency.packageName}@${dependency.version} is stale:\n` + + dependency.snapshots.map(snapshot => ` - ${snapshot}`).join('\n') + + `\nRun: pnpm snapshots:update:dep ${dependency.dependencyName}`, + ) + .join('\n\n') +} + +export async function assertSnapshotDependenciesCurrent(): Promise { + const stale = await findStaleSnapshotDependencies() + if (stale.length) { + throw new Error(snapshotDependencyUpdateHint(stale)) + } +} diff --git a/scripts/test/snapshots.mts b/scripts/test/snapshots.mts new file mode 100644 index 0000000000..a141568eb5 --- /dev/null +++ b/scripts/test/snapshots.mts @@ -0,0 +1,283 @@ +import { spawnSync } from 'node:child_process' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { + SNAPSHOT_DEPENDENCIES, + assertSnapshotDependenciesCurrent, +} from './snapshot-dependencies.mts' +import { isMainModule } from '../lib/is-main-module.mts' +import { runMain } from '../lib/run-main.mts' + +import type { SnapshotDependencyName } from './snapshot-dependencies.mts' +import type { ScriptMeta } from '../lib/run-main.mts' +import type { SpawnSyncOptions } from 'node:child_process' + +const ROOT = path.resolve(import.meta.dirname, '../..') +const ARTIFACTS_ROOT = path.join(ROOT, 'build/native-test-artifacts') +const LOCK_DIR = path.join(ARTIFACTS_ROOT, '.snapshot-run-lock') +const SNAPSHOT_TESTS = [ + 'test/native/commands/cli.native.test.mts', + 'test/native/commands/cli-behavior.native.test.mts', + 'test/native/commands/tui.native.test.mts', +] as const + +export interface SnapshotOptions { + readonly update: boolean + readonly dependencyName?: SnapshotDependencyName +} + +function missingSnapshotDependencyError(): Error { + return new Error( + 'Provide a dependency name. Usage: pnpm snapshots:update:dep . ' + + 'Current example: pnpm snapshots:update:dep coana. ' + + 'Partner names to map: socket-patch, socket-python-cli. ' + + `Currently mapped: ${Object.keys(SNAPSHOT_DEPENDENCIES).join(', ')}.`, + ) +} + +export function parseSnapshotArgs(args: readonly string[]): SnapshotOptions { + let update = false + let dependencyName: SnapshotDependencyName | undefined + for (let index = 0; index < args.length; index++) { + const arg = args[index]! + if (arg === '--update' || arg === '-u') { + update = true + } else if (arg === '--dep' || arg.startsWith('--dep=')) { + const value = arg === '--dep' ? args[++index] : arg.slice('--dep='.length) + if (!value?.trim()) { + throw missingSnapshotDependencyError() + } + if (!(value in SNAPSHOT_DEPENDENCIES)) { + throw new Error( + `Unknown snapshot dependency: ${value}; available: ${Object.keys(SNAPSHOT_DEPENDENCIES).join(', ')}`, + ) + } + if (dependencyName) { + throw new Error('Pass only one --dep option') + } + dependencyName = value as SnapshotDependencyName + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + if (dependencyName && !update) { + throw new Error('--dep can only be used with --update') + } + return dependencyName ? { update, dependencyName } : { update } +} + +export function nativeSnapshotArgs(update: boolean): string[] { + return ['--build', ...SNAPSHOT_TESTS, ...(update ? ['--update'] : [])] +} + +export function dependencySnapshotArgs( + dependencyName: SnapshotDependencyName, +): string[] { + const config = SNAPSHOT_DEPENDENCIES[dependencyName] + return [ + '--build', + 'test/native/commands/cli.native.test.mts', + '--testNamePattern', + config.testNamePattern, + '--update', + ] +} + +export function terminalSnapshotArgs(update: boolean): string[] { + return [ + 'node_modules/vitest/vitest.mjs', + 'run', + '--config', + '.config/vitest.terminal.config.mts', + 'test/unit/utils/self-update.terminal.test.mts', + ...(update ? ['--update'] : []), + ] +} + +export function run( + args: readonly string[], + options: SpawnSyncOptions = {}, +): void { + const result = spawnSync(process.execPath, [...args], { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + ...options, + }) + if (result.error) { + throw result.error + } + if (result.status !== 0) { + throw new Error( + `${args.join(' ')} exited with ${result.status ?? 'no exit status'}`, + ) + } +} + +export function sourceCommit(): string { + const revision = spawnSync('git', ['rev-parse', 'HEAD'], { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + if (revision.status !== 0) { + return 'unknown' + } + const status = spawnSync('git', ['status', '--porcelain'], { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + if (status.status !== 0) { + return 'unknown' + } + return `${revision.stdout.trim()}${status.stdout.trim() ? ' (dirty)' : ''}` +} + +export async function writeTestResult( + artifactsDir: string, + sourceRevision = sourceCommit(), +): Promise { + const signing = JSON.parse( + await fs.readFile(path.join(artifactsDir, 'signing-receipt.json'), 'utf8'), + ) as { + mode: string + sourceSha256: string + stagedSha256: string + } + const result = { + status: 'passed', + sourceCommit: sourceRevision, + platform: `${process.platform}/${process.arch}`, + signingMode: signing.mode, + sourceSha256: signing.sourceSha256, + binarySha256: signing.stagedSha256, + } + await fs.writeFile( + path.join(artifactsDir, 'test-result.json'), + `${JSON.stringify(result, null, 2)}\n`, + ) +} + +export interface SnapshotWorkflowDependencies { + acquireLock(): Promise + releaseLock(): Promise + createArtifactsDir(): Promise + runNative(update: boolean, artifactsDir: string): void + runDependencyNative(dependencyName: SnapshotDependencyName, artifactsDir: string): void + runTerminal(update: boolean, artifactsDir: string): void + writeTestResult(artifactsDir: string, sourceCommit: string): Promise + runReport(artifactsDir: string, reportPath: string): void + printReport(reportPath: string): void +} + +const DEFAULT_DEPENDENCIES: SnapshotWorkflowDependencies = { + async acquireLock() { + await fs.mkdir(ARTIFACTS_ROOT, { recursive: true }) + try { + await fs.mkdir(LOCK_DIR) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new Error( + `Another snapshot run holds ${LOCK_DIR}; remove it only if that run has stopped`, + ) + } + throw error + } + }, + async releaseLock() { + await fs.rm(LOCK_DIR, { recursive: true, force: true }) + }, + async createArtifactsDir() { + return await fs.mkdtemp( + path.join( + ARTIFACTS_ROOT, + `${process.platform}-${process.arch}-snapshots-`, + ), + ) + }, + runNative(update, artifactsDir) { + run(['scripts/test/native.mts', ...nativeSnapshotArgs(update)], { + env: { ...process.env, SOCKET_CLI_NATIVE_TEST_ARTIFACTS: artifactsDir }, + }) + }, + runDependencyNative(dependencyName, artifactsDir) { + run(['scripts/test/native.mts', ...dependencySnapshotArgs(dependencyName)], { + env: { ...process.env, SOCKET_CLI_NATIVE_TEST_ARTIFACTS: artifactsDir }, + }) + }, + runTerminal(update, artifactsDir) { + run(terminalSnapshotArgs(update), { + env: { + ...process.env, + SOCKET_CLI_NATIVE_TEST_ARTIFACTS: artifactsDir, + SOCKET_CLI_NATIVE_TEST_UPDATE_SNAPSHOTS: String(update), + }, + }) + }, + writeTestResult, + runReport(artifactsDir, reportPath) { + run([ + 'scripts/test/terminal-report.mts', + '--artifacts', + artifactsDir, + '--output', + reportPath, + ]) + }, + printReport(reportPath) { + process.stdout.write(`Native snapshot report: ${reportPath}\n`) + }, +} + +export async function runSnapshotWorkflow( + update: boolean, + dependencies: SnapshotWorkflowDependencies = DEFAULT_DEPENDENCIES, + selectedDependencyName?: SnapshotDependencyName, +): Promise { + await dependencies.acquireLock() + try { + const artifactsDir = await dependencies.createArtifactsDir() + const reportPath = path.join(artifactsDir, 'index.html') + const sourceRevision = sourceCommit() + if (selectedDependencyName) { + dependencies.runDependencyNative(selectedDependencyName, artifactsDir) + } else { + if (!update) { + await assertSnapshotDependenciesCurrent() + } + dependencies.runNative(update, artifactsDir) + dependencies.runTerminal(update, artifactsDir) + } + await dependencies.writeTestResult(artifactsDir, sourceRevision) + dependencies.runReport(artifactsDir, reportPath) + dependencies.printReport(reportPath) + return 0 + } finally { + await dependencies.releaseLock() + } +} + +export async function main(): Promise { + const { dependencyName, update } = parseSnapshotArgs(process.argv.slice(2)) + return await runSnapshotWorkflow(update, DEFAULT_DEPENDENCIES, dependencyName) +} + +const SCRIPT_META: ScriptMeta = { + describe: 'builds, runs, and reports native terminal snapshot tests', + help: `Usage: pnpm snapshots [--update|-u] + pnpm snapshots:update + pnpm snapshots:update:dep + + Builds the native CLI, runs the focused terminal screenshot tests, and writes + a self-contained HTML report into a fresh build/native-test-artifacts folder. + Pass --update or -u to update the native screenshot baselines. Use the dep + command to rebuild and update only snapshots affected by a pinned tool, for + example: pnpm snapshots:update:dep coana.`, +} + +if (isMainModule(import.meta.url)) { + runMain(main, SCRIPT_META) +} diff --git a/scripts/test/terminal-report.mts b/scripts/test/terminal-report.mts new file mode 100644 index 0000000000..4569b095c8 --- /dev/null +++ b/scripts/test/terminal-report.mts @@ -0,0 +1,212 @@ +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' + +import { isMainModule } from '../lib/is-main-module.mts' +import { runMain } from '../lib/run-main.mts' + +type Artifact = { name: string; file: string; kind: string; data: string } +const esc = (s: string) => + s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + +async function filesAt(dir: string): Promise { + const output: string[] = [] + async function visit(current: string): Promise { + try { + const entries = await readdir(current, { withFileTypes: true }) + await Promise.all( + entries.map(async entry => { + const filename = path.join(current, entry.name) + if (entry.isDirectory()) { + await visit(filename) + } else if (entry.isFile()) { + output.push(filename) + } + }), + ) + } catch { + /* Missing artifacts are represented in the report. */ + } + } + await visit(dir) + return output +} + +export async function main(): Promise { + const args = process.argv.slice(2) + let artifactsDir = path.resolve('build/native-test-artifacts') + let output = path.join(artifactsDir, 'index.html') + for (let i = 0; i < args.length; i++) { + if (args[i] === '--artifacts' && args[i + 1]) { + artifactsDir = path.resolve(args[++i]!) + } else if (args[i] === '--output' && args[i + 1]) { + output = path.resolve(args[++i]!) + } else { + throw new Error(`Unknown or incomplete argument: ${args[i]}`) + } + } + const records: Artifact[] = [] + const allFiles = await filesAt(artifactsDir) + const sequences = new Map< + string, + { checkpoints: string[]; durations: number[] } + >() + const svgs = new Map() + await Promise.all( + allFiles + .filter(file => path.basename(file) === 'sequence.json') + .map(async file => { + const flow = path.dirname(file) + try { + const parsed = JSON.parse(await readFile(file, 'utf8')) as { + checkpoints?: string[] + durations?: number[] + } + if ( + Array.isArray(parsed.checkpoints) && + parsed.checkpoints.every( + name => typeof name === 'string' && /^[\w.-]+$/.test(name), + ) + ) { + sequences.set(flow, { + checkpoints: parsed.checkpoints, + durations: Array.isArray(parsed.durations) + ? parsed.durations + : [], + }) + } + } catch { + /* malformed receipt is excluded; image files are still shown */ + } + }), + ) + await Promise.all( + allFiles + .filter(file => path.extname(file).toLowerCase() === '.png') + .map(async file => { + const name = path.relative(artifactsDir, file).replaceAll('\\', '/') + const bytes = await readFile(file) + records.push({ + name, + file, + kind: 'PNG', + data: `data:image/png;base64,${bytes.toString('base64')}`, + }) + }), + ) + await Promise.all( + allFiles + .filter(file => path.extname(file).toLowerCase() === '.svg') + .map(async file => { + const bytes = await readFile(file) + svgs.set( + path.relative(artifactsDir, file).replaceAll('\\', '/'), + `data:image/svg+xml;base64,${bytes.toString('base64')}`, + ) + }), + ) + const statusFile = allFiles.find( + file => path.basename(file) === 'test-result.json', + ) + const status = statusFile + ? (JSON.parse(await readFile(statusFile, 'utf8')) as Record< + string, + unknown + >) + : {} + const metadata = allFiles.filter( + file => + /receipt|manifest/i.test(path.basename(file)) && file.endsWith('.json'), + ) + const receipts = await Promise.all( + metadata.map(async file => ({ + name: path.relative(artifactsDir, file).replaceAll('\\', '/'), + value: JSON.parse(await readFile(file, 'utf8')) as Record< + string, + unknown + >, + })), + ) + records.sort((a, b) => a.name.localeCompare(b.name)) + const assetIds = new Map() + const assets: Array<{ id: string; data: string }> = [] + const assetId = (data: string): string => { + const existing = assetIds.get(data) + if (existing) { + return existing + } + const id = `asset-${assets.length}` + assetIds.set(data, id) + assets.push({ id, data }) + return id + } + const cards = records + .map(item => { + const flow = path.dirname(path.join(artifactsDir, item.name)) + const stem = path.basename(item.file, '.png') + const order = sequences.get(flow)?.checkpoints.indexOf(stem) ?? -1 + const duration = + order >= 0 ? sequences.get(flow)?.durations[order] : undefined + return { ...item, order, duration } + }) + .sort((a, b) => { + const flowA = path.dirname(path.join(artifactsDir, a.name)) + const flowB = path.dirname(path.join(artifactsDir, b.name)) + return ( + flowA.localeCompare(flowB) || + (a.order < 0 ? Number.MAX_SAFE_INTEGER : a.order) - + (b.order < 0 ? Number.MAX_SAFE_INTEGER : b.order) || + a.name.localeCompare(b.name, undefined, { numeric: true }) + ) + }) + .map(item => { + const svg = svgs.get(item.name.replace(/\.png$/i, '.svg')) + const flowName = path.basename(path.dirname(item.name)) + const helpCard = + /^socket-help(?:-.+)?$/.test(flowName) || + /^socket-patch-.+-help$/.test(flowName) + const pngAsset = assetId(item.data) + const svgAsset = svg ? assetId(svg) : undefined + const captureLabel = + item.order >= 0 + ? `Checkpoint ${item.order + 1}${typeof item.duration === 'number' ? ` · ${item.duration} ms` : ''}` + : 'Legacy capture (no sequence metadata)' + return `` + }) + .join('\n') + const animations = [...svgs.entries()] + .filter(([name]) => path.basename(name) === 'animation.svg') + .map(([name, data]) => { + const id = assetId(data) + return `` + }) + .join('\n') + const assetManifest = JSON.stringify(assets).replaceAll('<', '\\u003c') + const sha = process.env['GITHUB_SHA'] ?? 'unknown' + const platform = + typeof status['platform'] === 'string' + ? status['platform'] + : `${process.platform}/${process.arch}` + const statusLabel = + status['status'] === 'passed' + ? 'passed' + : `not a passing run (${String(status['status'] ?? 'unknown')})` + const html = `Native terminal screenshots

Native terminal screenshots

Source commit: ${esc(String(status['sourceCommit'] ?? sha))} · runner: ${esc(platform)} · screenshot tests: ${esc(statusLabel)}

Signing: ${esc(String(status['signingMode'] ?? 'unknown'))} · source: ${esc(String(status['sourceSha256'] ?? 'unknown'))} · binary: ${esc(String(status['binarySha256'] ?? 'unknown'))}

Platform validation

PlatformScreenshot testsSigning
${esc(String(status['platform'] ?? platform))}${esc(statusLabel)}${esc(String(status['signingMode'] ?? 'unknown'))}
Build receipts (${receipts.length})
${esc(JSON.stringify(receipts, null, 2))}
${animations}${cards || '

No PNG captures found.

'}
` + await mkdir(path.dirname(output), { recursive: true }) + await writeFile(output, html) + console.log(`Wrote ${records.length} image(s) to ${output}`) + return 0 +} + +if (isMainModule(import.meta.url)) { + runMain(main, { + describe: + 'builds a self-contained HTML report from native terminal screenshots', + help: 'Usage: node scripts/test/terminal-report.mts [--artifacts ] [--output ]', + }) +} diff --git a/src/commands/manifest/bazel/bazel-bin-detect.mts b/src/commands/manifest/bazel/bazel-bin-detect.mts index c18e1f7eb6..8ef67aa5cc 100644 --- a/src/commands/manifest/bazel/bazel-bin-detect.mts +++ b/src/commands/manifest/bazel/bazel-bin-detect.mts @@ -1,10 +1,17 @@ -import { existsSync } from 'node:fs' +import { + accessSync, + existsSync, + constants as fsConstants, + statSync, +} from 'node:fs' import path from 'node:path' import { findBazelExecutable, + isNodeScript, resolveBazelExecutable, } from './bazel-process.mts' +import { isStandaloneSea } from '../../../sea/runtime.mts' import { InputError } from '../../../utils/errors.mts' /** @@ -21,6 +28,39 @@ export async function resolveBazelBinary( env: NodeJS.ProcessEnv, cwd: string, ): Promise { + if (isStandaloneSea()) { + const override = explicit ?? env['SOCKET_CLI_BAZEL_PATH'] + if (!override) { + throw new InputError( + 'Bazel is not bundled with this standalone Socket CLI. Set SOCKET_CLI_BAZEL_PATH or pass --bazel with an installed native Bazel executable.', + ) + } + const resolved = path.resolve(cwd, override) + try { + if (!existsSync(resolved) || !statSync(resolved).isFile()) { + throw new Error('not a file') + } + if (process.platform !== 'win32') { + accessSync(resolved, fsConstants.X_OK) + } + } catch { + throw new InputError( + `The standalone Bazel path '${resolved}' is missing or not executable.`, + ) + } + const bin = resolveBazelExecutable(resolved) + if (/^bazelisk(?:\.exe)?$/i.test(path.basename(bin))) { + throw new InputError( + 'Standalone Socket requires an installed Bazel binary and will not use Bazelisk to download a Bazel version.', + ) + } + if (bin === process.execPath || isNodeScript(bin)) { + throw new InputError( + 'Standalone Socket cannot launch Bazel through a JavaScript launcher because process.execPath is the SEA executable. Set SOCKET_CLI_BAZEL_PATH to a native Bazel executable.', + ) + } + return bin + } if (explicit) { const resolved = path.resolve(cwd, explicit) if (!existsSync(resolved)) { diff --git a/src/commands/manifest/bazel/bazel-process.mts b/src/commands/manifest/bazel/bazel-process.mts index fa0edbe293..bc31bd2cad 100644 --- a/src/commands/manifest/bazel/bazel-process.mts +++ b/src/commands/manifest/bazel/bazel-process.mts @@ -98,11 +98,11 @@ export function resolveBazelExecutable(file: string): string { } } -function isNodeScript(bin: string): boolean { +export function isNodeScript(bin: string): boolean { if (/\.[cm]?js$/i.test(bin)) { return true } - if (path.extname(bin) || process.platform !== 'win32' || !existsSync(bin)) { + if (path.extname(bin) || !existsSync(bin)) { return false } const fd = openSync(bin, 'r') diff --git a/src/commands/manifest/bazel/bazel-process.test.mts b/src/commands/manifest/bazel/bazel-process.test.mts index 9c8d6871c7..61e6858edd 100644 --- a/src/commands/manifest/bazel/bazel-process.test.mts +++ b/src/commands/manifest/bazel/bazel-process.test.mts @@ -1,17 +1,16 @@ import childProcess from 'node:child_process' import { chmodSync, - copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, - rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs' +import { rm as rmAsync } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { setTimeout as sleep } from 'node:timers/promises' @@ -70,21 +69,27 @@ describe('Bazel process invocation', () => { writeFileSync(launcher, LAUNCHER) }) - afterEach(() => { + afterEach(async () => { for (const pid of servers.splice(0)) { if (isAlive(pid)) { process.kill(pid, 'SIGKILL') + // eslint-disable-next-line no-await-in-loop -- Wait for each server to release Windows file handles. + expect(await waitForExit(pid)).toBe(true) } } - rmSync(cwd, { recursive: true, force: true, maxRetries: 5 }) + await rmAsync(cwd, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }) }) it('passes arguments unchanged to a native executable', async () => { - const executable = path.join( - cwd, - process.platform === 'win32' ? 'native launcher.exe' : 'native launcher', - ) - copyFileSync(process.execPath, executable) + // Use the actual Node executable rather than copying it. Node 26 builds on + // macOS dynamically link libnode via @rpath, which a copied binary cannot + // resolve from this temporary directory. + const executable = process.execPath const result = await spawnBazel(executable, [launcher, ...ARGUMENTS], { cwd, }) @@ -427,8 +432,10 @@ if (args.includes('info')) { process.stdout.write(${JSON.stringify(`install_base expect(Date.now() - startedAt).toBeLessThan(15_000) } finally { const pid = readChildPid(record) - process.kill(pid, 'SIGKILL') - // Windows keeps the temporary directory locked until the process has exited. + if (isAlive(pid)) { + process.kill(pid, 'SIGKILL') + } + // Windows can report process exit before inherited handles release the cwd. expect(await waitForExit(pid)).toBe(true) } }, 30_000) diff --git a/src/commands/manifest/bazel/bazel-python-shim.mts b/src/commands/manifest/bazel/bazel-python-shim.mts index 20951b9b43..6c63cec6d6 100644 --- a/src/commands/manifest/bazel/bazel-python-shim.mts +++ b/src/commands/manifest/bazel/bazel-python-shim.mts @@ -1,9 +1,17 @@ import { execFileSync } from 'node:child_process' -import { mkdtempSync, symlinkSync } from 'node:fs' +import { + accessSync, + constants as fsConstants, + mkdtempSync, + rmSync, + statSync, + symlinkSync, +} from 'node:fs' import os from 'node:os' import path from 'node:path' import { findBazelExecutable, prependBazelPath } from './bazel-process.mts' +import { isStandaloneSea } from '../../../sea/runtime.mts' import { InputError } from '../../../utils/errors.mts' export type PythonShimResult = { @@ -37,7 +45,7 @@ function isPython3( function notPython3Error(executable: string): InputError { return new InputError( - `Python 3 is required for Bazel manifest extraction. ${executable} did not run as Python 3. Install Python 3 and ensure python${process.platform === 'win32' ? '.exe' : ''} is on PATH.`, + `Python 3 is required for Bazel manifest extraction. ${executable} did not run as Python 3. Install Python 3 and ensure python${process.platform === 'win32' ? '.exe' : ''} is on PATH or set SOCKET_CLI_PYTHON_PATH in a standalone build.`, ) } @@ -45,6 +53,61 @@ export async function provisionPythonShim( env: NodeJS.ProcessEnv, cwd: string, ): Promise { + if (isStandaloneSea()) { + const override = env['SOCKET_CLI_PYTHON_PATH'] + if (!override) { + throw new InputError( + 'Python is not bundled with this standalone Socket CLI. Set SOCKET_CLI_PYTHON_PATH to an installed Python 3 executable for Bazel manifest extraction.', + ) + } + const executable = path.resolve(cwd, override) + try { + accessSync( + executable, + process.platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK, + ) + if (!statSync(executable).isFile()) { + throw new Error('not a file') + } + } catch { + throw new InputError( + `SOCKET_CLI_PYTHON_PATH must point to an executable Python 3 binary, but '${executable}' is missing or not executable.`, + ) + } + if ( + process.platform === 'win32' && + path.extname(executable).toLowerCase() !== '.exe' + ) { + throw new InputError( + 'Bazel on Windows requires SOCKET_CLI_PYTHON_PATH to point to a native Python 3 python.exe.', + ) + } + if (!isPython3(executable, env, cwd)) { + throw notPython3Error(executable) + } + if (process.platform === 'win32') { + if (path.basename(executable).toLowerCase() !== 'python.exe') { + throw new InputError( + "SOCKET_CLI_PYTHON_PATH must name python.exe on Windows so its adjacent DLLs and standard library remain available. Point it to the Python installation's python.exe.", + ) + } + return { + env: prependBazelPath(env, path.dirname(executable)), + shimDir: undefined, + } + } + const shimDir = mkdtempSync(path.join(os.tmpdir(), 'socket-bazel-python-')) + try { + for (const name of ['python', 'python3']) { + const alias = path.join(shimDir, name) + symlinkSync(executable, alias) + } + } catch (error) { + rmSync(shimDir, { recursive: true, force: true }) + throw error + } + return { env: prependBazelPath(env, shimDir), shimDir } + } const python = await findBazelExecutable('python', env, cwd) if (python) { if ( diff --git a/src/commands/manifest/bazel/bazel-python-shim.test.mts b/src/commands/manifest/bazel/bazel-python-shim.test.mts index af1d8446d3..1dad2fa82b 100644 --- a/src/commands/manifest/bazel/bazel-python-shim.test.mts +++ b/src/commands/manifest/bazel/bazel-python-shim.test.mts @@ -9,10 +9,17 @@ import { import os from 'node:os' import path from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { findBazelExecutable, getBazelEnvironment } from './bazel-process.mts' +import { + findBazelExecutable, + getBazelEnvironment, + getBazelEnvironmentValue, +} from './bazel-process.mts' import { provisionPythonShim } from './bazel-python-shim.mts' +import { isStandaloneSea } from '../../../sea/runtime.mts' + +vi.mock('../../../sea/runtime.mts', () => ({ isStandaloneSea: vi.fn() })) const python = await findBazelExecutable( process.platform === 'win32' ? 'python' : 'python3', @@ -25,6 +32,7 @@ describe('provisionPythonShim', () => { const shims: string[] = [] beforeEach(() => { + vi.mocked(isStandaloneSea).mockReturnValue(false) cwd = mkdtempSync(path.join(os.tmpdir(), 'bazel python ')) }) @@ -122,4 +130,23 @@ describe('provisionPythonShim', () => { expect(readlinkSync(path.join(result.shimDir, 'python'))).toBe(python) }, ) + + it.skipIf(!python || process.platform !== 'win32')( + 'keeps an overridden Windows interpreter beside its runtime files', + async () => { + if (!python) { + throw new Error('Python fixture is unavailable') + } + vi.mocked(isStandaloneSea).mockReturnValue(true) + const env = getBazelEnvironment({ + SOCKET_CLI_PYTHON_PATH: path.relative(cwd, python), + Path: 'existing-path', + }) + const result = await provisionPythonShim(env, cwd) + expect(result.shimDir).toBeUndefined() + expect(getBazelEnvironmentValue(result.env, 'PATH')).toBe( + `${path.dirname(python)}${path.delimiter}existing-path`, + ) + }, + ) }) diff --git a/src/commands/manifest/cmd-manifest-cdxgen.mts b/src/commands/manifest/cmd-manifest-cdxgen.mts index defe312dd4..1909710e12 100644 --- a/src/commands/manifest/cmd-manifest-cdxgen.mts +++ b/src/commands/manifest/cmd-manifest-cdxgen.mts @@ -1,5 +1,10 @@ +import { existsSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + import terminalLink from 'terminal-link' import yargsParse from 'yargs-parser' +import colors from 'yoctocolors-cjs' import { joinAnd } from '@socketsecurity/registry/lib/arrays' import { logger } from '@socketsecurity/registry/lib/logger' @@ -9,6 +14,7 @@ import { pluralize } from '@socketsecurity/registry/lib/words' import { runCdxgen } from './run-cdxgen.mts' import constants, { FLAG_HELP } from '../../constants.mts' import { commonFlags, outputFlags } from '../../flags.mts' +import { isStandaloneSea } from '../../sea/runtime.mts' import { filterFlags, isHelpFlag } from '../../utils/cmd.mts' import { meowOrExit } from '../../utils/meow-with-subcommands.mts' @@ -292,6 +298,25 @@ async function run( return } + // The standalone build must not start cdxgen just to display its help: + // cdxgen is an optional payload and can be unavailable for this target. + // Keep this local help useful without resolving or installing that payload. + if (yargv.help && isStandaloneSea()) { + logger.log(` +Usage + socket manifest cdxgen [options] [path] + +Options + -h, --help Show this help + -o, --output Output file (default: socket-cdx.json) + -t, --type npm, nodejs, javascript, js, typescript, ts, or tsx + --lifecycle pre-build Read dependency metadata without running project installs + +Generates a CycloneDX SBOM from a root npm package-lock.json v3. +Only one npm project is supported in this standalone build. Other cdxgen modes are unavailable.`) + return + } + // Change defaults when not passing the --help flag. if (!yargv.help) { // Make 'lifecycle' default to 'pre-build', which also sets 'install-deps' to `false`, @@ -316,6 +341,30 @@ async function run( const { spawnPromise } = await runCdxgen(yargv) + if (isStandaloneSea()) { + // The embedded Worker exposes an awaited result. Avoid translating its + // synthetic exit event into process.exit(), which can bypass CLI error + // formatting and telemetry drain on rejection or cancellation. + const result = (await spawnPromise) as unknown as { + code: number | null + signal: NodeJS.Signals | null + } + if (result.signal) { + process.exitCode = 128 + (os.constants.signals[result.signal] ?? 1) + } else { + process.exitCode = typeof result.code === 'number' ? result.code : 1 + if ( + result.code === 0 && + typeof yargv.output === 'string' && + yargv.output !== '-' && + existsSync(path.resolve(process.cwd(), yargv.output)) + ) { + logger.log(colors.cyanBright(`${yargv.output} created!`)) + } + } + return + } + // See https://nodejs.org/api/child_process.html#event-exit. spawnPromise.process.on('exit', (code, signalName) => { if (signalName) { diff --git a/src/commands/manifest/convert_gradle_to_maven.mts b/src/commands/manifest/convert_gradle_to_maven.mts index ec50691c1c..7d30fa97f2 100644 --- a/src/commands/manifest/convert_gradle_to_maven.mts +++ b/src/commands/manifest/convert_gradle_to_maven.mts @@ -5,6 +5,8 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { spawn } from '@socketsecurity/registry/lib/spawn' import constants from '../../constants.mts' +import { resolveBuildToolBin } from './scripts/build-tool.mts' +import { isStandaloneSea } from '../../sea/runtime.mts' export async function convertGradleToMaven({ bin, @@ -21,9 +23,18 @@ export async function convertGradleToMaven({ }) { // TODO: Implement json/md. + let selectedBin: string + try { + selectedBin = resolveBuildToolBin('gradle', cwd, bin, isStandaloneSea()) + } catch (error) { + process.exitCode = 1 + logger.fail(error instanceof Error ? error.message : String(error)) + return + } + // Note: use resolve because the bin could be an absolute path, away from cwd // TODO: what about $PATH resolved commands? (`gradlew` without dir prefix) - const rBin = path.resolve(cwd, bin) + const rBin = path.resolve(cwd, selectedBin) const binExists = fs.existsSync(rBin) const cwdExists = fs.existsSync(cwd) @@ -64,9 +75,11 @@ export async function convertGradleToMaven({ 'pom', ] if (verbose) { - logger.log('[VERBOSE] Executing:', [bin], ', args:', commandArgs) + logger.log('[VERBOSE] Executing:', [selectedBin], ', args:', commandArgs) } - logger.log(`Converting gradle to maven from \`${bin}\` on \`${cwd}\` ...`) + logger.log( + `Converting gradle to maven from \`${selectedBin}\` on \`${cwd}\` ...`, + ) const output = await execGradle(rBin, commandArgs, cwd, verbose) if (output.code) { process.exitCode = 1 diff --git a/src/commands/manifest/convert_sbt_to_maven.mts b/src/commands/manifest/convert_sbt_to_maven.mts index eeda44768a..484ce00dff 100644 --- a/src/commands/manifest/convert_sbt_to_maven.mts +++ b/src/commands/manifest/convert_sbt_to_maven.mts @@ -6,6 +6,8 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { spawn } from '@socketsecurity/registry/lib/spawn' import constants from '../../constants.mts' +import { resolveBuildToolBin } from './scripts/build-tool.mts' +import { isStandaloneSea } from '../../sea/runtime.mts' import { getErrorCause } from '../../utils/errors.mts' // Walk up from a pom path to find a `target` directory ancestor and return @@ -39,22 +41,33 @@ export async function convertSbtToMaven({ }) { // TODO: Implement json/md. + let selectedBin: string + try { + selectedBin = resolveBuildToolBin('sbt', cwd, bin, isStandaloneSea()) + } catch (error) { + process.exitCode = 1 + logger.fail(error instanceof Error ? error.message : String(error)) + return + } + const { spinner } = constants logger.group('sbt2maven:') - logger.info(`- executing: \`${bin}\``) + logger.info(`- executing: \`${selectedBin}\``) logger.info(`- src dir: \`${cwd}\``) logger.groupEnd() try { - spinner.start(`Converting sbt to maven from \`${bin}\` on \`${cwd}\`...`) + spinner.start( + `Converting sbt to maven from \`${selectedBin}\` on \`${cwd}\`...`, + ) // Run sbt with the init script we provide which should yield zero or more // pom files. We have to figure out where to store those pom files such that // we can upload them and predict them through the GitHub API. We could do a // .socket folder. We could do a socket.pom.gz with all the poms, although // I'd prefer something plain-text if it is to be committed. - const output = await spawn(bin, ['makePom', ...sbtOpts], { cwd }) + const output = await spawn(selectedBin, ['makePom', ...sbtOpts], { cwd }) spinner.stop() diff --git a/src/commands/manifest/run-cdxgen.mts b/src/commands/manifest/run-cdxgen.mts index 20bfa22fbf..919c673d28 100644 --- a/src/commands/manifest/run-cdxgen.mts +++ b/src/commands/manifest/run-cdxgen.mts @@ -1,4 +1,5 @@ -import { existsSync, rmSync } from 'node:fs' +import { EventEmitter } from 'node:events' +import { existsSync, promises as fs, rmSync } from 'node:fs' import path from 'node:path' import colors from 'yoctocolors-cjs' @@ -6,6 +7,15 @@ import colors from 'yoctocolors-cjs' import { logger } from '@socketsecurity/registry/lib/logger' import constants, { FLAG_HELP, NPM, PNPM, YARN } from '../../constants.mts' +import { runCdxgen as runEmbeddedCdxgen } from '../../sea/adapters/cdxgen.mts' +import { + convertYarnClassicToNpmLock, + removeOwnedPackageLock, +} from '../../sea/adapters/synp.mts' +import { + assertEmbeddedToolAvailable, + isStandaloneSea, +} from '../../sea/runtime.mts' import { spawnCdxgenDlx, spawnSynpDlx } from '../../utils/dlx.mts' import { findUp } from '../../utils/fs.mts' import { isYarnBerry } from '../../utils/yarn-version.mts' @@ -63,6 +73,282 @@ function argvObjectToArray(argvObj: ArgvObject): string[] { } export async function runCdxgen(argvObj: ArgvObject): Promise { + if (isStandaloneSea()) { + const unsupported = new Set([ + 'deep', + 'evidence', + 'include-formulation', + 'include-crypto', + 'install-deps', + 'multi-project', + 'recurse', + 'server', + 'export-proto', + 'proto-bin-file', + 'profile', + 'feature-flags', + 'filter', + 'only', + 'technique', + 'standard', + 'exclude-type', + 'babel', + 'validate', + ]) + for (const key of Object.keys(argvObj)) { + if (unsupported.has(key)) { + const value = argvObj[key] + if (value !== false && value !== undefined && value !== null) { + throw new Error( + `The standalone cdxgen build does not support --${key}.`, + ) + } + } else if ( + !['_', '--', 'output', 'type', 'lifecycle', 'help'].includes(key) + ) { + throw new Error( + `The standalone cdxgen build does not support --${key}.`, + ) + } + } + if (argvObj['help']) { + throw new Error('Help output is handled before cdxgen execution.') + } + if (argvObj['lifecycle'] && argvObj['lifecycle'] !== 'pre-build') { + throw new Error( + 'The standalone cdxgen build supports only the pre-build lifecycle.', + ) + } + if ( + argvObj['type'] && + ![ + 'npm', + 'nodejs', + 'javascript', + 'js', + 'typescript', + 'ts', + 'tsx', + ].includes(String(argvObj['type']).toLowerCase()) + ) { + throw new Error('The standalone cdxgen build supports only npm projects.') + } + + const positional = Array.isArray(argvObj['_']) ? argvObj['_'] : [] + if ( + positional.length > 1 || + (positional[0] && path.resolve(String(positional[0])) !== process.cwd()) + ) { + throw new Error( + 'The standalone cdxgen build supports one project at the current working directory.', + ) + } + if (Array.isArray(argvObj['--']) && argvObj['--'].length) { + throw new Error( + 'The standalone cdxgen build does not support arguments after --.', + ) + } + + const root = process.cwd() + const packageLockPath = path.join(root, PACKAGE_LOCK_JSON) + const yarnLockPath = path.join(root, YARN_LOCK) + if ( + existsSync(path.join(root, 'npm-shrinkwrap.json')) || + existsSync(path.join(root, 'rush.json')) || + existsSync(path.join(root, PNPM_LOCK_YAML)) + ) { + throw new Error( + 'The standalone cdxgen build supports one npm or Yarn Classic project without shrinkwrap, pnpm, or Rush configuration.', + ) + } + let packageJson: { + workspaces?: unknown + workspace?: unknown + dependencies?: Record + devDependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record + } + try { + packageJson = JSON.parse( + await fs.readFile(path.join(root, 'package.json'), 'utf8'), + ) as typeof packageJson + } catch { + throw new Error( + 'The standalone cdxgen build requires a valid root package.json.', + ) + } + if ( + !packageJson || + typeof packageJson !== 'object' || + Array.isArray(packageJson) + ) { + throw new Error( + 'The standalone cdxgen build requires a valid root package.json object.', + ) + } + if (packageJson.workspaces || packageJson.workspace) { + throw new Error( + 'The standalone cdxgen build does not support workspaces.', + ) + } + const localDependency = (value: unknown): boolean => + typeof value === 'string' && /^(?:file:|link:|workspace:)/i.test(value) + for (const group of [ + packageJson.dependencies, + packageJson.devDependencies, + packageJson.optionalDependencies, + packageJson.peerDependencies, + ]) { + if (Object.values(group ?? {}).some(localDependency)) { + throw new Error( + 'The standalone cdxgen build does not support local or workspace dependencies.', + ) + } + } + + const hasPackageLock = existsSync(packageLockPath) + const hasYarnLock = existsSync(yarnLockPath) + if (hasPackageLock && hasYarnLock) { + throw new Error( + 'The standalone cdxgen build does not support projects with both npm and Yarn lockfiles.', + ) + } + let generatedLock: + | Awaited> + | undefined + const abortController = new AbortController() + const onSigint = () => + abortController.abort( + Object.assign(new Error('Interrupted'), { code: 'SIGINT' }), + ) + const onSigterm = () => + abortController.abort( + Object.assign(new Error('Terminated'), { code: 'SIGTERM' }), + ) + const onSighup = () => + abortController.abort( + Object.assign(new Error('Hangup'), { code: 'SIGHUP' }), + ) + let signalHandlersInstalled = false + const installSignalHandlers = () => { + if (signalHandlersInstalled) { + return + } + signalHandlersInstalled = true + process.once('SIGINT', onSigint) + process.once('SIGTERM', onSigterm) + process.once('SIGHUP', onSighup) + } + const removeSignalHandlers = () => { + if (!signalHandlersInstalled) { + return + } + signalHandlersInstalled = false + process.removeListener('SIGINT', onSigint) + process.removeListener('SIGTERM', onSigterm) + process.removeListener('SIGHUP', onSighup) + } + if (hasPackageLock) { + let lock: { lockfileVersion?: number } + try { + lock = JSON.parse(await fs.readFile(packageLockPath, 'utf8')) as { + lockfileVersion?: number + } + } catch { + throw new Error( + 'The standalone cdxgen build requires a valid npm package-lock.json.', + ) + } + if (lock.lockfileVersion !== 3) { + throw new Error( + 'The standalone cdxgen build requires npm package-lock v3; only Yarn Classic conversion uses v1.', + ) + } + assertEmbeddedToolAvailable('cdxgen', 'manifest-cdxgen-npm-lock-v3') + } else if (hasYarnLock) { + const yarnText = await fs.readFile(yarnLockPath, 'utf8') + if ( + !/^# yarn lockfile v1(?:\r?\n|$)/m.test(yarnText) || + /^__metadata:\s*$/m.test(yarnText) || + existsSync(path.join(root, '.yarnrc.yml')) + ) { + throw new Error( + 'The standalone cdxgen build supports Yarn Classic lockfile v1 only; Yarn Berry is unavailable.', + ) + } + if (!existsSync(path.join(root, 'node_modules'))) { + throw new Error( + 'Yarn Classic conversion requires an existing node_modules directory.', + ) + } + assertEmbeddedToolAvailable('cdxgen', 'manifest-cdxgen-yarn-classic') + assertEmbeddedToolAvailable('synp') + installSignalHandlers() + try { + generatedLock = await convertYarnClassicToNpmLock( + root, + abortController.signal, + ) + } catch (error) { + removeSignalHandlers() + if (abortController.signal.aborted) { + const reason = abortController.signal.reason as { code?: unknown } + if (typeof reason?.code === 'string') { + if (isStandaloneSea()) { + await removeOwnedPackageLock(generatedLock) + const canceled = Promise.resolve({ + code: null, + signal: reason.code as NodeJS.Signals, + cancelled: true, + }) as unknown as ShadowBinResult['spawnPromise'] + Object.defineProperty(canceled, 'process', { + value: new EventEmitter(), + }) + return { spawnPromise: canceled } + } + process.kill(process.pid, reason.code as NodeJS.Signals) + } + } + throw error + } + } else { + throw new Error( + 'The standalone cdxgen build requires npm package-lock v3 or Yarn Classic lockfile v1 with node_modules.', + ) + } + + const output = + typeof argvObj['output'] === 'string' + ? argvObj['output'] + : 'socket-cdx.json' + const outputPath = output === '-' ? '-' : path.resolve(root, output) + if (generatedLock && outputPath === packageLockPath) { + await removeOwnedPackageLock(generatedLock) + removeSignalHandlers() + throw new Error( + 'The standalone cdxgen build cannot use package-lock.json as the SBOM output path.', + ) + } + installSignalHandlers() + try { + const embedded = await runEmbeddedCdxgen( + root, + outputPath, + { stdio: 'inherit', signal: abortController.signal }, + async () => { + removeSignalHandlers() + await removeOwnedPackageLock(generatedLock) + }, + ) + return embedded as ShadowBinResult + } catch (error) { + removeSignalHandlers() + await removeOwnedPackageLock(generatedLock) + throw error + } + } + const argvMutable = { __proto__: null, ...argvObj } as ArgvObject const shadowOpts: DlxOptions = { diff --git a/src/commands/manifest/scripts/build-tool.mts b/src/commands/manifest/scripts/build-tool.mts index 69c2c7cbcb..67f9b7f94c 100644 --- a/src/commands/manifest/scripts/build-tool.mts +++ b/src/commands/manifest/scripts/build-tool.mts @@ -1,4 +1,9 @@ -import { existsSync } from 'node:fs' +import { + accessSync, + existsSync, + constants as fsConstants, + statSync, +} from 'node:fs' import { basename, resolve } from 'node:path' import constants from '../../../constants.mts' @@ -43,6 +48,46 @@ export function buildToolLabelsFor(purlTypes: readonly PURL_Type[]): string[] { // Purl types whose packages have no namespace (NuGet ids are flat). export const NAMESPACELESS_PURL_TYPES: ReadonlySet = new Set(['nuget']) +const BUILD_TOOL_PATH_ENV: Record = { + __proto__: null, + gradle: 'SOCKET_CLI_GRADLE_PATH', + maven: 'SOCKET_CLI_MAVEN_PATH', + sbt: 'SOCKET_CLI_SBT_PATH', +} as unknown as Record + +function validatedEnvBuildToolPath( + tool: BuildTool, + projectDir: string, +): string { + const envName = BUILD_TOOL_PATH_ENV[tool] + const selected = process.env[envName] + if (!selected) { + throw new Error( + `Standalone Socket does not support ${tool} manifest execution without ${envName}: JVM runtime and toolchain assets are not bundled.`, + ) + } + const executable = resolve(projectDir, selected) + return validateExecutablePath(envName, selected, executable) +} + +function validateExecutablePath( + envName: string, + selected: string, + executable: string, +): string { + try { + if (!statSync(executable).isFile()) { + throw new Error('not a regular file') + } + accessSync(executable, fsConstants.X_OK) + return executable + } catch { + throw new Error( + `${envName} must point to a regular executable file; received '${selected}'.`, + ) + } +} + // PATH fallback when no `bin` and no project wrapper. const DEFAULT_BUILD_TOOL_BIN: Record = { __proto__: null, @@ -123,7 +168,19 @@ export function resolveBuildToolBin( tool: BuildTool, projectDir: string, bin?: string | undefined, + standalone = false, ): string { + if (standalone) { + return validatedEnvBuildToolPath(tool, projectDir) + } + const envOverride = process.env[BUILD_TOOL_PATH_ENV[tool]] + if (envOverride) { + return validateExecutablePath( + BUILD_TOOL_PATH_ENV[tool], + envOverride, + resolve(projectDir, envOverride), + ) + } if (bin) { return bin } diff --git a/src/commands/manifest/scripts/dotnet-tool/build-tool.sh b/src/commands/manifest/scripts/dotnet-tool/build-tool.sh index 0ebeb6cd23..8b65e0af87 100755 --- a/src/commands/manifest/scripts/dotnet-tool/build-tool.sh +++ b/src/commands/manifest/scripts/dotnet-tool/build-tool.sh @@ -6,7 +6,9 @@ set -euo pipefail cd "$(dirname "$0")" # Publish into a fresh dir and swap it in, so no stale file survives. staging="$(mktemp -d "${TMPDIR:-/tmp}/socket-facts-dotnet.XXXXXX")" -dotnet restore socket-facts-dotnet.csproj --locked-mode --nologo -v quiet +# Vulnerability-feed auditing runs in the dedicated security workflow; restore +# this locked build input without making the helper build depend on that feed. +dotnet restore socket-facts-dotnet.csproj --locked-mode -p:NuGetAudit=false --nologo -v quiet dotnet publish socket-facts-dotnet.csproj -c Release -o "$staging" --no-restore --nologo -v quiet rm -f "$staging"/*.pdb if [ -d publish ]; then diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index ea09c83a43..c7057c872a 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -8,6 +8,7 @@ import { resolveBuildToolBin, socketFactsFileName } from './build-tool.mts' import { serializeExcludePathPatterns } from './exclude-paths-glob.mts' import { parseRecords } from './records.mts' import constants from '../../../constants.mts' +import { isStandaloneSea } from '../../../sea/runtime.mts' import { withTmpDir } from '../../../utils/fs.mts' import { getOrInsert } from '../../../utils/get-or-insert.mts' import { camelToKebab } from '../../../utils/strings.mts' @@ -294,10 +295,15 @@ async function invokeGradle( task: string, tmpDirPrefix: string, ): Promise { + const bin = resolveBuildToolBin( + 'gradle', + opts.projectDir, + opts.bin, + isStandaloneSea(), + ) const initScript = manifestScriptsPath(initScriptFilename) return await withTmpDir(tmpDirPrefix, async tmp => { const recordsFile = path.join(tmp, 'records.tsv') - const bin = resolveBuildToolBin('gradle', opts.projectDir, opts.bin) // Disable the configuration cache: the init script's legacy // resolvedConfiguration API and shared accumulator aren't cache-safe. const args = [ @@ -348,12 +354,17 @@ async function invokeSbtIn( pluginDestFilename: string, task: string, ): Promise { + const bin = resolveBuildToolBin( + 'sbt', + opts.projectDir, + opts.bin, + isStandaloneSea(), + ) await writeSbtPlugin(globalBase, pluginSourceFilename, pluginDestFilename) const recordsFile = path.join(globalBase, 'records.tsv') // The global base is shared across sbt roots: a build that dies before // writing must not be read as the previous root's records. await fs.rm(recordsFile, { force: true }) - const bin = resolveBuildToolBin('sbt', opts.projectDir, opts.bin) // Fresh per-run global base (not ~/.sbt): sbt executes everything under // plugins/, so a shared path is a code-injection surface. BSP off for this // run. sbt also provisions the project's Scala toolchain under @@ -421,6 +432,12 @@ async function invokeMaven( coanaTask: string, tmpDirPrefix: string, ): Promise { + const bin = resolveBuildToolBin( + 'maven', + opts.projectDir, + opts.bin, + isStandaloneSea(), + ) const jarPath = manifestScriptsPath( 'maven-extension', 'coana-maven-extension.jar', @@ -432,7 +449,6 @@ async function invokeMaven( ) return await withTmpDir(tmpDirPrefix, async tmp => { const recordsFile = path.join(tmp, 'records.tsv') - const bin = resolveBuildToolBin('maven', opts.projectDir, opts.bin) // `validate` is the cheapest phase that triggers the extension; no compile // needed (analysis uses configured paths, not classes). const props = [ diff --git a/src/commands/package/output-purls-shallow-malware.test.mts b/src/commands/package/output-purls-shallow-malware.test.mts index 0d7a461909..421f7f1228 100644 --- a/src/commands/package/output-purls-shallow-malware.test.mts +++ b/src/commands/package/output-purls-shallow-malware.test.mts @@ -1,11 +1,21 @@ import { describe, expect, it } from 'vitest' import npmMalware from './fixtures/npm_malware.json' -import { - generateMarkdownReport, - generateTextReport, - preProcess, -} from './output-purls-shallow-score.mts' +const previousForceColor = process.env['FORCE_COLOR'] +const previousNoColor = process.env['NO_COLOR'] +process.env['FORCE_COLOR'] = '1' +delete process.env['NO_COLOR'] +const { generateMarkdownReport, generateTextReport, preProcess } = await import( + './output-purls-shallow-score.mts' +) +if (previousForceColor === undefined) { + delete process.env['FORCE_COLOR'] +} else { + process.env['FORCE_COLOR'] = previousForceColor +} +if (previousNoColor !== undefined) { + process.env['NO_COLOR'] = previousNoColor +} describe('package score output with malware detection', async () => { describe('malware and gptMalware alerts', () => { diff --git a/src/commands/package/output-purls-shallow-score.test.mts b/src/commands/package/output-purls-shallow-score.test.mts index 0a8a18e671..c0b60ad18f 100644 --- a/src/commands/package/output-purls-shallow-score.test.mts +++ b/src/commands/package/output-purls-shallow-score.test.mts @@ -7,11 +7,21 @@ import nugetShallow from './fixtures/nuget_shallow.json' import pythonDupes from './fixtures/python_dupes.json' import pythonShallow from './fixtures/python_shallow.json' import rubyShallow from './fixtures/ruby_shallow.json' -import { - generateMarkdownReport, - generateTextReport, - preProcess, -} from './output-purls-shallow-score.mts' +const previousForceColor = process.env['FORCE_COLOR'] +const previousNoColor = process.env['NO_COLOR'] +process.env['FORCE_COLOR'] = '1' +delete process.env['NO_COLOR'] +const { generateMarkdownReport, generateTextReport, preProcess } = await import( + './output-purls-shallow-score.mts' +) +if (previousForceColor === undefined) { + delete process.env['FORCE_COLOR'] +} else { + process.env['FORCE_COLOR'] = previousForceColor +} +if (previousNoColor !== undefined) { + process.env['NO_COLOR'] = previousNoColor +} describe('package score output', async () => { describe('npm', () => { diff --git a/src/commands/patch/cmd-patch.mts b/src/commands/patch/cmd-patch.mts index 782f48f286..5527131118 100644 --- a/src/commands/patch/cmd-patch.mts +++ b/src/commands/patch/cmd-patch.mts @@ -1,8 +1,18 @@ import { spawnSync } from 'node:child_process' -import { existsSync } from 'node:fs' +import { + accessSync, + existsSync, + constants as fsConstants, + statSync, +} from 'node:fs' import path from 'node:path' import constants from '../../constants.mts' +import { + cleanupBundledTool, + getBundledTool, + isStandaloneSea, +} from '../../sea/runtime.mts' import type { CliCommandContext } from '../../utils/meow-with-subcommands.mts' @@ -41,6 +51,35 @@ function resolveSocketPatchBin(): string { return 'socket-patch' } +function isSelfUpdate(argv: readonly string[]): boolean { + return argv.some( + (arg, index) => + arg === '--update' || + arg === 'self-update' || + arg === 'selfupdate' || + arg === 'self_update' || + ((arg === 'update' || arg === 'upgrade') && + (index === 0 || argv[index - 1] === 'socket-patch')), + ) +} + +function validateExplicitSocketPatchPath(binPath: string): string { + const absoluteBinPath = path.resolve(binPath) + try { + const stats = statSync(absoluteBinPath) + if (!stats.isFile()) { + throw new Error('not a regular file') + } + accessSync(absoluteBinPath, fsConstants.X_OK) + } catch (cause) { + throw new Error( + `SOCKET_CLI_SOCKET_PATCH_PATH must name an existing executable file: ${absoluteBinPath}`, + { cause }, + ) + } + return absoluteBinPath +} + async function run( argv: string[] | readonly string[], _importMeta: ImportMeta, @@ -48,6 +87,27 @@ async function run( ): Promise { const { ENV } = constants + const standalone = isStandaloneSea() + if (standalone && isSelfUpdate(argv)) { + throw new Error( + 'socket-patch self-update is unavailable in the standalone Socket build because it cannot replace the bundled executable.', + ) + } + let explicitBinPath = standalone + ? ENV.SOCKET_CLI_SOCKET_PATCH_PATH + : undefined + let bundledBinPath: string | undefined + if (standalone) { + // The explicit override takes precedence; otherwise extract only the + // verified embedded payload. Neither branch consults PATH. + if (explicitBinPath) { + explicitBinPath = validateExplicitSocketPatchPath(explicitBinPath) + } else { + bundledBinPath = await getBundledTool('socket-patch') + explicitBinPath = bundledBinPath + } + } + // Build environment variables for the socket-patch binary. const spawnEnv: Record = { ...process.env, @@ -77,22 +137,32 @@ async function run( // Resolve and spawn the socket-patch Rust binary. // On Windows, node_modules/.bin shims are .cmd scripts that require shell. - const binPath = resolveSocketPatchBin() - const result = spawnSync(binPath, [...argv], { - stdio: 'inherit', - env: spawnEnv, - shell: constants.WIN32, - }) + const binPath = explicitBinPath || resolveSocketPatchBin() + try { + const result = spawnSync(binPath, [...argv], { + stdio: 'inherit', + env: spawnEnv, + shell: explicitBinPath ? false : constants.WIN32, + }) - if (result.error) { - throw result.error - } - // Propagate signal if the child was killed (e.g. SIGTERM, SIGINT). - if (result.signal) { - process.kill(process.pid, result.signal) - return - } - if (result.status !== null && result.status !== 0) { - process.exitCode = result.status + if (result.error) { + throw result.error + } + // Propagate signal if the child was killed (e.g. SIGTERM, SIGINT). + if (result.signal) { + if (bundledBinPath) { + await cleanupBundledTool(bundledBinPath) + bundledBinPath = undefined + } + process.kill(process.pid, result.signal) + return + } + if (result.status !== null && result.status !== 0) { + process.exitCode = result.status + } + } finally { + if (bundledBinPath) { + await cleanupBundledTool(bundledBinPath) + } } } diff --git a/src/commands/scan/cmd-scan-create.mts b/src/commands/scan/cmd-scan-create.mts index 5a2390b11f..90a10ef627 100644 --- a/src/commands/scan/cmd-scan-create.mts +++ b/src/commands/scan/cmd-scan-create.mts @@ -186,7 +186,7 @@ const generalFlags: MeowFlags = { type: 'boolean', default: false, description: - 'Scan each TARGET directory as a uv project, using the versions pinned in its uv.lock or its workspace root uv.lock. Uploads a CycloneDX dependency graph per TARGET in place of manifest discovery, including all extras and dependency groups. Requires uv on PATH.', + 'Scan each TARGET directory as a uv project, using the versions pinned in its uv.lock or its workspace root uv.lock. Uploads a CycloneDX dependency graph per TARGET in place of manifest discovery, including all extras and dependency groups. Requires uv on PATH; standalone builds use SOCKET_CLI_UV_PATH.', }, } diff --git a/src/commands/scan/cmd-scan-create.test.mts b/src/commands/scan/cmd-scan-create.test.mts index 940d9e9628..3efa1bb09e 100644 --- a/src/commands/scan/cmd-scan-create.test.mts +++ b/src/commands/scan/cmd-scan-create.test.mts @@ -119,7 +119,7 @@ describe('socket scan create', async () => { --report-level Which policy level alerts should be reported (default 'error') --set-as-alerts-page When true and if this is the "default branch" then this Scan will be the one reflected on your alerts page. See help for details. Defaults to true. --tmp Set the visibility (true/false) of the scan in your dashboard. - --uv-members Scan each TARGET directory as a uv project, using the versions pinned in its uv.lock or its workspace root uv.lock. Uploads a CycloneDX dependency graph per TARGET in place of manifest discovery, including all extras and dependency groups. Requires uv on PATH. + --uv-members Scan each TARGET directory as a uv project, using the versions pinned in its uv.lock or its workspace root uv.lock. Uploads a CycloneDX dependency graph per TARGET in place of manifest discovery, including all extras and dependency groups. Requires uv on PATH; standalone builds use SOCKET_CLI_UV_PATH. --workspace The workspace in the Socket Organization that the repository is in to associate with the full scan. Reachability Options (when --reach is used) diff --git a/src/commands/scan/generate-uv-member-sboms.mts b/src/commands/scan/generate-uv-member-sboms.mts index e01f2bc4b6..ce9614d1ad 100644 --- a/src/commands/scan/generate-uv-member-sboms.mts +++ b/src/commands/scan/generate-uv-member-sboms.mts @@ -1,4 +1,11 @@ -import { existsSync, promises as fs, rmSync } from 'node:fs' +import { + accessSync, + existsSync, + promises as fs, + constants as fsConstants, + rmSync, + statSync, +} from 'node:fs' import path from 'node:path' import { isDirSync } from '@socketsecurity/registry/lib/fs' @@ -6,6 +13,11 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { spawn } from '@socketsecurity/registry/lib/spawn' import constants from '../../constants.mts' +import { + cleanupBundledTool, + getBundledTool, + isStandaloneSea, +} from '../../sea/runtime.mts' import { InputError, getErrorMessage } from '../../utils/errors.mts' import type { GeneratedScanFiles } from './handle-create-new-scan.mts' @@ -13,6 +25,32 @@ import type { GeneratedScanFiles } from './handle-create-new-scan.mts' const CLEANUP_SIGNALS: NodeJS.Signals[] = ['SIGHUP', 'SIGINT', 'SIGTERM'] const UV_SBOM_FILENAME = 'socket-uv-cdx.json' +const UV_PATH_ENV = 'SOCKET_CLI_UV_PATH' + +function resolveUvExecutable(): string { + if (!isStandaloneSea()) { + return 'uv' + } + const override = process.env[UV_PATH_ENV] + if (!override) { + return '' + } + const executable = path.resolve(process.cwd(), override) + try { + accessSync( + executable, + process.platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK, + ) + if (!statSync(executable).isFile()) { + throw new Error('not a file') + } + } catch { + throw new InputError( + `${UV_PATH_ENV} must point to an executable uv binary, but '${executable}' is missing or not executable.`, + ) + } + return executable +} type Component = { name: string @@ -52,9 +90,15 @@ async function exportSbom( groups: '--all-groups' | '--no-default-groups', ): Promise { let content: string + const uvExecutable = resolveUvExecutable() + let bundledExecutable: string | undefined + let childClosed: Promise | undefined try { - const { stdout } = await spawn( - 'uv', + if (!uvExecutable) { + bundledExecutable = await getBundledTool('uv') + } + const child = spawn( + uvExecutable || bundledExecutable!, [ 'export', '--project', @@ -73,6 +117,10 @@ async function exportSbom( stdio: 'pipe', }, ) + if (child.process) { + childClosed = new Promise(resolve => child.process.once('close', resolve)) + } + const { stdout } = await child content = stdout } catch (e) { throw new InputError( @@ -81,6 +129,11 @@ async function exportSbom( ? String(e.stderr).trim() || getErrorMessage(e) : getErrorMessage(e), ) + } finally { + await childClosed + if (bundledExecutable) { + await cleanupBundledTool(bundledExecutable) + } } return parseSbom(content, memberDir) } diff --git a/src/commands/scan/generate-uv-member-sboms.test.mts b/src/commands/scan/generate-uv-member-sboms.test.mts index a8d6a4afb3..44fca29776 100644 --- a/src/commands/scan/generate-uv-member-sboms.test.mts +++ b/src/commands/scan/generate-uv-member-sboms.test.mts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { existsSync, promises as fs } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' @@ -9,9 +10,20 @@ import { resolveUvMemberDirs, } from './generate-uv-member-sboms.mts' -const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() })) +const { mockCleanupBundledTool, mockGetBundledTool, mockSea, mockSpawn } = + vi.hoisted(() => ({ + mockSpawn: vi.fn(), + mockGetBundledTool: vi.fn(), + mockCleanupBundledTool: vi.fn(), + mockSea: vi.fn(), + })) vi.mock('@socketsecurity/registry/lib/spawn', () => ({ spawn: mockSpawn })) +vi.mock('../../sea/runtime.mts', () => ({ + cleanupBundledTool: mockCleanupBundledTool, + getBundledTool: mockGetBundledTool, + isStandaloneSea: mockSea, +})) const sbom = JSON.stringify({ bomFormat: 'CycloneDX', @@ -28,6 +40,9 @@ describe('uv member SBOM export', () => { beforeEach(async () => { vi.clearAllMocks() + mockSea.mockReturnValue(false) + mockGetBundledTool.mockResolvedValue('/tmp/bundled-uv') + mockCleanupBundledTool.mockResolvedValue(undefined) cwd = await fs.mkdtemp(path.join(tmpdir(), 'socket-uv-unit-')) apiDir = path.join(cwd, 'packages/api') workerDir = path.join(cwd, 'packages/worker') @@ -77,6 +92,63 @@ describe('uv member SBOM export', () => { ) }) + it('cleans an extracted uv only after the child closes', async () => { + mockSea.mockReturnValue(true) + const closed: boolean[] = [] + mockSpawn.mockImplementation(() => { + const child = new EventEmitter() + const promise = Promise.resolve({ stdout: sbom }) as Promise<{ + stdout: string + }> & { process: EventEmitter } + Object.defineProperty(promise, 'process', { value: child }) + setTimeout(() => { + closed.push(true) + child.emit('close') + }, 0) + return promise + }) + mockCleanupBundledTool.mockImplementation(async () => { + expect(closed.length).toBeGreaterThan(0) + }) + + await generateUvMemberSboms([apiDir]) + expect(mockGetBundledTool).toHaveBeenCalledTimes(2) + expect(mockCleanupBundledTool).toHaveBeenCalledTimes(2) + }) + + it('does not extract or clean uv when SOCKET_CLI_UV_PATH overrides it', async () => { + mockSea.mockReturnValue(true) + const override = path.join(cwd, 'uv') + await fs.writeFile(override, 'external uv') + await fs.chmod(override, 0o700) + const previous = process.env['SOCKET_CLI_UV_PATH'] + process.env['SOCKET_CLI_UV_PATH'] = override + try { + await generateUvMemberSboms([apiDir]) + expect(mockSpawn).toHaveBeenCalledWith( + override, + expect.any(Array), + expect.any(Object), + ) + expect(mockGetBundledTool).not.toHaveBeenCalled() + expect(mockCleanupBundledTool).not.toHaveBeenCalled() + } finally { + if (previous === undefined) { + delete process.env['SOCKET_CLI_UV_PATH'] + } else { + process.env['SOCKET_CLI_UV_PATH'] = previous + } + } + }) + + it('surfaces failure to remove an extracted uv payload', async () => { + mockSea.mockReturnValue(true) + mockCleanupBundledTool.mockRejectedValueOnce(new Error('cleanup failed')) + await expect(generateUvMemberSboms([apiDir])).rejects.toThrow( + 'cleanup failed', + ) + }) + it('removes every written SBOM on cleanup', async () => { const { cleanup, files } = await generateUvMemberSboms([apiDir, workerDir]) expect(files).toEqual([ diff --git a/src/constants.mts b/src/constants.mts index 371f1ba22e..107d6bf200 100644 --- a/src/constants.mts +++ b/src/constants.mts @@ -1,4 +1,4 @@ -import { realpathSync } from 'node:fs' +import { readFileSync, realpathSync } from 'node:fs' import { createRequire } from 'node:module' import os from 'node:os' import path from 'node:path' @@ -24,6 +24,7 @@ const { CI, COLUMN_LIMIT, DOT_GIT_DIR, + DOT_PACKAGE_LOCK_JSON, DOT_SOCKET_DIR, EMPTY_FILE, EMPTY_VALUE, @@ -46,7 +47,6 @@ const { EXTENSIONS, EXTENSIONS_JSON, GITIGNORE, - DOT_PACKAGE_LOCK_JSON, LATEST, LICENSE, LICENSE_GLOB, @@ -72,9 +72,9 @@ const { PRE_COMMIT, README_GLOB, README_GLOB_RECURSIVE, - REGISTRY_SCOPE_DELIMITER, README_MD, REGISTRY, + REGISTRY_SCOPE_DELIMITER, RESOLUTIONS, SOCKET_GITHUB_ORG, SOCKET_IPC_HANDSHAKE, @@ -165,6 +165,7 @@ export type ENV = Remap< SOCKET_CLI_NO_API_TOKEN: boolean SOCKET_CLI_NPM_PATH: string SOCKET_CLI_ORG_SLUG: string + SOCKET_CLI_SOCKET_PATCH_PATH: string SOCKET_CLI_VIEW_ALL_RISKS: boolean SOCKET_PATCH_PROXY_URL: string TERM: string @@ -205,7 +206,6 @@ const CONFIG_KEY_ORG = 'org' // The Socket facts filename. The file itself is generated by the Coana CLI's // `manifest` commands; this constant must match the filename Coana emits. const DOT_SOCKET_DOT_FACTS_JSON = `${DOT_SOCKET_DIR}.facts.json` -const DLX_BINARY_CACHE_TTL = 7 * 24 * 60 * 60 * 1_000 // 7 days in milliseconds. const DRY_RUN_LABEL = '[DryRun]' const DRY_RUN_BAILING_NOW = `${DRY_RUN_LABEL}: Bailing now` const DRY_RUN_NOT_SAVING = `${DRY_RUN_LABEL}: Not saving` @@ -299,7 +299,6 @@ export type Constants = Remap< readonly CONFIG_KEY_ORG: typeof CONFIG_KEY_ORG readonly DOT_GIT_DIR: typeof DOT_GIT_DIR readonly DOT_SOCKET_DIR: typeof DOT_SOCKET_DIR - readonly DLX_BINARY_CACHE_TTL: typeof DLX_BINARY_CACHE_TTL readonly DOT_SOCKET_DOT_FACTS_JSON: typeof DOT_SOCKET_DOT_FACTS_JSON readonly DRY_RUN_BAILING_NOW: typeof DRY_RUN_BAILING_NOW readonly DRY_RUN_LABEL: typeof DRY_RUN_LABEL @@ -447,14 +446,38 @@ function getNpmStdioPipeOptions() { const LAZY_ENV = () => { const { env } = process const envHelpers = /*@__PURE__*/ require('@socketsecurity/registry/lib/env') - const utils = /*@__PURE__*/ require( - path.join(constants.rootPath, 'dist/utils.js'), - ) const envAsBoolean = envHelpers.envAsBoolean const envAsNumber = envHelpers.envAsNumber const envAsString = envHelpers.envAsString - const getConfigValueOrUndef = utils.getConfigValueOrUndef - const readOrDefaultSocketJson = utils.readOrDefaultSocketJson + // Keep this leaf independent from dist/utils.js. That chunk imports + // constants.js, so requiring it here forms a runtime cycle that Node SEA's + // VFS loader cannot safely resolve. + const savedConfigValue = (key: string): string | undefined => { + try { + const raw = readFileSync( + path.join(constants.socketAppDataPath, 'config.json'), + 'utf8', + ) + const config = JSON.parse( + Buffer.from(raw, 'base64').toString(), + ) as Record + const value = config[key] + return typeof value === 'string' ? value : undefined + } catch { + return undefined + } + } + const socketJson = (() => { + try { + return JSON.parse( + readFileSync(path.join(process.cwd(), SOCKET_JSON), 'utf8'), + ) as { + defaults?: { scan?: { github?: { githubApiUrl?: string } } } + } + } catch { + return undefined + } + })() const GITHUB_TOKEN = envAsString(env['GITHUB_TOKEN']) const INLINED_SOCKET_CLI_PUBLISHED_BUILD = envAsBoolean( process.env['INLINED_SOCKET_CLI_PUBLISHED_BUILD'], @@ -589,7 +612,7 @@ const LAZY_ENV = () => { envAsString(env['SOCKET_CLI_API_BASE_URL']) || // TODO: Remove legacy environment variable name. envAsString(env['SOCKET_SECURITY_API_BASE_URL']) || - getConfigValueOrUndef('apiBaseUrl') || + savedConfigValue('apiBaseUrl') || API_V0_URL, // Set the proxy that all requests are routed through. // https://github.com/SocketDev/socket-cli?tab=readme-ov-file#environment-variables-for-development @@ -629,8 +652,7 @@ const LAZY_ENV = () => { // https://docs.github.com/en/rest SOCKET_CLI_GITHUB_API_URL: envAsString(env['SOCKET_CLI_GITHUB_API_URL']) || - readOrDefaultSocketJson(process.cwd())?.defaults?.scan?.github - ?.githubApiUrl || + socketJson?.defaults?.scan?.github?.githubApiUrl || 'https://api.github.com', // A classic GitHub personal access token with the "repo" scope or a // fine-grained access token with at least read/write permissions set for @@ -650,6 +672,10 @@ const LAZY_ENV = () => { envAsString(env['SOCKET_CLI_ORG_SLUG']) || // Coana CLI accepts the SOCKET_ORG_SLUG environment variable. envAsString(env['SOCKET_ORG_SLUG']), + // Explicit external socket-patch executable for standalone builds. + SOCKET_CLI_SOCKET_PATCH_PATH: envAsString( + env['SOCKET_CLI_SOCKET_PATCH_PATH'], + ), // View all risks of a Socket wrapped npm/npx run. SOCKET_CLI_VIEW_ALL_RISKS: envAsBoolean(env[SOCKET_CLI_VIEW_ALL_RISKS]), // Override the public patch API proxy URL for socket-patch. @@ -673,10 +699,17 @@ const lazyBinCliPath = () => path.join(constants.binPath, 'cli.js') const lazyBlessedContribPath = () => path.join(constants.externalPath, 'blessed-contrib') +export function resolveBlessedTerm( + term: string | undefined, + win32: boolean, +): string { + return term || (win32 ? 'windows-ansi' : 'xterm') +} + const lazyBlessedOptions = () => Object.freeze({ smartCSR: true, - term: constants.WIN32 ? 'windows-ansi' : 'xterm', + term: resolveBlessedTerm(constants.ENV.TERM, constants.WIN32), useBCE: true, }) @@ -811,7 +844,14 @@ const lazyProcessEnv = () => null, ) -const lazyRootPath = () => path.join(realpathSync.native(__dirname), '..') +const lazyRootPath = () => { + // Node SEA's VFS paths are virtual and cannot be canonicalized with native + // realpath. The bootstrap publishes the archive root before loading the CLI. + const seaVfsRoot = ( + globalThis as typeof globalThis & { __socketSeaVfsRoot?: string } + ).__socketSeaVfsRoot + return seaVfsRoot ?? path.join(realpathSync.native(__dirname), '..') +} const lazyShadowBinPath = () => path.join(constants.rootPath, 'shadow-npm-bin') @@ -848,7 +888,9 @@ const lazySocketAppDataPath = (): string | undefined => { : constants.ENV.XDG_DATA_HOME if (!dataHome) { if (WIN32) { - const logger = /*@__PURE__*/ require('@socketsecurity/registry/lib/logger') + const { + logger, + } = /*@__PURE__*/ require('@socketsecurity/registry/lib/logger') logger.warn(`Missing %LOCALAPPDATA%.`) } else { dataHome = path.join( @@ -1153,7 +1195,6 @@ export { CONFIG_KEY_DEFAULT_ORG, CONFIG_KEY_ENFORCED_ORGS, CONFIG_KEY_ORG, - DLX_BINARY_CACHE_TTL, DOT_SOCKET_DOT_FACTS_JSON, DRY_RUN_BAILING_NOW, DRY_RUN_LABEL, diff --git a/src/sea/README.md b/src/sea/README.md index 1bccbc9822..a8e31d03f5 100644 --- a/src/sea/README.md +++ b/src/sea/README.md @@ -1,75 +1,72 @@ -# Socket CLI Self-Executable Application (SEA) +# Socket CLI Node 27 SEA -Build self-contained executables using Node.js SEA. +This prototype builds a standalone `socket` executable with Node 27. It embeds the application and tool files in Node's virtual file system (VFS), so startup does not install npm packages or download helper tools. -## Architecture +A release config is required at `.config/release.json`, `release.json`, or `.release.json`; release tools fail closed if it is missing, invalid, or conflicting. The shared policy supports `stable-from-hint` and `prerelease-only`; SEA builds require `prerelease-only`. Its `versionHint` is the intended final release version (`2.1.0`), so the published versions remain `2.1.0-rc.N`. The v1.x release workflow selects major version 1 and does not promote this track to a stable release. -The executable is a **thin wrapper** that downloads `@socketsecurity/cli` from npm on first use. +## Release recovery -Contains: -- Node.js runtime -- Bootstrap code to download CLI -- No actual CLI implementation +The npm workflow reserves the version tag and stages the verified package files. It does not create a GitHub release. Approve each stage ID with `pnpm run approve:npm `; the command uses pnpm 11 for stage commands and dispatches `release-github.yml`. That workflow waits until every package for the version is public before publishing the GitHub release. -On first run: -1. Downloads `@socketsecurity/cli` from npm -2. Installs to `~/.socket/cli/` -3. Runs with your arguments +| npm status | Action | +| --- | --- | +| `validating` | Wait, then retry the same stage ID. | +| `staged` | Approve with the same stage ID. | +| `published` | Do not stage again. Retry GitHub finalization with that stage ID. | +| `blocked` or `deleted` | Keep the reserved tag. The version is burned; prepare the next version. | -## Files +If a workflow stops before showing a stage ID, find the existing stage in its logs or npm before starting another release. Never rerun staging for a reserved tag. Changelogs start at the last public version, so changes from burned attempts carry forward. The first prerelease uses the latest stable release as its baseline; only a repository with no published release uses all history. -- **bootstrap.mts** - Thin wrapper that downloads CLI from npm -- **build-sea.mts** - Build script for creating executables +## Why Node SEA -## Building +The CLI already runs on Node. SEA keeps that runtime and uses Node's VFS to embed JavaScript and supporting files in one executable. This prototype has not benchmarked SEA against `@yao-pkg/pkg`, `nexe`, or Bun's compiler. Node 27 SEA is experimental, so the current choice is a prototype decision rather than a claim that it is the best production packager. -```bash -# Build for current platform -pnpm build:sea +## Build -# Build for specific platform/arch -pnpm build:sea -- --platform=darwin --arch=arm64 - -# Use specific Node version -pnpm build:sea -- --node-version=20.11.0 +```sh +pnpm build:sea -- --prototype ``` -### Options +The builder uses Node `v27.0.0-alpha.0.0.0`. It verifies the Node archive against the release `SHASUMS256.txt`. Node 27 is a prerelease, so these builds are experimental. -- `--platform=` - Target platform (win32, darwin, linux) -- `--arch=` - Target architecture (x64, arm64) -- `--node-version=` - Node.js version (default: 20.11.0) -- `--output-dir=` - Output directory (default: dist/sea) +Build on the target OS and architecture. The builder supports `darwin`, `linux`, and `win32`, with `x64` and `arm64` archives where Node publishes them. Cross-target builds fail. -## Output +The builder creates a private temporary copy of the distribution. It does not replace the checkout's `dist` or `external` directories. It writes the executable and manifest to `build/sea`. Use `--output-dir` or `SOCKET_CLI_SEA_OUTPUT_DIR` to select another directory; the command-line option takes precedence. -Executables in `dist/sea/`: -- `socket-win-x64.exe` - Windows x64 -- `socket-win-arm64.exe` - Windows ARM64 -- `socket-macos-x64` - macOS Intel -- `socket-macos-arm64` - macOS Apple Silicon -- `socket-linux-x64` - Linux x64 -- `socket-linux-arm64` - Linux ARM64 +The builder stages independent payloads in parallel. It waits for all staging jobs before cleanup. It builds the .NET facts helper before Rollup copies the distribution. -## Usage +Verified source downloads are cached under `~/.cache/socket-sea/downloads`; each cache hit is checked against its pinned digest, and Node's `SHASUMS256.txt` is fetched fresh for each build. The runtime executable does not access this cache or download helper tools. -```bash -./dist/sea/socket-macos-arm64 --version -./dist/sea/socket-macos-arm64 scan . -./dist/sea/socket-macos-arm64 [options] -``` +## Embedded tools + +The build embeds the .NET facts helper, `socket-patch`, and `uv` when a verified payload exists for the target. The .NET helper still needs a compatible .NET SDK from the project or `PATH`. The scan command uses `SOCKET_CLI_UV_PATH` when set. Otherwise, it extracts embedded `uv` to a temporary directory when needed. + +The build stages and embeds the `cdxgen` and `synp` APIs for darwin-arm64, but the manifest marks both features unavailable. Their source provenance and third-party notice obligations are not resolved, so the CLI refuses to run them. Their notice files stay with this build because the payloads are already embedded in the executable. + +Rollup creates a notice inventory from each bundled dependency graph. Some pinned packages omit their license text from npm; exact package-version entries in `.config/rollup.cdxgen-sea.config.mjs` and `src/sea/rollup-provenance.mjs` supply the missing text. A dependency bump cannot reuse a mismatched entry, so the inventory records a missing-text obligation. README files are not used as license text or shipped as notice documents. + +The build does not embed Coana, Python, or a JVM toolchain. It does not add a runtime path to download helper tools. Socket API requests and project inputs keep their existing network behavior. -First run downloads CLI from npm. Subsequent runs use cached version. +## Manifest -## How It Works +Each successful build writes a manifest beside the executable. It records the target, Socket and Node versions, file digests, bundled tools, third-party notices, and available capabilities. Read the manifest's `capabilities` and notice `obligations` before using a bundled tool. The npm prerelease package uses the `rc-2.1.0` dist-tag; it does not use `latest` or `next`. -1. **First Run**: Downloads `@socketsecurity/cli` from npm to `~/.socket/cli/` -2. **Subsequent Runs**: Uses cached CLI -3. **Requirements**: System Node.js required to run downloaded CLI +## Prototype status + +Use the executable's `--version` output and the adjacent manifest's recorded Socket and Node versions to identify a build. Artifact size, digest, signature status, bundled tools, and capabilities depend on the build inputs and target; inspect the generated manifest for those values. The prototype remains experimental. Production signing, notarization, release provenance, and target qualification are incomplete. + +## Native terminal tests + +Use Node 26.13.0 and build the prototype on the host OS before running its native tests: + +For the screenshot baseline workflow, run `pnpm snapshots` to rebuild and verify the focused screenshot suites, or `pnpm snapshots:update` (equivalent to `pnpm snapshots -u`) to update baselines. Both commands print the self-contained report path. + +```sh +pnpm build:sea -- --prototype +SOCKET_CLI_NATIVE_TEST_ARTIFACTS=build/native-test-artifacts/run-1 pnpm test:native test/native/commands/cli.native.test.mts test/native/commands/cli-behavior.native.test.mts +node scripts/test/terminal-report.mts --artifacts build/native-test-artifacts/run-1 --output build/native-test-artifacts/run-1/index.html +``` -## Notes +Choose a fresh run directory each time; it stores checkpoint PNG/SVG files, the animation, and signing receipt. On macOS, configured Developer ID credentials sign the staged binary; CI exposes signing secrets only on trusted pushes. -- Small binary contains only bootstrap code -- CLI downloaded on first use -- Cached at `~/.socket/cli/` -- Requires Node.js installed on system \ No newline at end of file +Terminal images trim unused rows while preserving internal spacing. Each animation uses a shared height across its checkpoints. diff --git a/src/sea/adapters/cdxgen-worker.mjs b/src/sea/adapters/cdxgen-worker.mjs new file mode 100644 index 0000000000..d6874dd97e --- /dev/null +++ b/src/sea/adapters/cdxgen-worker.mjs @@ -0,0 +1,519 @@ +import childProcess from 'node:child_process' +import dgram from 'node:dgram' +import { readFile, writeFile } from 'node:fs/promises' +import http from 'node:http' +import http2 from 'node:http2' +import https from 'node:https' +import { syncBuiltinESMExports } from 'node:module' +import net from 'node:net' +import path from 'node:path' +import tls from 'node:tls' +import { fileURLToPath } from 'node:url' +import util from 'node:util' +import { workerData } from 'node:worker_threads' + +// eslint-disable-next-line import-x/no-unresolved, n/no-missing-import -- The worker path resolves in its staged SEA VFS payload. +import { isObjectObject } from './utils/is-object-object.mjs' + +const ipc = workerData?.socketIpc +if ( + !ipc || + typeof ipc.projectPath !== 'string' || + typeof ipc.sourceRoot !== 'string' || + typeof ipc.outputPath !== 'string' +) { + throw new Error('Invalid Socket cdxgen Worker request') +} + +// cdxgen probes host tools while importing its API. A nonempty allowlist +// prevents those probes from spawning commands; the restricted npm API path +// does not need any subprocesses. +process.env.CDXGEN_PLUGINS_DIR = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'plugins-empty', +) +process.env.CDXGEN_ALLOWED_COMMANDS = 'socket-sea-no-commands-allowed' + +const denied = () => { + throw new Error( + 'Standalone cdxgen cannot start subprocesses or access the network', + ) +} +for (const name of [ + 'spawn', + 'spawnSync', + 'exec', + 'execSync', + 'execFile', + 'execFileSync', + 'fork', +]) { + childProcess[name] = denied +} +for (const module of [http, https]) { + module.request = denied + module.get = denied +} +net.connect = denied +net.createConnection = denied +net.Socket.prototype.connect = denied +http2.connect = denied +dgram.createSocket = denied +tls.connect = denied +tls.TLSSocket.prototype.connect = denied +globalThis.fetch = denied +syncBuiltinESMExports() + +const isolatedRoot = path.resolve(ipc.projectPath) +const originalRoot = path.resolve(ipc.sourceRoot) +const packageJson = JSON.parse( + await readFile(path.join(isolatedRoot, 'package.json'), 'utf8'), +) +const packageLock = JSON.parse( + await readFile(path.join(isolatedRoot, 'package-lock.json'), 'utf8'), +) +if (!isObjectObject(packageJson)) { + throw new Error('Invalid package.json in cdxgen project') +} +if (!isObjectObject(packageLock)) { + throw new Error('Malformed npm package-lock.json') +} +if (![1, 3].includes(packageLock.lockfileVersion)) { + throw new Error( + 'The standalone cdxgen API supports package-lock versions 1 and 3 only', + ) +} +if (packageJson.workspaces || packageJson.workspace) { + throw new Error('The standalone cdxgen API does not support workspaces') +} +const hasLocalDependency = value => + typeof value === 'string' && /^(?:file:|link:|workspace:)/i.test(value) +const validPackageName = name => { + if (typeof name !== 'string' || !name || name.includes('\\')) { + return false + } + const parts = name.split('/') + if (parts.some(part => !part || part === '.' || part === '..')) { + return false + } + return name.startsWith('@') + ? parts.length === 2 && parts[0].length > 1 + : parts.length === 1 +} +for (const group of [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', +]) { + if (packageJson[group] !== undefined && !isObjectObject(packageJson[group])) { + throw new Error(`Invalid ${group} in package.json`) + } + if ( + Object.keys(packageJson[group] || {}).some(name => !validPackageName(name)) + ) { + throw new Error(`Invalid package name in package.json ${group}`) + } + if (Object.values(packageJson[group] || {}).some(hasLocalDependency)) { + throw new Error( + 'The standalone cdxgen API does not support local or workspace dependencies', + ) + } +} +const inspectV1Dependencies = ( + dependencies, + dependencyPath = 'dependencies', +) => { + if (dependencies === undefined) { + return + } + if (!isObjectObject(dependencies)) { + throw new Error(`Malformed package-lock v1 ${dependencyPath}`) + } + for (const [name, dependency] of Object.entries(dependencies)) { + if (!validPackageName(name)) { + throw new Error(`Invalid package-lock v1 dependency name '${name}'`) + } + if (!isObjectObject(dependency)) { + throw new Error(`Malformed package-lock v1 dependency '${name}'`) + } + if (typeof dependency.version !== 'string' || !dependency.version) { + throw new Error(`Malformed package-lock v1 version for '${name}'`) + } + for (const metadataKey of [ + '_socketOptionalDependencies', + '_socketPeerDependencies', + ]) { + if ( + dependency[metadataKey] !== undefined && + (!Array.isArray(dependency[metadataKey]) || + dependency[metadataKey].some(item => typeof item !== 'string')) + ) { + throw new Error(`Malformed Synp dependency metadata for '${name}'`) + } + } + if ( + hasLocalDependency(dependency.version) || + hasLocalDependency(dependency.resolved) || + dependency.link + ) { + throw new Error( + 'The standalone cdxgen API does not support local or linked lockfile entries', + ) + } + inspectV1Dependencies( + dependency.dependencies, + `${dependencyPath}.${name}.dependencies`, + ) + } +} +const checkRootDependencyGroup = ( + group, + lockDependencies, + groupName, + optional, + optionalNames = new Set(), +) => { + if (group === undefined) { + return + } + if (!isObjectObject(group)) { + throw new Error(`Malformed package.json ${groupName}`) + } + for (const name of Object.keys(group)) { + if ( + !( + isObjectObject(lockDependencies) && + Object.hasOwn(lockDependencies, name) + ) && + !optional && + !optionalNames.has(name) + ) { + throw new Error( + `package-lock is missing required root ${groupName} '${name}'`, + ) + } + } +} + +if (packageLock.lockfileVersion === 1) { + const rootDependencies = packageLock.dependencies ?? {} + if (!isObjectObject(rootDependencies)) { + throw new Error('Malformed package-lock v1 dependencies map') + } + inspectV1Dependencies(rootDependencies) + checkRootDependencyGroup( + packageJson.dependencies, + rootDependencies, + 'dependencies', + false, + new Set(Object.keys(packageJson.optionalDependencies || {})), + ) + checkRootDependencyGroup( + packageJson.devDependencies, + rootDependencies, + 'devDependencies', + false, + ) + checkRootDependencyGroup( + packageJson.optionalDependencies, + rootDependencies, + 'optionalDependencies', + true, + ) + checkRootDependencyGroup( + packageJson.peerDependencies, + rootDependencies, + 'peerDependencies', + true, + ) + + const resolveV1Dependency = (name, ownDependencies, ancestorMaps) => + Boolean( + (isObjectObject(ownDependencies) && + Object.hasOwn(ownDependencies, name)) || + ancestorMaps.some( + map => isObjectObject(map) && Object.hasOwn(map, name), + ), + ) + const validateV1Graph = (dependencies, ancestorMaps = []) => { + for (const [packageName, dependency] of Object.entries( + dependencies || {}, + )) { + const requires = dependency.requires + if (requires !== undefined && !isObjectObject(requires)) { + throw new Error( + `Malformed package-lock v1 requires map for '${packageName}'`, + ) + } + if ( + Object.values(requires || {}).some(value => typeof value !== 'string') + ) { + throw new Error( + `Malformed package-lock v1 requirement spec for '${packageName}'`, + ) + } + const optional = new Set(dependency._socketOptionalDependencies || []) + const peers = new Set(dependency._socketPeerDependencies || []) + const visibleMaps = [dependencies, ...ancestorMaps] + for (const requiredName of Object.keys(requires || {})) { + if (!validPackageName(requiredName)) { + throw new Error( + `Invalid package-lock v1 requirement name '${requiredName}'`, + ) + } + if ( + !resolveV1Dependency( + requiredName, + dependency.dependencies, + visibleMaps, + ) && + !optional.has(requiredName) && + !Object.hasOwn(dependency.optionalDependencies || {}, requiredName) && + !peers.has(requiredName) + ) { + throw new Error( + `package-lock v1 is missing required '${requiredName}' for '${packageName}'`, + ) + } + } + validateV1Graph(dependency.dependencies, visibleMaps) + } + } + validateV1Graph(rootDependencies) +} else { + const packages = packageLock.packages + if ( + !isObjectObject(packages) || + !Object.hasOwn(packages, '') || + !isObjectObject(packages['']) + ) { + throw new Error( + 'Malformed package-lock v3 packages map or root package record', + ) + } + for (const [packagePath, dependency] of Object.entries(packages)) { + if (!isObjectObject(dependency)) { + throw new Error(`Malformed package-lock v3 record '${packagePath}'`) + } + if (packagePath) { + const parts = packagePath.split('/') + if ( + !packagePath.startsWith('node_modules/') || + packagePath.startsWith('/') || + packagePath.includes('\\') || + parts.some(part => !part || part === '.' || part === '..') + ) { + throw new Error(`Invalid package-lock v3 package path '${packagePath}'`) + } + if (typeof dependency.version !== 'string' || !dependency.version) { + throw new Error(`Missing package-lock v3 version for '${packagePath}'`) + } + if ( + hasLocalDependency(dependency.version) || + hasLocalDependency(dependency.resolved) || + dependency.link + ) { + throw new Error( + 'The standalone cdxgen API does not support local or linked lockfile entries', + ) + } + } + } + + const resolveV3Dependency = (name, packagePath) => { + let current = packagePath + while (current) { + const candidate = `${current}/node_modules/${name}` + if (Object.hasOwn(packages, candidate)) { + return true + } + const parentMarker = current.lastIndexOf('/node_modules/') + current = parentMarker < 0 ? '' : current.slice(0, parentMarker) + } + return Object.hasOwn(packages, `node_modules/${name}`) + } + const validateV3DependencyGroup = ( + group, + packagePath, + groupName, + optional, + optionalNames = new Set(), + ) => { + if (group === undefined) { + return + } + if (!isObjectObject(group)) { + throw new Error( + `Malformed package-lock v3 ${groupName} map at '${packagePath || ''}'`, + ) + } + for (const name of Object.keys(group)) { + if ( + !resolveV3Dependency(name, packagePath) && + !optional && + !optionalNames.has(name) + ) { + throw new Error( + `package-lock v3 is missing required '${name}' at '${packagePath || ''}'`, + ) + } + } + } + const rootOptionalNames = new Set( + Object.keys(packageJson.optionalDependencies || {}), + ) + const validateV3RootGroup = (group, groupName, optional) => { + if (group === undefined) { + return + } + if (!isObjectObject(group)) { + throw new Error(`Malformed package.json ${groupName}`) + } + for (const name of Object.keys(group)) { + if ( + !resolveV3Dependency(name, '') && + !optional && + !(groupName === 'dependencies' && rootOptionalNames.has(name)) + ) { + throw new Error( + `package-lock v3 is missing required root ${groupName} '${name}'`, + ) + } + } + } + validateV3RootGroup(packageJson.dependencies, 'dependencies', false) + validateV3RootGroup(packageJson.devDependencies, 'devDependencies', false) + validateV3RootGroup( + packageJson.optionalDependencies, + 'optionalDependencies', + true, + ) + validateV3RootGroup(packageJson.peerDependencies, 'peerDependencies', true) + for (const [packagePath, dependency] of Object.entries(packages)) { + validateV3DependencyGroup( + dependency.dependencies, + packagePath, + 'dependencies', + false, + new Set(Object.keys(dependency.optionalDependencies || {})), + ) + validateV3DependencyGroup( + dependency.devDependencies, + packagePath, + 'devDependencies', + packagePath !== '', + ) + validateV3DependencyGroup( + dependency.optionalDependencies, + packagePath, + 'optionalDependencies', + true, + ) + validateV3DependencyGroup( + dependency.peerDependencies, + packagePath, + 'peerDependencies', + true, + ) + } +} +// eslint-disable-next-line import-x/no-unresolved, n/no-missing-import -- The package is staged beside this worker in the SEA VFS. +const { createNodejsBom } = await import('./lib/cli/index.js') + +let incompleteNotice = '' +const originalConsole = new Map() +for (const method of ['log', 'warn', 'error']) { + const original = console[method] + originalConsole.set(method, original) + console[method] = (...args) => { + const message = util.format(...args) + if ( + message.includes('Unable to parse') && + message.includes('The resulting SBOM would be incomplete.') + ) { + incompleteNotice += message + } + original.apply(console, args) + } +} +let bom +try { + bom = await createNodejsBom(isolatedRoot, { + projectType: ['npm'], + installDeps: false, + noBabel: true, + recurse: false, + deep: false, + evidence: false, + includeCrypto: false, + includeFormulation: false, + lifecycle: 'pre-build', + specVersion: 1.6, + output: undefined, + }) +} finally { + for (const [method, original] of originalConsole) { + console[method] = original + } +} +if (incompleteNotice) { + throw new Error( + 'cdxgen could not parse the package-lock; refusing an incomplete SBOM', + ) +} +if (!bom?.bomJson) { + throw new Error('cdxgen did not produce an npm BOM') +} +const rewriteTemporaryPath = sourceValue => { + const sourcePath = path.resolve( + path.isAbsolute(sourceValue) + ? sourceValue + : path.join(isolatedRoot, sourceValue), + ) + if ( + sourcePath === isolatedRoot || + sourcePath.startsWith(`${isolatedRoot}${path.sep}`) + ) { + return path.join(originalRoot, path.relative(isolatedRoot, sourcePath)) + } + return sourceValue +} +const rewriteSourcePaths = (value, inEvidence = false) => { + if (!value || typeof value !== 'object') { + return + } + if (Array.isArray(value)) { + for (const child of value) { + rewriteSourcePaths(child, inEvidence) + } + return + } + if (value.name === 'SrcFile' && typeof value.value === 'string') { + value.value = rewriteTemporaryPath(value.value) + } else if ( + value.name === 'cdx:npm:binPaths' && + typeof value.value === 'string' + ) { + value.value = value.value.split(', ').map(rewriteTemporaryPath).join(', ') + } + const evidence = inEvidence || Object.hasOwn(value, 'identity') + for (const [key, child] of Object.entries(value)) { + if ( + evidence && + ['value', 'concludedValue', 'location'].includes(key) && + typeof child === 'string' + ) { + value[key] = rewriteTemporaryPath(child) + } else { + rewriteSourcePaths(child, evidence || key === 'evidence') + } + } +} +rewriteSourcePaths(bom.bomJson) +const serialized = `${JSON.stringify(bom.bomJson, null, 2)}\n` +if (ipc.outputPath === '-') { + process.stdout.write(serialized) +} else { + await writeFile(ipc.outputPath, serialized, { encoding: 'utf8' }) +} diff --git a/src/sea/adapters/cdxgen.mts b/src/sea/adapters/cdxgen.mts new file mode 100644 index 0000000000..08f7da0326 --- /dev/null +++ b/src/sea/adapters/cdxgen.mts @@ -0,0 +1,95 @@ +import { EventEmitter } from 'node:events' +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { runEmbeddedJsTool } from '../runtime.mts' + +import type { DlxOptions } from '../../utils/dlx.mts' +import type { EmbeddedToolRunResult } from '../runtime.mts' + +export async function runCdxgen( + projectPath: string, + outputPath: string, + options?: DlxOptions, + beforeExit?: (() => Promise) | undefined, +): Promise<{ + spawnPromise: Promise & { process: EventEmitter } +}> { + const isolatedRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'socket-cdxgen-api-'), + ) + try { + const packageJson = JSON.parse( + await fs.readFile(path.join(projectPath, 'package.json'), 'utf8'), + ) as Record + if (typeof packageJson['name'] !== 'string' || !packageJson['name']) { + // cdxgen otherwise uses the directory basename as its fallback project + // name; retain the user's project identity while scanning the temp root. + packageJson['name'] = path.basename(path.resolve(projectPath)) + } + await Promise.all([ + fs.writeFile( + path.join(isolatedRoot, 'package.json'), + `${JSON.stringify(packageJson)}\n`, + ), + fs.copyFile( + path.join(projectPath, 'package-lock.json'), + path.join(isolatedRoot, 'package-lock.json'), + ), + ]) + } catch (error) { + await fs.rm(isolatedRoot, { recursive: true, force: true }) + throw error + } + + const process = new EventEmitter() + const cleanTemp = async () => + await fs.rm(isolatedRoot, { recursive: true, force: true }) + const embeddedPromise = runEmbeddedJsTool('cdxgen', [], { + cwd: options?.cwd, + env: { CDXGEN_PLUGINS_DIR: 'plugins-empty' }, + inheritEnv: false, + signal: options?.signal, + stdio: options?.stdio, + ipc: { + projectPath: isolatedRoot, + sourceRoot: path.resolve(projectPath), + outputPath, + }, + }) + const finalize = async ( + code: number | null, + signal: NodeJS.Signals | null, + ) => { + const cleanupResults = await Promise.allSettled([ + cleanTemp(), + Promise.resolve().then(async () => await beforeExit?.()), + ]) + const cleanupErrors = cleanupResults.flatMap(result => + result.status === 'rejected' ? [result.reason] : [], + ) + const failure = + cleanupErrors.length === 0 + ? undefined + : cleanupErrors.length === 1 + ? cleanupErrors[0] + : new AggregateError(cleanupErrors, 'cdxgen cleanup failed') + process.emit('exit', failure ? 1 : code, failure ? null : signal) + if (failure) { + throw failure + } + } + const spawnPromise = embeddedPromise.then( + async result => { + await finalize(result.code, result.signal) + return result + }, + async error => { + await finalize(1, null) + throw error + }, + ) as Promise & { process: EventEmitter } + Object.defineProperty(spawnPromise, 'process', { value: process }) + return { spawnPromise } +} diff --git a/src/sea/adapters/synp-worker.mjs b/src/sea/adapters/synp-worker.mjs new file mode 100644 index 0000000000..92319afdcb --- /dev/null +++ b/src/sea/adapters/synp-worker.mjs @@ -0,0 +1,191 @@ +/* eslint-disable no-await-in-loop -- Nested lockfile annotations are written in traversal order. */ + +import { createHash } from 'node:crypto' +import { open, readFile, stat } from 'node:fs/promises' +import { createRequire } from 'node:module' +import path from 'node:path' +import { workerData } from 'node:worker_threads' + +import { isObjectObject } from '../../utils/is-object-object.mjs' + +const require = createRequire(import.meta.url) + +async function main() { + const ipc = workerData && workerData.socketIpc + if ( + !ipc || + typeof ipc.projectPath !== 'string' || + typeof ipc.outputPath !== 'string' || + typeof ipc.tempDev !== 'string' || + typeof ipc.tempIno !== 'string' || + path.dirname(path.resolve(ipc.outputPath)) !== + path.resolve(ipc.projectPath) || + !/^\.socket-cdxgen-lock-[a-f\d-]+\.tmp$/i.test( + path.basename(ipc.outputPath), + ) + ) { + throw new Error('Invalid Socket Synp Worker request') + } + + const projectPath = path.resolve(ipc.projectPath) + const outputPath = path.resolve(ipc.outputPath) + const yarnLockPath = path.join(projectPath, 'yarn.lock') + const yarnLock = await readFile(yarnLockPath, 'utf8') + if (!/^# yarn lockfile v1(?:\r?\n|$)/m.test(yarnLock)) { + throw new Error('Embedded Synp supports Yarn Classic lockfile v1 only') + } + + const packageJson = JSON.parse( + await readFile(path.join(projectPath, 'package.json'), 'utf8'), + ) + if (packageJson.workspaces) { + throw new Error('Embedded Synp does not support workspaces') + } + if ( + Object.values({ + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.optionalDependencies, + }).some( + value => typeof value === 'string' && value.startsWith('workspace:'), + ) + ) { + throw new Error( + 'Embedded Synp does not support Yarn workspace dependencies', + ) + } + const nodeModules = await stat(path.join(projectPath, 'node_modules')) + if (!nodeModules.isDirectory()) { + throw new Error('Embedded Synp requires an existing node_modules directory') + } + + const synp = require('synp') + const converted = JSON.parse(synp.yarnToNpm(projectPath, false)) + if (converted.lockfileVersion !== 1) { + throw new Error('Synp returned an unsupported package-lock format') + } + const annotateDependencies = async (dependencies, modulesPath) => { + if (dependencies !== undefined && !isObjectObject(dependencies)) { + throw new Error('Synp returned a malformed dependencies map') + } + for (const [name, entry] of Object.entries(dependencies || {})) { + const packagePath = path.join(modulesPath, ...name.split('/')) + let manifest + try { + manifest = JSON.parse( + await readFile(path.join(packagePath, 'package.json'), 'utf8'), + ) + } catch { + throw new Error( + `Synp lock entry '${name}' has no matching installed package manifest`, + ) + } + entry._socketOptionalDependencies = Object.keys( + manifest.optionalDependencies || {}, + ) + entry._socketPeerDependencies = Object.keys( + manifest.peerDependencies || {}, + ) + await annotateDependencies( + entry.dependencies, + path.join(packagePath, 'node_modules'), + ) + } + } + await annotateDependencies( + converted.dependencies, + path.join(projectPath, 'node_modules'), + ) + const findDependency = (name, ownDependencies, ancestorMaps) => { + if ( + isObjectObject(ownDependencies) && + Object.hasOwn(ownDependencies, name) + ) { + return ownDependencies[name] + } + for (const map of ancestorMaps) { + if (isObjectObject(map) && Object.hasOwn(map, name)) { + return map[name] + } + } + } + const collectDependencies = (name, entry, ancestorMaps, collected) => { + if (!entry || collected.has(entry)) { + return + } + collected.add(entry) + if (entry.requires !== undefined && !isObjectObject(entry.requires)) { + throw new Error(`Synp returned a malformed requires map for '${name}'`) + } + const visibleMaps = [entry.dependencies, ...ancestorMaps] + const dependencyNames = new Set([ + ...Object.keys(entry.requires || {}), + ...(entry._socketOptionalDependencies || []), + ]) + for (const dependencyName of dependencyNames) { + collectDependencies( + dependencyName, + findDependency(dependencyName, entry.dependencies, visibleMaps), + visibleMaps, + collected, + ) + } + } + const productionEntries = new Set() + for (const name of [ + ...Object.keys(packageJson.dependencies || {}), + ...Object.keys(packageJson.optionalDependencies || {}), + ]) { + collectDependencies( + name, + findDependency(name, converted.dependencies, []), + [converted.dependencies], + productionEntries, + ) + } + const developmentEntries = new Set() + for (const name of Object.keys(packageJson.devDependencies || {})) { + collectDependencies( + name, + findDependency(name, converted.dependencies, []), + [converted.dependencies], + developmentEntries, + ) + } + for (const entry of productionEntries) { + entry.dev = false + } + for (const entry of developmentEntries) { + if (!productionEntries.has(entry)) { + entry.dev = true + } + } + const bytes = Buffer.from(`${JSON.stringify(converted, null, 2)}\n`) + const digest = createHash('sha256').update(bytes).digest('hex') + let handle + try { + handle = await open(outputPath, 'r+') + const createdStat = await handle.stat() + if ( + String(createdStat.dev) !== ipc.tempDev || + String(createdStat.ino) !== ipc.tempIno + ) { + throw new Error( + 'Synp temporary output ownership changed before conversion', + ) + } + await handle.truncate(0) + await handle.writeFile(bytes) + await handle.sync() + process.stdout.write( + `${JSON.stringify({ dev: String(createdStat.dev), ino: String(createdStat.ino), size: bytes.length, sha256: digest })}\n`, + ) + } finally { + await handle?.close() + } +} + +main().catch(error => { + process.stderr.write(`${error && error.stack ? error.stack : error}\n`) + process.exitCode = 1 +}) diff --git a/src/sea/adapters/synp.mts b/src/sea/adapters/synp.mts new file mode 100644 index 0000000000..be553709fc --- /dev/null +++ b/src/sea/adapters/synp.mts @@ -0,0 +1,166 @@ +import { createHash, randomUUID } from 'node:crypto' +import { promises as fs } from 'node:fs' +import path from 'node:path' + +import { assertEmbeddedToolAvailable, runEmbeddedJsTool } from '../runtime.mts' + +export interface OwnedPackageLock { + path: string + dev: string + ino: string + size: number + sha256: string +} + +interface SynpWorkerResult { + dev: string + ino: string + size: number + sha256: string +} + +async function removeIfOwned( + filename: string, + identity: Pick, +): Promise { + try { + const stat = await fs.lstat(filename) + if ( + String(stat.dev) === identity.dev && + String(stat.ino) === identity.ino + ) { + await fs.unlink(filename) + } + } catch (error) { + if ( + !(error instanceof Error && 'code' in error && error.code === 'ENOENT') + ) { + throw error + } + } +} + +export async function removeOwnedPackageLock( + owner: OwnedPackageLock | undefined, +): Promise { + if (!owner) { + return + } + try { + const [stat, contents] = await Promise.all([ + fs.lstat(owner.path), + fs.readFile(owner.path), + ]) + const digest = createHash('sha256').update(contents).digest('hex') + if ( + String(stat.dev) === owner.dev && + String(stat.ino) === owner.ino && + stat.size === owner.size && + digest === owner.sha256 + ) { + await fs.unlink(owner.path) + } + } catch (error) { + if ( + !(error instanceof Error && 'code' in error && error.code === 'ENOENT') + ) { + throw error + } + } +} + +export async function convertYarnClassicToNpmLock( + projectPath: string, + signal?: AbortSignal, +): Promise { + assertEmbeddedToolAvailable('synp') + const root = path.resolve(projectPath) + const packageLockPath = path.join(root, 'package-lock.json') + const temporaryPath = path.join( + root, + `.socket-cdxgen-lock-${randomUUID()}.tmp`, + ) + const placeholder = await fs.open(temporaryPath, 'wx', 0o600) + const placeholderStat = await placeholder.stat() + await placeholder.close() + const placeholderIdentity = { + dev: String(placeholderStat.dev), + ino: String(placeholderStat.ino), + } + let packageLockOwner: OwnedPackageLock | undefined + + try { + const result = await runEmbeddedJsTool('synp', [], { + cwd: root, + inheritEnv: false, + env: {}, + signal, + stdio: 'pipe', + ipc: { + projectPath: root, + outputPath: temporaryPath, + tempDev: placeholderIdentity.dev, + tempIno: placeholderIdentity.ino, + }, + }) + if (result.code !== 0 || result.cancelled) { + throw Object.assign( + new Error( + result.stderr?.trim() || 'Embedded Synp failed to convert yarn.lock.', + ), + { code: result.signal ?? undefined }, + ) + } + + let converted: SynpWorkerResult + try { + converted = JSON.parse(result.stdout ?? '') as SynpWorkerResult + } catch { + throw new Error('Embedded Synp returned invalid ownership metadata.') + } + if ( + converted.dev !== placeholderIdentity.dev || + converted.ino !== placeholderIdentity.ino || + !Number.isSafeInteger(converted.size) || + converted.size <= 0 || + !/^[a-f0-9]{64}$/.test(converted.sha256) + ) { + throw new Error('Embedded Synp returned invalid ownership metadata.') + } + + const [tempStat, tempContents] = await Promise.all([ + fs.lstat(temporaryPath), + fs.readFile(temporaryPath), + ]) + const tempDigest = createHash('sha256').update(tempContents).digest('hex') + if ( + String(tempStat.dev) !== converted.dev || + String(tempStat.ino) !== converted.ino || + tempStat.size !== converted.size || + tempDigest !== converted.sha256 || + JSON.parse(tempContents.toString('utf8')).lockfileVersion !== 1 + ) { + throw new Error( + 'Embedded Synp output failed ownership or format verification.', + ) + } + + // link() creates the destination atomically and fails if another process + // created package-lock.json after our initial absence check. + await fs.link(temporaryPath, packageLockPath) + packageLockOwner = { + path: packageLockPath, + dev: converted.dev, + ino: converted.ino, + size: converted.size, + sha256: converted.sha256, + } + await fs.unlink(temporaryPath) + return packageLockOwner + } catch (error) { + await removeOwnedPackageLock(packageLockOwner) + throw error + } finally { + await removeIfOwned(temporaryPath, placeholderIdentity) + } +} diff --git a/src/sea/archive-paths.mts b/src/sea/archive-paths.mts new file mode 100644 index 0000000000..d31e92f1d3 --- /dev/null +++ b/src/sea/archive-paths.mts @@ -0,0 +1,19 @@ +export function seaArchivePaths({ + hasCdxgen, + hasSynp, + hasUv, + rootFiles, +}: { + hasCdxgen: boolean + hasSynp: boolean + hasUv: boolean + rootFiles: string[] +}): string[] { + return [ + 'dist', + 'external', + ...rootFiles, + 'resources', + ...(hasCdxgen || hasSynp || hasUv ? ['tools'] : []), + ] +} diff --git a/src/sea/bootstrap.mts b/src/sea/bootstrap.mts index 42d7992268..b0870940b0 100644 --- a/src/sea/bootstrap.mts +++ b/src/sea/bootstrap.mts @@ -1,198 +1,7 @@ -/** - * Ultra-thin bootstrap wrapper for Socket CLI SEA. - * - * This is the THINNEST possible wrapper that: - * - Downloads @socketsecurity/cli from npm on first use - * - Executes it with user's arguments - * - Has NO external dependencies except Node.js built-ins - */ - -import { spawn } from 'node:child_process' -import crypto from 'node:crypto' -import { existsSync, promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -// Minimal constants. -const SOCKET_HOME = path.join(os.homedir(), '.socket') -const SOCKET_CLI_DIR = path.join(SOCKET_HOME, 'cli') -const SOCKET_CLI_PACKAGE = '@socketsecurity/cli' -const NPM_REGISTRY = 'https://registry.npmjs.org' - -async function getLatestVersion(): Promise { - const response = await fetch(`${NPM_REGISTRY}/${SOCKET_CLI_PACKAGE}/latest`) - if (!response.ok) { - throw new Error(`Failed to fetch package info: ${response.statusText}`) - } - const data = (await response.json()) as { version: string } - return data.version -} - -async function downloadPackage(version: string): Promise { - console.error(`Downloading ${SOCKET_CLI_PACKAGE}@${version} from npm...`) - - const tarballUrl = `${NPM_REGISTRY}/${SOCKET_CLI_PACKAGE}/-/cli-${version}.tgz` - const response = await fetch(tarballUrl) - - if (!response.ok) { - throw new Error(`Failed to download package: ${response.statusText}`) - } - - const tempDir = path.join( - SOCKET_HOME, - 'tmp', - crypto.createHash('sha256').update(`${version}`).digest('hex'), - ) - await fs.mkdir(tempDir, { recursive: true }) - - try { - const tarballPath = path.join(tempDir, 'package.tgz') - const buffer = Buffer.from(await response.arrayBuffer()) - await fs.writeFile(tarballPath, buffer) - - // Extract tarball using tar command. - await new Promise((resolve, reject) => { - const child = spawn('tar', ['-xzf', tarballPath, '-C', tempDir], { - stdio: 'ignore', - }) - child.on('exit', code => - code === 0 ? resolve() : reject(new Error('tar failed')), - ) - }) - - const packageDir = path.join(tempDir, 'package') - - if (existsSync(SOCKET_CLI_DIR)) { - await fs.rm(SOCKET_CLI_DIR, { recursive: true, force: true }) - } - - await fs.rename(packageDir, SOCKET_CLI_DIR) - - // Install dependencies. - console.error('Installing dependencies...') - await new Promise((resolve, reject) => { - const child = spawn( - 'npm', - ['install', '--production', '--no-save', '--no-audit', '--no-fund'], - { - cwd: SOCKET_CLI_DIR, - stdio: 'inherit', - }, - ) - child.on('exit', code => - code === 0 ? resolve() : reject(new Error('npm install failed')), - ) - }) - - console.error('Socket CLI downloaded successfully!') - } finally { - await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}) - } -} - -async function getInstalledVersion(): Promise { - const packageJsonPath = path.join(SOCKET_CLI_DIR, 'package.json') - - if (!existsSync(packageJsonPath)) { - return null - } - - try { - const content = await fs.readFile(packageJsonPath, 'utf8') - const packageJson = JSON.parse(content) as { version: string } - return packageJson.version - } catch { - return null - } -} - -async function main(): Promise { - try { - await fs.mkdir(SOCKET_HOME, { recursive: true }) - - const installedVersion = await getInstalledVersion() - - if (!installedVersion) { - console.error('First run detected. Downloading Socket CLI from npm...') - const latestVersion = await getLatestVersion() - await downloadPackage(latestVersion) - } - - // Find CLI entry point. - const packageJsonPath = path.join(SOCKET_CLI_DIR, 'package.json') - const content = await fs.readFile(packageJsonPath, 'utf8') - const packageJson = JSON.parse(content) as { - bin?: Record | string - } - - let cliPath: string - if (typeof packageJson.bin === 'string') { - cliPath = path.join(SOCKET_CLI_DIR, packageJson.bin) - } else if (packageJson.bin?.['socket']) { - cliPath = path.join(SOCKET_CLI_DIR, packageJson.bin['socket']) - } else { - cliPath = path.join(SOCKET_CLI_DIR, 'dist', 'cli.js') - } - - // Forward all arguments to the CLI. - const args = process.argv.slice(2) - - // The SEA contains Node.js runtime, so we need to find a way to execute - // the downloaded CLI. Since require() won't work in SEA context for external - // modules, we need to spawn using system Node.js if available. - - // Try using 'node' from PATH first. - const nodeCmd = 'node' - let useSystemNode = true - - // Quick check if node exists. - try { - const testChild = spawn('node', ['--version'], { stdio: 'pipe' }) - await new Promise(resolve => { - testChild.on('error', () => { - useSystemNode = false - resolve() - }) - testChild.on('exit', code => { - if (code !== 0) { - useSystemNode = false - } - resolve() - }) - }) - } catch { - useSystemNode = false - } - - if (!useSystemNode) { - console.error('Error: Node.js is required to run Socket CLI') - console.error( - 'The SEA wrapper has downloaded the CLI but needs Node.js to execute it.', - ) - console.error('Please install Node.js from https://nodejs.org/') - // eslint-disable-next-line n/no-process-exit - process.exit(1) - } - - // Spawn with system Node.js. - const child = spawn(nodeCmd, [cliPath, ...args], { - stdio: 'inherit', - env: process.env, - }) - - child.on('exit', code => { - // eslint-disable-next-line n/no-process-exit - process.exit(code || 0) - }) - } catch (error) { - console.error('Socket CLI bootstrap error:', error) - // eslint-disable-next-line n/no-process-exit - process.exit(1) - } -} - -main().catch(error => { - console.error('Fatal error:', error) - // eslint-disable-next-line n/no-process-exit - process.exit(1) -}) +/** Load the complete root Socket distribution relative to the SEA VFS main. */ +// Node places the VFS main at its mount root. Expose that stable root before +// loading Socket so native payload adapters can read embedded files by path. +;( + globalThis as typeof globalThis & { __socketSeaVfsRoot?: string } +).__socketSeaVfsRoot = __dirname +require('./dist/cli.js') diff --git a/src/sea/build-sea.mts b/src/sea/build-sea.mts index be5e314ce2..d63ed6e865 100644 --- a/src/sea/build-sea.mts +++ b/src/sea/build-sea.mts @@ -1,522 +1,1425 @@ #!/usr/bin/env node -/** - * Build script for creating self-executable Socket CLI applications. - * Uses Node.js Single Executable Application (SEA) feature. - * - * IMPORTANT: This builds a THIN WRAPPER that downloads @socketsecurity/cli on first use. - * The binary contains only: - * - Node.js runtime - * - Bootstrap code to download/execute @socketsecurity/cli - * - No actual CLI implementation - * - * The real Socket CLI code lives in the @socketsecurity/cli npm package, - * which is downloaded from npm registry on first run. - * - * Supported platforms: - * - Windows (x64, arm64) - * - macOS (x64, arm64) - * - Linux (x64, arm64) - * - * Usage: - * - Build all platforms: npm run build:sea - * - Build specific platform: npm run build:sea -- --platform=darwin --arch=x64 - * - Use advanced bootstrap: npm run build:sea -- --advanced - */ - -import crypto from 'node:crypto' +/* eslint-disable no-await-in-loop -- Target builds mutate shared staging and output paths. */ +/** Build a self-contained root Socket CLI using the pinned Node 27 SEA/VFS APIs. */ +import { createHash, randomUUID } from 'node:crypto' import { existsSync, promises as fs } from 'node:fs' import os from 'node:os' import path from 'node:path' -import url from 'node:url' - -import trash from 'trash' - -import { spawn } from '@socketsecurity/registry/lib/spawn' - -const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) - -interface BuildTarget { - platform: NodeJS.Platform - arch: string - nodeVersion: string - outputName: string +import { fileURLToPath, pathToFileURL } from 'node:url' +import { gunzipSync } from 'node:zlib' + +import { parse as parseYaml } from 'yaml' + +import { seaArchivePaths } from './archive-paths.mts' +import { acquireBuildLock } from './build/lock.mts' +import { publishArtifactPair } from './build/publish.mts' +import { createSeaCapabilities } from './capabilities.mts' +import { downloadBytes } from './download-bytes.mts' +import { cachedDownload } from './download-cache.mts' +import { stageSynp } from './stage-synp.mts' +import { stageUv } from './stage-uv.mts' +import { SEA_VERSION } from './version.mts' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(HERE, '../..') +const NODE_VERSION = '27.0.0-alpha.0.0.0' +const MAX_PINNED_ARCHIVE_BYTES = 512 * 1024 * 1024 +const MAX_NODE_SUMS_BYTES = 1024 * 1024 +// `pnpm build:dist:src` starts by deleting `dist/`. Keep SEA artifacts outside +// that managed distribution tree so an ordinary package build cannot erase +// the output of a preceding SEA build. +const DEFAULT_OUT = path.join(ROOT, 'build/sea') +const TARGETS = [ + ['darwin', 'arm64', 'darwin-arm64'], + ['darwin', 'x64', 'darwin-x64'], + ['linux', 'arm64', 'linux-arm64'], + ['linux', 'x64', 'linux-x64'], + ['win32', 'arm64', 'win-arm64'], + ['win32', 'x64', 'win-x64'], +] as const + +function sha256(data: Buffer): string { + return createHash('sha256').update(data).digest('hex') } -interface BuildOptions { - platform?: NodeJS.Platform - arch?: string - nodeVersion?: string - outputDir?: string +async function digestFile(filename: string): Promise { + return sha256(await fs.readFile(filename)) } -// Supported Node.js versions for SEA. -// Node v24+ has better SEA support and smaller binary sizes. -// const SUPPORTED_NODE_VERSIONS = ['20.11.0', '22.0.0', '24.8.0'] - -// Default Node.js version for SEA. -// Using v20 which has stable SEA support. -const DEFAULT_NODE_VERSION = '20.11.0' - -// Build targets for different platforms. -const BUILD_TARGETS: BuildTarget[] = [ - { - platform: 'win32', - arch: 'x64', - nodeVersion: DEFAULT_NODE_VERSION, - outputName: 'socket-win-x64.exe', - }, - { - platform: 'win32', - arch: 'arm64', - nodeVersion: DEFAULT_NODE_VERSION, - outputName: 'socket-win-arm64.exe', - }, - { - platform: 'darwin', - arch: 'x64', - nodeVersion: DEFAULT_NODE_VERSION, - outputName: 'socket-macos-x64', - }, - { - platform: 'darwin', - arch: 'arm64', - nodeVersion: DEFAULT_NODE_VERSION, - outputName: 'socket-macos-arm64', - }, - { - platform: 'linux', - arch: 'x64', - nodeVersion: DEFAULT_NODE_VERSION, - outputName: 'socket-linux-x64', - }, - { - platform: 'linux', - arch: 'arm64', - nodeVersion: DEFAULT_NODE_VERSION, - outputName: 'socket-linux-arm64', - }, -] - -/** - * Download Node.js binary for a specific platform. - */ -async function downloadNodeBinary( - version: string, - platform: NodeJS.Platform, - arch: string, -): Promise { - const nodeDir = path.join(os.homedir(), '.socket', 'node-binaries') - const platformArch = `${platform}-${arch}` - const nodeFilename = platform === 'win32' ? 'node.exe' : 'node' - const nodePath = path.join(nodeDir, `v${version}`, platformArch, nodeFilename) - - // Check if already downloaded. - if (existsSync(nodePath)) { - console.log(`Using cached Node.js ${version} for ${platformArch}`) - return nodePath - } - - // Construct download URL. - const baseUrl = 'https://nodejs.org/download/release' - const archMap: Record = { - x64: 'x64', - arm64: 'arm64', - ia32: 'x86', - } - const platformMap: Record = { - darwin: 'darwin', - linux: 'linux', - win32: 'win', - } - - const nodePlatform = platformMap[platform] - const nodeArch = archMap[arch] - const tarName = `node-v${version}-${nodePlatform}-${nodeArch}` - const extension = platform === 'win32' ? '.zip' : '.tar.gz' - const downloadUrl = `${baseUrl}/v${version}/${tarName}${extension}` - - console.log(`Downloading Node.js ${version} for ${platformArch}...`) - console.log(`URL: ${downloadUrl}`) - - // Download the archive. - const response = await fetch(downloadUrl) - if (!response.ok) { - throw new Error(`Failed to download Node.js: ${response.statusText}`) - } - - // Create temp directory. - const tempDir = path.join( - nodeDir, - 'tmp', - crypto.createHash('sha256').update(downloadUrl).digest('hex'), - ) - await fs.mkdir(tempDir, { recursive: true }) - - try { - // Save archive. - const archivePath = path.join(tempDir, `node${extension}`) - const buffer = Buffer.from(await response.arrayBuffer()) - await fs.writeFile(archivePath, buffer) - - // Extract archive. - if (platform === 'win32') { - // For Windows binaries, use unzip if available, otherwise skip. - // Note: We're building cross-platform, so we may be on macOS/Linux building for Windows. - if (process.platform === 'win32') { - // On Windows, use PowerShell. - await spawn( - 'powershell', - [ - '-Command', - `Expand-Archive -Path '${archivePath}' -DestinationPath '${tempDir}'`, - ], - { stdio: 'ignore' }, - ) - } else { - // On Unix building for Windows, try unzip. - await spawn('unzip', ['-q', archivePath, '-d', tempDir], { - stdio: 'ignore', +async function digestTree(directory: string): Promise { + const entries: Array<{ + path: string + type: 'file' | 'symlink' + mode: number + sha256?: string + target?: string + }> = [] + const visit = async (current: string): Promise => { + const names = (await fs.readdir(current)).sort() + for (const name of names) { + const fullPath = path.join(current, name) + const relativePath = path + .relative(directory, fullPath) + .split(path.sep) + .join('/') + const stat = await fs.lstat(fullPath) + if (stat.isDirectory()) { + await visit(fullPath) + } else if (stat.isFile()) { + entries.push({ + path: relativePath, + type: 'file', + mode: stat.mode & 0o7777, + sha256: await digestFile(fullPath), }) + } else if (stat.isSymbolicLink()) { + entries.push({ + path: relativePath, + type: 'symlink', + mode: stat.mode & 0o7777, + target: await fs.readlink(fullPath), + }) + } else { + throw new Error(`Unsupported non-file SEA payload entry: ${fullPath}`) } - } else { - // Use tar for Unix systems. - await spawn('tar', ['-xzf', archivePath, '-C', tempDir], { - stdio: 'ignore', - }) } + } + await visit(directory) + if (entries.length === 0) { + throw new Error(`Required SEA payload directory is empty: ${directory}`) + } + return sha256(Buffer.from(JSON.stringify(entries))) +} - // Find and move the Node binary. - const extractedDir = path.join(tempDir, tarName) - const extractedBinary = path.join( - extractedDir, - platform === 'win32' ? 'node.exe' : 'bin/node', - ) - - // Ensure target directory exists. - const targetDir = path.dirname(nodePath) - await fs.mkdir(targetDir, { recursive: true }) - - // Move binary to final location. - await fs.copyFile(extractedBinary, nodePath) +async function listThirdPartyArtifacts( + directory: string, + vfsPrefix: string, +): Promise<{ + inventories: Array<{ path: string; sha256: string }> + documents: Array<{ path: string; sha256: string }> +}> { + const inventories: Array<{ path: string; sha256: string }> = [] + const documents: Array<{ path: string; sha256: string }> = [] + const visit = async (current: string): Promise => { + for (const name of (await fs.readdir(current)).sort()) { + const fullPath = path.join(current, name) + const stat = await fs.lstat(fullPath) + if (stat.isDirectory()) { + await visit(fullPath) + } else if (stat.isFile()) { + const relative = path + .relative(directory, fullPath) + .split(path.sep) + .join('/') + const entry = { + path: `${vfsPrefix}/${relative}`, + sha256: await digestFile(fullPath), + } + if (/^THIRD-PARTY-NOTICES(?:-[A-Za-z0-9._-]+)?\.json$/.test(name)) { + inventories.push(entry) + } else if (relative.includes('third-party-notices/')) { + documents.push(entry) + } + } + } + } + await visit(directory) + inventories.sort((left, right) => left.path.localeCompare(right.path)) + documents.sort((left, right) => left.path.localeCompare(right.path)) + return { inventories, documents } +} - // Make executable on Unix. - if (platform !== 'win32') { - await fs.chmod(nodePath, 0o755) +async function normalizeTreeTimestamps(directory: string): Promise { + const fixedTimestamp = Date.UTC(1980, 0, 1) / 1000 + const visit = async (current: string): Promise => { + const names = (await fs.readdir(current)).sort() + for (const name of names) { + const fullPath = path.join(current, name) + const stat = await fs.lstat(fullPath) + if (stat.isDirectory()) { + await visit(fullPath) + } else if (!stat.isFile()) { + throw new Error(`Unsupported cdxgen payload entry: ${fullPath}`) + } + await fs.utimes(fullPath, fixedTimestamp, fixedTimestamp) } + await fs.utimes(current, fixedTimestamp, fixedTimestamp) + } + await visit(directory) +} - console.log(`Downloaded Node.js ${version} for ${platformArch}`) - return nodePath - } finally { - // Clean up temp directory using trash. - await trash(tempDir).catch(() => {}) +function integrityMatches(bytes: Buffer, integrity: string): boolean { + const [algorithm, encoded] = integrity.split('-', 2) + if (algorithm !== 'sha512' || !encoded) { + return false + } + return createHash('sha512').update(bytes).digest('base64') === encoded +} + +export function sha512IntegrityHex(integrity: string): string | undefined { + const [algorithm, encoded, extra] = integrity.split('-') + if (algorithm !== 'sha512' || !encoded || extra !== undefined) { + return undefined } + const digest = Buffer.from(encoded, 'base64') + if (digest.length !== 64 || digest.toString('base64') !== encoded) { + return undefined + } + return digest.toString('hex') } -/** - * Generate SEA configuration. - */ -async function generateSeaConfig( - entryPoint: string, - outputPath: string, -): Promise { - const configPath = path.join(path.dirname(outputPath), 'sea-config.json') - const blobPath = path.join(path.dirname(outputPath), 'sea-blob.blob') - - const config = { - main: entryPoint, - output: blobPath, - disableExperimentalSEAWarning: true, - useSnapshot: false, // Disable for compatibility. - useCodeCache: true, // Enable code cache for optimization. - assets: {}, // No assets to minimize size. - } - - await fs.writeFile(configPath, JSON.stringify(config, null, 2)) - return configPath +export function findNodeArchiveSha256( + sums: string, + archive: string, +): string | undefined { + const matches = sums.split(/\r?\n/).flatMap(line => { + const match = /^([a-fA-F0-9]{64})\s+\*?(.+?)\s*$/.exec(line) + return match?.[2] === archive ? [match[1]!.toLowerCase()] : [] + }) + return matches.length === 1 ? matches[0] : undefined } -/** - * Build SEA blob. - */ -async function buildSeaBlob( - nodeBinary: string, - configPath: string, -): Promise { - const config = JSON.parse(await fs.readFile(configPath, 'utf8')) - const blobPath = config.output - - console.log('Generating SEA blob...') - - // Generate the blob using the Node binary. - const spawnPromise = spawn( - nodeBinary, - ['--experimental-sea-config', configPath], - { stdio: 'inherit' }, +export function findLockedPackageIntegrity( + lock: string, + packageName: string, + version: string, +): string | undefined { + const escapedName = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = lock.match( + new RegExp( + `^ '${escapedName}@${escapedVersion}':\\r?\\n resolution: \\{integrity: (sha512-[^}]+)\\}`, + 'm', + ), ) + return match?.[1] +} - const result = await spawnPromise - if ( - result && - typeof result === 'object' && - 'exitCode' in result && - result['exitCode'] !== 0 - ) { +function readTarEntries( + archive: Buffer, + binaryEntry: string, +): Map { + const tar = gunzipSync(archive) + const files = new Map() + for (let offset = 0; offset + 512 <= tar.length; ) { + const header = tar.subarray(offset, offset + 512) + if (header.every(byte => byte === 0)) { + break + } + const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '') + const prefix = header + .subarray(345, 500) + .toString('utf8') + .replace(/\0.*$/, '') + const entryName = prefix ? `${prefix}/${name}` : name + const type = header[156] + if (type !== 0 && type !== 48) { + throw new Error(`Unsupported socket-patch tar entry type at ${entryName}`) + } + const sizeText = header + .subarray(124, 136) + .toString('ascii') + .replace(/\0.*$/, '') + .trim() + if (!/^[0-7]+$/.test(sizeText)) { + throw new Error(`Malformed socket-patch tar entry size at ${entryName}`) + } + const size = Number.parseInt(sizeText, 8) + const checksumText = header + .subarray(148, 156) + .toString('ascii') + .replace(/\0.*$/, '') + .trim() + const expectedChecksum = Number.parseInt(checksumText, 8) + let checksum = 0 + for (let index = 0; index < 512; index += 1) { + checksum += index >= 148 && index < 156 ? 32 : header[index]! + } + if (!Number.isFinite(expectedChecksum) || checksum !== expectedChecksum) { + throw new Error( + `Invalid socket-patch tar header checksum at ${entryName}`, + ) + } + const dataStart = offset + 512 + const dataEnd = dataStart + size + if (dataEnd > tar.length) { + throw new Error(`Truncated socket-patch tar entry at ${entryName}`) + } + if (entryName !== binaryEntry && entryName !== 'package/package.json') { + throw new Error(`Unexpected socket-patch tar entry: ${entryName}`) + } + if (files.has(entryName)) { + throw new Error(`Duplicate socket-patch tar entry: ${entryName}`) + } + files.set(entryName, Buffer.from(tar.subarray(dataStart, dataEnd))) + offset = dataStart + Math.ceil(size / 512) * 512 + } + if (!files.has(binaryEntry) || !files.has('package/package.json')) { throw new Error( - `Failed to generate SEA blob: exit code ${result['exitCode']}`, + 'socket-patch package tarball is missing its executable or package metadata', ) } - - return blobPath + return files } -/** - * Inject SEA blob into Node binary. - */ -async function injectSeaBlob( - nodeBinary: string, - blobPath: string, - outputPath: string, -): Promise { - console.log('Creating self-executable...') - - // Copy the Node binary. - await fs.copyFile(nodeBinary, outputPath) - - if (process.platform === 'darwin') { - // On macOS, remove signature before injection. - console.log('Removing signature...') - await spawn('codesign', ['--remove-signature', outputPath], { - stdio: 'inherit', - }) - - // Inject with macOS-specific flags. - console.log('Injecting SEA blob...') - await spawn( - 'pnpm', - [ - 'exec', - 'postject', - outputPath, - 'NODE_SEA_BLOB', - blobPath, - '--sentinel-fuse', - 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2', - '--macho-segment-name', - 'NODE_SEA', - ], - { stdio: 'inherit' }, +function validateNativeBinary( + bytes: Buffer, + platform: string, + arch: string, +): void { + let valid = false + if (platform === 'darwin') { + const magic = bytes.readUInt32LE(0) + const cpu = bytes.readUInt32LE(4) + valid = + (magic === 0xfeedfacf || magic === 0xcffaedfe) && + ((arch === 'arm64' && (cpu === 0x0100000c || cpu === 0x0c000001)) || + (arch === 'x64' && (cpu === 0x01000007 || cpu === 0x07000001))) + } else if (platform === 'linux') { + const machine = + bytes[5] === 2 ? bytes.readUInt16BE(18) : bytes.readUInt16LE(18) + valid = + bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) && + ((arch === 'arm64' && machine === 183) || + (arch === 'x64' && machine === 62)) + } else if ( + platform === 'win32' && + bytes.length >= 64 && + bytes.toString('ascii', 0, 2) === 'MZ' + ) { + const peOffset = bytes.readUInt32LE(0x3c) + valid = + peOffset + 6 <= bytes.length && + bytes.toString('ascii', peOffset, peOffset + 4) === 'PE\0\0' && + ((arch === 'arm64' && bytes.readUInt16LE(peOffset + 4) === 0xaa64) || + (arch === 'x64' && bytes.readUInt16LE(peOffset + 4) === 0x8664)) + } + if (!valid) { + throw new Error( + `socket-patch binary does not match target ${platform}-${arch}`, ) + } +} - // Re-sign the binary. - console.log('Re-signing binary...') - await spawn('codesign', ['--sign', '-', outputPath], { - stdio: 'inherit', - }) - } else if (process.platform === 'win32') { - // Windows injection. - await spawn( - 'pnpm', - [ - 'exec', - 'postject', - outputPath, - 'NODE_SEA_BLOB', - blobPath, - '--sentinel-fuse', - 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2', - ], - { stdio: 'inherit' }, +async function stageSocketPatch( + target: readonly [string, string, string], + stage: string, + version: string, + parentLicense: string, +): Promise< + | { packageName: string; integrity: string; digest: string; license: string } + | undefined +> { + const [platform, arch] = target + const packagePlatform = platform === 'win32' ? 'win32' : platform + const packageName = `@socketsecurity/socket-patch-${packagePlatform}-${arch}` + const lock = await fs.readFile(path.join(ROOT, 'pnpm-lock.yaml'), 'utf8') + const integrity = findLockedPackageIntegrity(lock, packageName, version) + if (!integrity) { + return undefined + } + const url = `https://registry.npmjs.org/${packageName}/-/${packageName.slice(packageName.lastIndexOf('/') + 1)}-${version}.tgz` + const digest = sha512IntegrityHex(integrity) + if (!digest) { + throw new Error( + `${packageName}@${version} has an invalid sha512 lockfile integrity value`, ) - console.log('Note: Windows binary may need signing for distribution') - } else { - // Linux injection. - await spawn( - 'pnpm', - [ - 'exec', - 'postject', - outputPath, - 'NODE_SEA_BLOB', - blobPath, - '--sentinel-fuse', - 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2', - ], - { stdio: 'inherit' }, + } + const tarball = await cachedDownload( + url, + { + algorithm: 'sha512', + digest, + maxBytes: MAX_PINNED_ARCHIVE_BYTES, + }, + () => + downloadBytes(url, { + maxBytes: MAX_PINNED_ARCHIVE_BYTES, + }), + ) + if (!integrityMatches(tarball, integrity)) { + throw new Error( + `${packageName}@${version} failed pnpm lockfile integrity verification`, + ) + } + const executableName = + platform === 'win32' ? 'socket-patch.exe' : 'socket-patch' + const binaryEntry = `package/${executableName}` + const entries = readTarEntries(tarball, binaryEntry) + const metadata = JSON.parse( + entries.get('package/package.json')!.toString('utf8'), + ) as { name?: string; version?: string; license?: string } + if (metadata.name !== packageName || metadata.version !== version) { + throw new Error( + `socket-patch package metadata mismatch for ${packageName}@${version}`, + ) + } + if (typeof metadata.license !== 'string' || metadata.license.trim() === '') { + throw new Error( + `${packageName}@${version} package metadata does not declare a license`, ) } + if (metadata.license !== parentLicense) { + throw new Error( + `${packageName}@${version} license ${metadata.license} does not match parent package license ${parentLicense}`, + ) + } + const binary = entries.get(binaryEntry)! + validateNativeBinary(binary, platform, arch) + const payloadDirectory = path.join(stage, 'resources') + await fs.mkdir(payloadDirectory, { recursive: true }) + await fs.writeFile(path.join(payloadDirectory, executableName), binary, { + mode: 0o755, + flag: 'wx', + }) + await fs.copyFile( + path.join(HERE, 'licenses/socket-patch-MIT.txt'), + path.join(payloadDirectory, 'socket-patch-LICENSE.txt'), + ) + return { + packageName, + integrity, + digest: sha256(binary), + license: metadata.license, + } } -/** - * Build a single target. - */ -async function buildTarget( - target: BuildTarget, - options: BuildOptions, -): Promise { - const { outputDir = path.join(__dirname, '../../dist/sea') } = options - - console.log( - `\nBuilding thin wrapper for ${target.platform}-${target.arch}...`, - ) - console.log('(Actual CLI will be downloaded from npm on first use)') +type StagedCdxgenApi = { + packageName: string + version: string + declaredLockIntegrity: string + license: string + digest: string + sourceInventorySha256: string + sourceModuleCount: number + noticePackageCount: number + licenseTextCount: number + noticeTextCount: number + obligationCount: number + noticeDocuments: Array<{ path: string; sha256: string }> +} - // Use the thin bootstrap for minimal size. - const tsEntryPoint = path.join(__dirname, 'bootstrap.mts') +async function stageCdxgenApi( + target: readonly [string, string, string], + stage: string, + version: string, +): Promise { + // Only darwin-arm64 has been qualified with the Node 27 VFS Worker smoke. + if (target[0] !== 'darwin' || target[1] !== 'arm64') { + return undefined + } - // Ensure output directory exists. - await fs.mkdir(outputDir, { recursive: true }) + const packageName = '@cyclonedx/cdxgen' + const packageRoot = path.join(ROOT, 'node_modules/@cyclonedx/cdxgen') + const packagePath = path.join(packageRoot, 'package.json') + if (!existsSync(packagePath)) { + throw new Error( + `Pinned ${packageName}@${version} is not installed at ${packagePath}`, + ) + } + const metadata = JSON.parse(await fs.readFile(packagePath, 'utf8')) as { + name?: string + version?: string + license?: string + } + if (metadata.name !== packageName || metadata.version !== version) { + throw new Error( + `Installed cdxgen package metadata does not match ${packageName}@${version}`, + ) + } + if (typeof metadata.license !== 'string' || metadata.license.trim() === '') { + throw new Error( + `${packageName}@${version} package.json does not declare a license`, + ) + } - // Build the bootstrap with Rollup to CommonJS for SEA. - const entryPoint = path.join(outputDir, 'bootstrap.cjs') - console.log('Building bootstrap...') + const lock = await fs.readFile(path.join(ROOT, 'pnpm-lock.yaml'), 'utf8') + const integrity = findLockedPackageIntegrity(lock, packageName, version) + if (!integrity) { + throw new Error( + `pnpm-lock.yaml has no integrity for ${packageName}@${version}`, + ) + } - // Set environment variables for the rollup config. - process.env['SEA_BOOTSTRAP'] = tsEntryPoint - process.env['SEA_OUTPUT'] = entryPoint + const payloadRoot = path.join(stage, 'tools/cdxgen') + await fs.mkdir(payloadRoot, { recursive: true }) + await run( + 'pnpm', + ['exec', 'rollup', '-c', '.config/rollup.cdxgen-sea.config.mjs'], + { + SEA_CDXGEN_INPUT: path.join(packageRoot, 'lib/cli/index.js'), + SEA_CDXGEN_OUTPUT: payloadRoot, + }, + ) + const entrypoint = path.join(payloadRoot, 'lib/cli/index.js') + const entryStat = await fs.stat(entrypoint).catch(() => undefined) + if (!entryStat?.isFile() || entryStat.size === 0) { + throw new Error('Rollup did not produce the embedded cdxgen API entrypoint') + } + const noticeInventoryPath = path.join(payloadRoot, 'THIRD-PARTY-NOTICES.json') + const noticeInventoryStat = await fs + .stat(noticeInventoryPath) + .catch(() => undefined) + if (!noticeInventoryStat?.isFile() || noticeInventoryStat.size === 0) { + throw new Error( + 'Rollup did not produce the cdxgen third-party notice inventory', + ) + } + const noticeInventory = JSON.parse( + await fs.readFile(noticeInventoryPath, 'utf8'), + ) as { + packages?: Array<{ + licenseFiles?: Array<{ path?: unknown; sha256?: unknown }> + noticeFiles?: Array<{ path?: unknown; sha256?: unknown }> + }> + obligations?: unknown[] + } + if ( + !Array.isArray(noticeInventory.packages) || + !Array.isArray(noticeInventory.obligations) + ) { + throw new Error( + 'The cdxgen third-party notice inventory has an invalid structure', + ) + } + const noticeDocuments = noticeInventory.packages + .flatMap(item => [ + ...(item.licenseFiles ?? []), + ...(item.noticeFiles ?? []), + ]) + .filter( + (item): item is { path: string; sha256: string } => + typeof item.path === 'string' && typeof item.sha256 === 'string', + ) + .map(item => ({ path: item.path, sha256: item.sha256 })) + .sort((left, right) => left.path.localeCompare(right.path)) + const sourceInventoryPath = path.join( + payloadRoot, + 'ROLLUP-SOURCE-INVENTORY.json', + ) + const sourceInventoryBytes = await fs + .readFile(sourceInventoryPath) + .catch(() => undefined) + if (!sourceInventoryBytes) { + throw new Error('Rollup did not produce the cdxgen source hash inventory') + } + const sourceInventory = JSON.parse(sourceInventoryBytes.toString('utf8')) as { + verification?: string + modules?: unknown[] + } + if ( + sourceInventory.verification !== 'unverified' || + !Array.isArray(sourceInventory.modules) + ) { + throw new Error( + 'The cdxgen source hash inventory has an invalid or unexpectedly verified structure', + ) + } - await spawn('pnpm', ['run', 'build:sea:internal:bootstrap'], { - stdio: 'inherit', + const dataDirectory = path.join(packageRoot, 'data') + if (!existsSync(dataDirectory)) { + throw new Error( + `Required cdxgen data directory is missing: ${dataDirectory}`, + ) + } + await fs.cp(dataDirectory, path.join(payloadRoot, 'data'), { + recursive: true, }) - - // Download Node.js binary for target platform. - const nodeBinary = await downloadNodeBinary( - target.nodeVersion, - target.platform, - target.arch, + await fs.copyFile(packagePath, path.join(payloadRoot, 'package.json')) + const licensePath = path.join(packageRoot, 'LICENSE') + if (!existsSync(licensePath)) { + throw new Error(`Required cdxgen license file is missing: ${licensePath}`) + } + await fs.copyFile(licensePath, path.join(payloadRoot, 'LICENSE')) + await fs.copyFile( + path.join(HERE, 'adapters/cdxgen-worker.mjs'), + path.join(payloadRoot, 'sea-worker.mjs'), ) + const workerUtils = path.join(payloadRoot, 'utils') + await fs.mkdir(workerUtils, { recursive: true }) + await fs.copyFile( + path.join(ROOT, 'src/utils/is-object-object.mjs'), + path.join(workerUtils, 'is-object-object.mjs'), + ) + // Keep a concrete VFS path for cdxgen's plugin-directory override. The + // marker does not install or expose any plugin executable. + const pluginsDirectory = path.join(payloadRoot, 'plugins-empty') + await fs.mkdir(pluginsDirectory, { recursive: true }) + await fs.writeFile(path.join(pluginsDirectory, '.socket-empty'), '') + + // ZIP stores file timestamps, so normalize the complete cdxgen closure to + // keep its archive representation stable across otherwise identical builds. + await normalizeTreeTimestamps(payloadRoot) + const digest = await digestTree(payloadRoot) + return { + packageName, + version, + declaredLockIntegrity: integrity, + license: metadata.license, + digest, + sourceInventorySha256: sha256(sourceInventoryBytes), + sourceModuleCount: sourceInventory.modules.length, + noticePackageCount: noticeInventory.packages.length, + licenseTextCount: noticeInventory.packages.reduce( + (count, item) => count + (item.licenseFiles?.length ?? 0), + 0, + ), + noticeTextCount: noticeInventory.packages.reduce( + (count, item) => count + (item.noticeFiles?.length ?? 0), + 0, + ), + obligationCount: noticeInventory.obligations.length, + noticeDocuments, + } +} - // Generate output path. - const outputPath = path.join(outputDir, target.outputName) - await fs.mkdir(outputDir, { recursive: true }) +async function pinnedNode(target: string): Promise<{ + executable: string + licenseFile: string + temporaryDirectory?: string +}> { + const base = `https://nodejs.org/dist/v${NODE_VERSION}` + const platform = target.startsWith('win-') ? 'win' : target.split('-')[0] + const arch = target.split('-')[1] + const ext = platform === 'win' ? 'zip' : 'tar.gz' + const archive = `node-v${NODE_VERSION}-${platform}-${arch}.${ext}` + const sums = await downloadBytes(`${base}/SHASUMS256.txt`, { + maxBytes: MAX_NODE_SUMS_BYTES, + }) + const expected = findNodeArchiveSha256(sums.toString('utf8'), archive) + if (!expected) { + throw new Error( + `Node ${NODE_VERSION} ${target} has no unique valid archive entry in official SHASUMS256.txt`, + ) + } + const archiveUrl = `${base}/${archive}` + const bytes = await cachedDownload( + archiveUrl, + { + algorithm: 'sha256', + digest: expected, + maxBytes: MAX_PINNED_ARCHIVE_BYTES, + }, + () => + downloadBytes(archiveUrl, { + maxBytes: MAX_PINNED_ARCHIVE_BYTES, + }), + ) + if (!expected || sha256(bytes) !== expected) { + throw new Error( + `Node ${NODE_VERSION} ${target} failed official SHASUMS256.txt verification`, + ) + } - // Generate SEA configuration. - const configPath = await generateSeaConfig(entryPoint, outputPath) + // The archive hash is part of the cache identity. Builds never mutate or + // clean a shared extraction tree; each extracts into an owned temp dir and + // atomically promotes the complete result. A cache that exists but fails + // full-tree validation is left untouched; this build uses its own verified + // extraction instead of racing another process to repair shared state. + const cache = path.join( + os.homedir(), + '.cache/socket-sea/node', + NODE_VERSION, + target, + expected, + ) + const executableRelative = platform === 'win' ? 'node.exe' : 'bin/node' + const executable = path.join(cache, executableRelative) + const cacheParent = path.dirname(cache) + await fs.mkdir(cacheParent, { recursive: true }) + const owned = await fs.mkdtemp( + path.join(cacheParent, `.extract-${process.pid}-`), + ) + let preserveOwned = false try { - // Build SEA blob using the downloaded Node binary. - const blobPath = await buildSeaBlob(nodeBinary, configPath) - - // Inject blob into Node binary. - await injectSeaBlob(nodeBinary, blobPath, outputPath) - - // Make executable on Unix. - if (target.platform !== 'win32') { - await fs.chmod(outputPath, 0o755) + const archivePath = path.join(owned, archive) + await fs.writeFile(archivePath, bytes) + const extracted = path.join(owned, 'unpacked') + await fs.mkdir(extracted) + if (platform === 'win') { + const ps = `Expand-Archive -LiteralPath '${archivePath.replaceAll("'", "''")}' -DestinationPath '${extracted.replaceAll("'", "''")}' -Force` + await run('powershell', ['-NoProfile', '-Command', ps]) + const extractedRoot = path.join( + extracted, + `node-v${NODE_VERSION}-win-${arch}`, + ) + await fs.copyFile( + path.join(extractedRoot, 'node.exe'), + path.join(owned, 'node.exe'), + ) + await fs.copyFile( + path.join(extractedRoot, 'LICENSE'), + path.join(owned, 'LICENSE'), + ) + } else { + await run('tar', [ + '-xzf', + archivePath, + '-C', + extracted, + '--strip-components=1', + ]) + await fs.copyFile( + path.join(extracted, 'LICENSE'), + path.join(owned, 'LICENSE'), + ) + for (const directory of ['bin', 'include', 'lib']) { + await fs + .rename(path.join(extracted, directory), path.join(owned, directory)) + .catch(error => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + }) + } } - - console.log(`✓ Built ${target.outputName}`) - - // Clean up temporary files using trash. - const filesToClean = [ - blobPath, - entryPoint.endsWith('.compiled.mjs') ? entryPoint : null, - entryPoint.endsWith('.mjs') && !entryPoint.endsWith('.compiled.mjs') - ? entryPoint - : null, - ].filter(Boolean) as string[] - - if (filesToClean.length > 0) { - await trash(filesToClean).catch(() => {}) + await fs.rm(extracted, { recursive: true, force: true }) + await fs.rm(archivePath, { force: true }) + if (!existsSync(path.join(owned, executableRelative))) { + throw new Error( + `Verified Node archive did not contain expected executable: ${path.join(owned, executableRelative)}`, + ) + } + const nodeLicense = path.join(owned, 'LICENSE') + if (!(await fs.stat(nodeLicense).catch(() => undefined))?.isFile()) { + throw new Error( + 'The verified Node archive did not contain its root LICENSE file', + ) } - } finally { - // Clean up config. - await trash(configPath).catch(() => {}) - } -} -/** - * Parse command-line arguments. - */ -function parseArgs(): BuildOptions { - const args = process.argv.slice(2) - const options: BuildOptions = {} - - for (const arg of args) { - if (arg.startsWith('--platform=')) { - const platform = arg.split('=')[1] - if (platform) { - options.platform = platform as NodeJS.Platform + // Hash the full extracted runtime group against this freshly downloaded + // and officially verified archive before reusing any warm cache entry. + const verifiedTree = await digestTree(owned) + const cachedStat = await fs.lstat(cache).catch(error => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined } - } else if (arg.startsWith('--arch=')) { - const arch = arg.split('=')[1] - if (arch) { - options.arch = arch + throw error + }) + if (cachedStat) { + if ( + cachedStat.isDirectory() && + !cachedStat.isSymbolicLink() && + (await digestTree(cache).catch(() => '')) === verifiedTree + ) { + return { executable, licenseFile: path.join(cache, 'LICENSE') } } - } else if (arg.startsWith('--node-version=')) { - const nodeVersion = arg.split('=')[1] - if (nodeVersion) { - options.nodeVersion = nodeVersion + preserveOwned = true + return { + executable: path.join(owned, executableRelative), + licenseFile: nodeLicense, + temporaryDirectory: owned, } - } else if (arg.startsWith('--output-dir=')) { - const outputDir = arg.split('=')[1] - if (outputDir) { - options.outputDir = outputDir + } + try { + await fs.rename(owned, cache) + } catch (error) { + // A concurrent build may have promoted first. Accept it only if its + // complete runtime tree matches the verified extraction owned here. + const code = (error as NodeJS.ErrnoException).code + if (code !== 'EEXIST' && code !== 'ENOTEMPTY') { + throw error } + const concurrentStat = await fs.lstat(cache).catch(() => undefined) + if ( + concurrentStat?.isDirectory() && + !concurrentStat.isSymbolicLink() && + (await digestTree(cache).catch(() => '')) === verifiedTree + ) { + return { executable, licenseFile: path.join(cache, 'LICENSE') } + } + preserveOwned = true + return { + executable: path.join(owned, executableRelative), + licenseFile: nodeLicense, + temporaryDirectory: owned, + } + } + } finally { + if (!preserveOwned) { + await fs.rm(owned, { recursive: true, force: true }) } } + if (!existsSync(executable)) { + throw new Error( + `Verified Node archive did not produce expected executable: ${executable}`, + ) + } + return { executable, licenseFile: path.join(cache, 'LICENSE') } +} - return options +async function run( + command: string, + args: string[], + env?: NodeJS.ProcessEnv, + cwd = ROOT, +): Promise { + const { spawn } = await import('node:child_process') + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + stdio: 'inherit', + env: { ...process.env, ...env }, + }) + child.once('error', reject) + child.once('close', code => + code === 0 + ? resolve() + : reject(new Error(`${command} exited with ${code}`)), + ) + }) +} + +export function parseArgs( + argv = process.argv.slice(2), + env = process.env, +): { + platform?: string + arch?: string + outputDir: string + prototype: boolean +} { + const result: { + platform?: string + arch?: string + outputDir: string + prototype: boolean + } = { + outputDir: env['SOCKET_CLI_SEA_OUTPUT_DIR'] + ? path.resolve(env['SOCKET_CLI_SEA_OUTPUT_DIR']) + : DEFAULT_OUT, + prototype: false, + } + for (const arg of argv) { + if (arg === '--') { + continue + } + const [key, ...rest] = arg.replace(/^--/, '').split('=') + const value = rest.join('=') + if (key === 'prototype' && value === '') { + result.prototype = true + } else if (key === 'platform') { + result.platform = value + } else if (key === 'arch') { + result.arch = value + } else if (key === 'output-dir') { + result.outputDir = path.resolve(value) + } else if (key === 'node-version') { + if (value !== NODE_VERSION) { + throw new Error(`Node version is pinned to ${NODE_VERSION}`) + } + } else { + throw new Error(`Unknown build option: ${arg}`) + } + } + return result } -/** - * Main build function. - */ async function main(): Promise { const options = parseArgs() - - console.log('Socket CLI Self-Executable Builder') - console.log('====================================') - console.log( - 'Building THIN WRAPPER that downloads @socketsecurity/cli on first use', + if (!options.prototype) { + throw new Error( + 'This is an experimental core-only prototype, not a release builder. Pass --prototype explicitly.', + ) + } + const target = TARGETS.find( + ([platform, arch]) => + (options.platform ?? process.platform) === platform && + (options.arch ?? process.arch) === arch, ) - - // Filter targets based on options. - let targets = BUILD_TARGETS - - if (options.platform) { - targets = targets.filter(t => t.platform === options.platform) - } else { - // If no platform specified, only build for current platform to avoid cross-platform issues. - targets = targets.filter(t => t.platform === process.platform) + if (!target) { + throw new Error( + 'No matching supported target; pass --platform and --arch explicitly for cross builds', + ) } - - if (options.arch) { - targets = targets.filter(t => t.arch === options.arch) - } else if (!options.platform) { - // If no arch specified and building for current platform, use current arch. - targets = targets.filter(t => t.arch === process.arch) + const [, , triple] = target + if (target[0] !== process.platform || target[1] !== process.arch) { + throw new Error( + `SEA prototype builds require a native host (${process.platform}-${process.arch}); requested ${triple}`, + ) } - - if (options.nodeVersion) { - targets = targets.map(t => ({ - ...t, - nodeVersion: options.nodeVersion || t.nodeVersion, - })) + const packageJson = JSON.parse( + await fs.readFile(path.join(ROOT, 'package.json'), 'utf8'), + ) as { + version?: string + dependencies?: Record + devDependencies?: Record } - - if (!targets.length) { - throw new Error('No build targets match the specified criteria') + const socketPatchVersion = + packageJson.dependencies?.['@socketsecurity/socket-patch'] + if ( + !socketPatchVersion || + !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test( + socketPatchVersion, + ) + ) { + throw new Error( + '@socketsecurity/socket-patch must have an exact semver version in package.json to build the SEA payload', + ) } - - // Build each target. - for (const target of targets) { - // eslint-disable-next-line no-await-in-loop - await buildTarget(target, options) + const socketPatchPackage = JSON.parse( + await fs.readFile( + path.join(ROOT, 'node_modules/@socketsecurity/socket-patch/package.json'), + 'utf8', + ), + ) as { + name?: string + version?: string + license?: string + } + if ( + socketPatchPackage.name !== '@socketsecurity/socket-patch' || + socketPatchPackage.version !== socketPatchVersion + ) { + throw new Error( + `Installed @socketsecurity/socket-patch metadata does not match pinned version ${socketPatchVersion}`, + ) + } + const socketPatchLicense = socketPatchPackage.license + if ( + typeof socketPatchLicense !== 'string' || + socketPatchLicense.trim() === '' + ) { + throw new Error( + `Pinned @socketsecurity/socket-patch@${socketPatchVersion} package.json does not declare a license`, + ) } + const out = path.resolve(options.outputDir) + await fs.mkdir(out, { recursive: true }) + const releaseOutputLock = await acquireBuildLock( + path.join(out, '.socket-sea.lock'), + ) + let releaseDotnetLock: (() => Promise) | undefined + try { + releaseDotnetLock = await acquireBuildLock( + path.join(ROOT, '.cache/socket-sea-dotnet.lock'), + ) + } catch (error) { + await releaseOutputLock() + throw error + } + let nodeRuntime: Awaited> | undefined + let node = '' + let stageDirectory: string | undefined + try { + node = (nodeRuntime = await pinnedNode(triple)).executable + const stage = (stageDirectory = await fs.mkdtemp( + path.join(os.tmpdir(), `socket-sea-${process.pid}-`), + )) + const stageDist = path.join(stage, 'dist') + const stageExternal = path.join(stage, 'external') + await fs.mkdir(stageDist, { recursive: true }) + await fs.mkdir(stageExternal, { recursive: true }) + await fs.mkdir(path.join(stage, 'resources'), { recursive: true }) + + const cdxgenVersion = packageJson.devDependencies?.['@cyclonedx/cdxgen'] + if ( + !cdxgenVersion || + !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(cdxgenVersion) + ) { + throw new Error( + '@cyclonedx/cdxgen must have an exact version in devDependencies to build the SEA payload', + ) + } + const synpVersion = packageJson.devDependencies?.['synp'] + const prepTasks = [ + // The 1.14.x distribution includes this first-party helper. Build it + // before Rollup copies manifest-scripts into the private distribution. + // Its runtime still uses the user's .NET SDK; NuGet is build-time only. + run('pnpm', ['run', 'build:dotnet-tool']), + stageSocketPatch(target, stage, socketPatchVersion, socketPatchLicense), + (async () => { + const cdxgenApi = await stageCdxgenApi(target, stage, cdxgenVersion) + if ( + cdxgenApi && + (!synpVersion || + !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(synpVersion)) + ) { + throw new Error( + 'synp must have an exact version in devDependencies to build the Yarn Classic SEA payload', + ) + } + const synpPayload = cdxgenApi + ? await stageSynp(stage, synpVersion!) + : undefined + return { cdxgenApi, synpPayload } + })(), + stageUv(stage, target[0], target[1]), + // Rollup copies this helper into manifest-scripts for standalone builds. + run('pnpm', ['run', 'build:maven-extension']), + ] as const + const prepLabels = [ + '.NET facts helper', + 'socket-patch payload', + 'cdxgen and synp payloads', + 'uv payload', + 'Maven manifest extension', + ] as const + const prepResults = await Promise.allSettled(prepTasks) + const prepErrors = prepResults.flatMap((result, index) => + result.status === 'rejected' + ? [ + new Error(`SEA preparation failed: ${prepLabels[index]}`, { + cause: result.reason, + }), + ] + : [], + ) + if (prepErrors.length > 0) { + throw new AggregateError( + prepErrors, + 'One or more independent SEA payload preparation steps failed', + ) + } + const socketPatch = ( + prepResults[1] as PromiseFulfilledResult< + Awaited> + > + ).value + const { cdxgenApi, synpPayload } = ( + prepResults[2] as PromiseFulfilledResult<{ + cdxgenApi: Awaited> + synpPayload: Awaited> | undefined + }> + ).value + const uvPayload = ( + prepResults[3] as PromiseFulfilledResult< + Awaited> + > + ).value + const socketPatchHashFile = socketPatch + ? path.join(stage, 'resources', 'socket-patch.sha256') + : undefined + if (socketPatch && socketPatchHashFile) { + await fs.writeFile(socketPatchHashFile, `${socketPatch.digest}\n`) + } + + // Rollup depends on the completed helper and copies it into stageDist. + // Keep it ordered after preparation, while the independent downloads and + // helper build above are allowed to overlap. + await run( + 'pnpm', + [ + 'exec', + 'dotenvx', + '-q', + 'run', + '-f', + '.env.local', + '--', + 'rollup', + '-c', + '.config/rollup.dist.config.mjs', + ], + { + SOCKET_SEA_BUILD: 'true', + SOCKET_SEA_DIST_DIR: stageDist, + SOCKET_SEA_EXTERNAL_DIR: stageExternal, + INLINED_SOCKET_CLI_VERSION: SEA_VERSION, + INLINED_SOCKET_CLI_LEGACY_BUILD: 'false', + INLINED_SOCKET_CLI_SENTRY_BUILD: 'false', + INLINED_SOCKET_CLI_PUBLISHED_BUILD: 'true', + }, + ) + const synpLock = synpPayload + ? (parseYaml( + await fs.readFile(path.join(ROOT, 'pnpm-lock.yaml'), 'utf8'), + ) as { + packages?: Record< + string, + { resolution?: { integrity?: unknown } } | undefined + > + }) + : undefined + const synpLocator = synpPayload ? `synp@${synpPayload.version}` : undefined + const synpLockIntegrity = synpLocator + ? synpLock?.packages?.[synpLocator]?.resolution?.integrity + : undefined + if ( + synpPayload && + (typeof synpLockIntegrity !== 'string' || + !synpLockIntegrity.startsWith('sha512-')) + ) { + throw new Error( + `pnpm-lock.yaml has no integrity for synp@${synpPayload.version}`, + ) + } + const archive = path.join(stage, 'socket-vfs.zip') + const bootstrap = path.join(stage, 'bootstrap.cjs') + const seaConfig = path.join(stage, 'sea-config.json') + const rootFiles = [ + 'package.json', + 'requirements.json', + 'translations.json', + 'LICENSE', + 'node-runtime-LICENSE', + ] + for (const filename of rootFiles) { + const source = + filename === 'node-runtime-LICENSE' + ? nodeRuntime.licenseFile + : path.join(ROOT, filename) + if (!existsSync(source)) { + throw new Error(`Required SEA VFS runtime file is missing: ${source}`) + } + if (filename === 'package.json') { + const packageJson = JSON.parse(await fs.readFile(source, 'utf8')) as { + version?: string + } + packageJson.version = SEA_VERSION + await fs.writeFile( + path.join(stage, filename), + `${JSON.stringify(packageJson, null, 2)}\n`, + ) + } else { + await fs.copyFile(source, path.join(stage, filename)) + } + } + const requiredPayloads = ['dist/cli.js', 'external'] + for (const filename of requiredPayloads) { + const fullPath = path.join(stage, filename) + const stat = await fs.stat(fullPath).catch(() => undefined) + if ( + !stat || + (filename === 'dist/cli.js' + ? !stat.isFile() || stat.size === 0 + : !stat.isDirectory()) + ) { + throw new Error( + `Required SEA payload is missing or malformed: ${filename}`, + ) + } + } + const dotnetToolDirectory = path.join( + stageDist, + 'manifest-scripts/dotnet-tool', + ) + const dotnetToolEntry = path.join( + dotnetToolDirectory, + 'socket-facts-dotnet.dll', + ) + const dotnetToolStat = await fs.stat(dotnetToolEntry).catch(() => undefined) + if (!dotnetToolStat?.isFile() || dotnetToolStat.size === 0) { + throw new Error( + 'The .NET facts helper was not copied into the SEA distribution', + ) + } + const capabilities = createSeaCapabilities({ + triple, + hasCdxgen: Boolean(cdxgenApi), + hasSynp: Boolean(synpPayload), + hasSocketPatch: Boolean(socketPatch), + hasUv: Boolean(uvPayload), + socketPatchVersion, + }) + await fs.writeFile( + path.join(stage, 'resources/capabilities.json'), + `${JSON.stringify(capabilities, null, 2)}\n`, + ) + // VFS archives and named SEA assets are mutually exclusive in Node 27. + // Native tools belong in the archive too, then are read from the VFS and + // materialized only at invocation time. + const archivePaths = seaArchivePaths({ + hasCdxgen: Boolean(cdxgenApi), + hasSynp: Boolean(synpPayload), + hasUv: Boolean(uvPayload), + rootFiles, + }) + if (process.platform === 'win32') { + const quotePowerShell = (value: string) => + `'${value.replaceAll("'", "''")}'` + const sourceList = archivePaths + .map(filename => quotePowerShell(path.join(stage, filename))) + .join(', ') + await run( + 'powershell', + [ + '-NoProfile', + '-Command', + `Compress-Archive -LiteralPath @(${sourceList}) -DestinationPath ${quotePowerShell(archive)} -Force`, + ], + undefined, + stage, + ) + } else { + await run('zip', ['-q', '-r', archive, ...archivePaths], undefined, stage) + } + const archiveStat = await fs.stat(archive).catch(() => undefined) + if (!archiveStat?.isFile() || archiveStat.size === 0) { + throw new Error('SEA VFS archive was not created or is empty') + } + await run( + 'pnpm', + ['exec', 'rollup', '-c', '.config/rollup.sea.config.mjs'], + { + SEA_BOOTSTRAP: path.join(HERE, 'bootstrap.mts'), + SEA_OUTPUT: bootstrap, + }, + ) + + const config = { + main: bootstrap, + output: path.join( + stage, + triple.startsWith('win-') ? `socket-${triple}.exe` : `socket-${triple}`, + ), + disableExperimentalSEAWarning: true, + useVfs: true, + vfsArchive: archive, + execArgv: ['--no-global-search-paths'], + execArgvExtension: 'none', + } + await fs.writeFile(seaConfig, JSON.stringify(config, null, 2)) + await run(node, ['--build-sea', seaConfig]) + const output = config.output + if (!triple.startsWith('win-')) { + await fs.chmod(output, 0o755) + } + if (triple.startsWith('darwin-')) { + await run('codesign', ['--sign', '-', output]) + } - console.log('\n✅ Build complete!') - console.log(`Output directory: ${options.outputDir || 'dist/sea'}`) - console.log('\nNOTE: These binaries are thin wrappers that will download') - console.log('@socketsecurity/cli from npm on first run.') + const appVersion = SEA_VERSION + const payloads: Array> = [ + { + id: 'node-runtime', + version: NODE_VERSION, + file: path.basename(node), + sha256: await digestFile(node), + license: 'MIT', + notices: [ + { + type: 'license', + value: 'MIT', + path: 'node-runtime-LICENSE', + sha256: await digestFile(path.join(stage, 'node-runtime-LICENSE')), + }, + ], + }, + { + id: 'socket-vfs', + version: appVersion, + file: path.basename(output), + sha256: await digestFile(output), + }, + { + id: 'socket-vfs-archive', + version: appVersion, + file: 'embedded-vfs-archive', + sha256: await digestFile(archive), + }, + { + id: 'socket-dist', + version: appVersion, + file: 'dist/', + sha256: await digestTree(path.join(stage, 'dist')), + }, + { + id: 'socket-dotnet-facts-helper', + version: appVersion, + file: 'dist/manifest-scripts/dotnet-tool/', + sha256: await digestTree( + path.join(stage, 'dist/manifest-scripts/dotnet-tool'), + ), + }, + { + id: 'external-dependencies', + version: appVersion, + file: 'external/', + sha256: await digestTree(path.join(stage, 'external')), + }, + ...(await Promise.all( + rootFiles.map(async filename => ({ + id: `runtime-file:${filename}`, + version: appVersion, + file: filename, + sha256: await digestFile(path.join(stage, filename)), + })), + )), + ] + if (socketPatch) { + payloads.push({ + id: 'socket-patch', + version: socketPatchVersion, + package: socketPatch.packageName, + integrity: socketPatch.integrity, + license: socketPatch.license, + notices: [ + { + type: 'license', + value: socketPatch.license, + path: 'resources/socket-patch-LICENSE.txt', + }, + ], + file: `SEA asset: socket-patch`, + source: `resources/${triple.startsWith('win-') ? 'socket-patch.exe' : 'socket-patch'}`, + digestFile: 'resources/socket-patch.sha256', + sha256: socketPatch.digest, + }) + } + if (cdxgenApi) { + payloads.push({ + id: 'cdxgen-api', + version: cdxgenApi.version, + package: cdxgenApi.packageName, + declaredLockIntegrity: cdxgenApi.declaredLockIntegrity, + sourceVerification: 'unverified', + sourceVerificationReason: + 'The frozen lockfile SRI is recorded as declared metadata. Installed Rollup module hashes are provided separately and are not authenticated against the locked tarballs or generated bundle bytes.', + sourceInventory: { + file: 'tools/cdxgen/ROLLUP-SOURCE-INVENTORY.json', + sha256: cdxgenApi.sourceInventorySha256, + moduleCount: cdxgenApi.sourceModuleCount, + }, + license: cdxgenApi.license, + notices: [ + { + type: 'license', + value: cdxgenApi.license, + path: 'tools/cdxgen/LICENSE', + }, + { + type: 'third-party-notice-inventory', + path: 'tools/cdxgen/THIRD-PARTY-NOTICES.json', + }, + ], + thirdPartyNotices: { + inventory: 'tools/cdxgen/THIRD-PARTY-NOTICES.json', + documents: cdxgenApi.noticeDocuments, + packageCount: cdxgenApi.noticePackageCount, + licenseTextCount: cdxgenApi.licenseTextCount, + noticeTextCount: cdxgenApi.noticeTextCount, + flaggedObligationCount: cdxgenApi.obligationCount, + }, + file: 'tools/cdxgen/', + entrypoint: 'tools/cdxgen/sea-worker.mjs', + sha256: cdxgenApi.digest, + }) + } + if (synpPayload && typeof synpLockIntegrity === 'string') { + payloads.push({ + id: 'synp-api', + version: synpPayload.version, + package: synpPayload.packageName, + declaredLockIntegrity: synpLockIntegrity, + sourceVerification: 'unverified', + sourceVerificationReason: + 'The frozen lockfile SRI and staged payload digest are recorded. No per-module source inventory is emitted, and neither value authenticates installed or transformed source bytes against the locked tarball.', + license: synpPayload.license, + notices: [ + { + type: 'license', + value: synpPayload.license, + path: 'tools/synp/LICENSE', + }, + { + type: 'third-party-notice-inventory', + path: 'tools/synp/THIRD-PARTY-NOTICES.json', + }, + ], + thirdPartyNotices: { + inventory: 'tools/synp/THIRD-PARTY-NOTICES.json', + inventorySha256: synpPayload.noticeInventorySha256, + documents: synpPayload.noticeDocuments, + packageCount: synpPayload.noticePackageCount, + flaggedObligationCount: synpPayload.obligationCount, + }, + file: 'tools/synp/', + entrypoint: 'tools/synp/bin/synp.js', + sha256: synpPayload.integrity, + }) + } + if (uvPayload) { + payloads.push({ + id: 'uv', + package: uvPayload.packageName, + version: uvPayload.version, + target: uvPayload.target, + source: uvPayload.sourceUrl, + archiveSha256: uvPayload.declaredArchiveSha256, + binary: uvPayload.binaryPath, + sha256: uvPayload.binarySha256, + size: uvPayload.binarySize, + license: uvPayload.license, + notices: uvPayload.noticeDocuments, + noticeInventory: { + path: uvPayload.noticeInventoryPath, + sha256: uvPayload.noticeInventorySha256, + }, + noticeObligations: uvPayload.noticeObligations, + }) + } + const manifest = { + schemaVersion: 1, + target: { os: target[0], arch: target[1], triple }, + application: { name: 'socket', version: appVersion }, + payloads, + thirdPartyNotices: { + rootDistribution: await listThirdPartyArtifacts( + path.join(stage, 'dist'), + 'dist', + ), + externalDependencies: await listThirdPartyArtifacts( + path.join(stage, 'external'), + 'external', + ), + }, + runtimeRequirements: [ + { + capability: 'manifest-dotnet-facts', + executable: 'dotnet', + resolution: 'socket manifest dotnet --bin or PATH', + requirement: + 'A .NET SDK compatible with the selected project; the Socket facts helper DLL is embedded, but the SDK/runtime is user-provided.', + }, + ], + capabilities, + } + const manifestStage = path.join(stage, 'artifact.manifest.json') + await fs.writeFile(manifestStage, `${JSON.stringify(manifest, null, 2)}\n`) + await publishArtifactPair({ + stagedBinary: output, + stagedManifest: manifestStage, + outputBinary: path.join(out, path.basename(output)), + outputManifest: path.join(out, `socket-${triple}.manifest.json`), + transactionDirectory: path.join( + out, + `.socket-sea-publish-${randomUUID()}`, + ), + }) + console.log( + `Built ${path.join(out, path.basename(output))} with Node v${NODE_VERSION} (${triple})`, + ) + } finally { + try { + if (stageDirectory) { + await fs.rm(stageDirectory, { recursive: true, force: true }) + } + if (nodeRuntime?.temporaryDirectory) { + await fs.rm(nodeRuntime.temporaryDirectory, { + recursive: true, + force: true, + }) + } + } finally { + try { + await releaseDotnetLock() + } finally { + await releaseOutputLock() + } + } + } } -// Run if executed directly. -if (import.meta.url === `file://${process.argv[1]}`) { +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { main().catch(error => { - console.error('Build failed:', error) - // eslint-disable-next-line n/no-process-exit - process.exit(1) + console.error(error) + process.exitCode = 1 }) } -export { buildTarget, downloadNodeBinary, main } +export { main, pinnedNode } diff --git a/src/sea/build/lock.mts b/src/sea/build/lock.mts new file mode 100644 index 0000000000..698a4fb3a1 --- /dev/null +++ b/src/sea/build/lock.mts @@ -0,0 +1,25 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' + +/** Acquire an exclusive directory lock. Existing locks are never removed as stale. */ +export async function acquireBuildLock( + lockPath: string, +): Promise<() => Promise> { + await fs.mkdir(path.dirname(lockPath), { recursive: true }) + try { + await fs.mkdir(lockPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new Error(`SEA build lock is already held: ${lockPath}`) + } + throw error + } + let released = false + return async () => { + if (released) { + return + } + released = true + await fs.rmdir(lockPath) + } +} diff --git a/src/sea/build/publish.mts b/src/sea/build/publish.mts new file mode 100644 index 0000000000..283b0f7383 --- /dev/null +++ b/src/sea/build/publish.mts @@ -0,0 +1,142 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' + +/** Replace an executable and its manifest as one rollback-safe publication. */ +export async function publishArtifactPair({ + operations = {}, + outputBinary, + outputManifest, + stagedBinary, + stagedManifest, + transactionDirectory, +}: { + stagedBinary: string + stagedManifest: string + outputBinary: string + outputManifest: string + transactionDirectory: string + operations?: { + rename?: typeof fs.rename + rm?: typeof fs.rm + } +}): Promise { + const rename = operations.rename ?? fs.rename + const remove = operations.rm ?? fs.rm + const nextBinary = path.join( + transactionDirectory, + path.basename(outputBinary), + ) + const nextManifest = path.join( + transactionDirectory, + path.basename(outputManifest), + ) + const backupBinary = path.join( + transactionDirectory, + `${path.basename(outputBinary)}.previous`, + ) + const backupManifest = path.join( + transactionDirectory, + `${path.basename(outputManifest)}.previous`, + ) + let backedUpBinary = false + let backedUpManifest = false + let installedBinary = false + let installedManifest = false + let committed = false + let retainedBackups = false + await fs.mkdir(transactionDirectory) + try { + await fs.copyFile(stagedBinary, nextBinary) + await fs.copyFile(stagedManifest, nextManifest) + await fs.chmod(nextBinary, (await fs.stat(stagedBinary)).mode & 0o7777) + const exists = async (filename: string): Promise => { + try { + await fs.lstat(filename) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } + } + const hadBinary = await exists(outputBinary) + const hadManifest = await exists(outputManifest) + if (hadBinary) { + await rename(outputBinary, backupBinary) + backedUpBinary = true + } + if (hadManifest) { + await rename(outputManifest, backupManifest) + backedUpManifest = true + } + await rename(nextBinary, outputBinary) + installedBinary = true + await rename(nextManifest, outputManifest) + installedManifest = true + committed = true + } catch (error) { + const rollbackErrors: unknown[] = [] + if (installedBinary) { + try { + await remove(outputBinary, { force: true }) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + if (installedManifest) { + try { + await remove(outputManifest, { force: true }) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + if (backedUpBinary) { + try { + await rename(backupBinary, outputBinary) + backedUpBinary = false + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + if (backedUpManifest) { + try { + await rename(backupManifest, outputManifest) + backedUpManifest = false + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + if (rollbackErrors.length) { + retainedBackups = backedUpBinary || backedUpManifest + throw new AggregateError( + [error, ...rollbackErrors], + `SEA artifact publication failed and rollback was incomplete; recoverable backups remain in ${transactionDirectory}`, + ) + } + throw error + } finally { + await remove(nextBinary, { force: true }).catch(() => undefined) + await remove(nextManifest, { force: true }).catch(() => undefined) + if (committed) { + let cleanupFailed = false + try { + await remove(backupBinary, { force: true }) + } catch { + cleanupFailed = true + } + try { + await remove(backupManifest, { force: true }) + } catch { + cleanupFailed = true + } + retainedBackups = cleanupFailed + } + if (!retainedBackups) { + await remove(transactionDirectory, { + recursive: true, + force: true, + }).catch(() => undefined) + } + } +} diff --git a/src/sea/capabilities.mts b/src/sea/capabilities.mts new file mode 100644 index 0000000000..8c3d276461 --- /dev/null +++ b/src/sea/capabilities.mts @@ -0,0 +1,95 @@ +export type SeaCapability = { id: string } | { id: string; reason: string } + +export type SeaCapabilities = { + available: string[] + unavailable: Array<{ id: string; reason: string }> +} + +export function createSeaCapabilities({ + hasCdxgen, + hasSocketPatch, + hasSynp, + hasUv, + socketPatchVersion, + triple, +}: { + triple: string + hasCdxgen: boolean + hasSynp: boolean + hasSocketPatch: boolean + hasUv: boolean + socketPatchVersion?: string +}): SeaCapabilities { + return { + available: [ + 'socket-core', + 'manifest-dotnet-facts', + ...(hasSocketPatch ? ['socket-patch'] : []), + ...(hasUv ? ['uv'] : []), + ], + unavailable: [ + ...(hasCdxgen + ? [ + { + id: 'manifest-cdxgen-npm-lock-v3', + reason: + 'The restricted npm lock v3 path passed local command smoke, but installed Rollup module bytes are not authenticated against the frozen package tarballs or generated bundle. See the explicitly unverified source inventory.', + }, + { + id: 'cdxgen-other-modes', + reason: + 'Only one npm package-lock.json v3 project is implemented; other project types and modes are not included.', + }, + { + id: 'manifest-cdxgen-yarn-classic', + reason: + 'Synp v1.9.14 API conversion and cdxgen package-lock v1 API smoke passed, but Synp Rollup provenance remains unauthenticated and its third-party notice inventory has missing-license-text obligations.', + }, + ] + : [ + { + id: 'cdxgen', + reason: `No cdxgen API payload is embedded for ${triple}; only darwin-arm64 has a qualified VFS bundle.`, + }, + ]), + { + id: 'synp', + reason: hasSynp + ? 'The Synp API is bundled for Yarn Classic, but source provenance remains unverified and its third-party notice inventory has missing-license-text obligations.' + : 'No Synp API payload is embedded because no compatible cdxgen VFS API payload is available for this target.', + }, + { + id: 'coana', + reason: + 'No Coana executable or verified payload is embedded in this core SEA build.', + }, + ...(!hasSocketPatch + ? [ + { + id: 'socket-patch', + reason: `No compatible locked socket-patch package was validated for ${triple}${socketPatchVersion ? ` at ${socketPatchVersion}` : ''}.`, + }, + ] + : []), + { + id: 'python', + reason: + 'No relocatable Python runtime and standard library are embedded in this core SEA build.', + }, + { + id: 'jvm-toolchain', + reason: + 'No JVM runtime or toolchain payload is embedded in this core SEA build.', + }, + ...(!hasSocketPatch + ? [ + { + id: 'native-assets', + reason: + 'No separately verified native tool assets are embedded in this core SEA build.', + }, + ] + : []), + ], + } +} diff --git a/src/sea/download-bytes.mts b/src/sea/download-bytes.mts new file mode 100644 index 0000000000..35aac4ca64 --- /dev/null +++ b/src/sea/download-bytes.mts @@ -0,0 +1,162 @@ +/* eslint-disable no-await-in-loop -- Retry attempts must run sequentially. */ +const MAX_ATTEMPTS = 3 +const RETRY_DELAYS = [500, 1_000] as const +const RETRYABLE_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504]) +const RETRYABLE_ERROR_CODES = new Set([ + 'UND_ERR_RES_CONTENT_LENGTH_MISMATCH', + 'UND_ERR_SOCKET', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_BODY_TIMEOUT', + 'ECONNRESET', + 'ETIMEDOUT', + 'EAI_AGAIN', +]) + +type DownloadBytesOptions = { + fetchOptions?: + | Omit[1]>, 'signal'> + | undefined + maxBytes?: number | undefined + timeoutMs?: number | undefined + acceptedStatuses?: readonly number[] | undefined +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function isRetryable(error: unknown): boolean { + const seen = new Set() + let current: unknown = error + while (current && typeof current === 'object' && !seen.has(current)) { + seen.add(current) + const candidate = current as { + cause?: unknown + code?: unknown + name?: unknown + status?: unknown + } + if ( + (typeof candidate.code === 'string' && + RETRYABLE_ERROR_CODES.has(candidate.code)) || + (typeof candidate.status === 'number' && + RETRYABLE_HTTP_STATUSES.has(candidate.status)) || + candidate.name === 'TimeoutError' + ) { + return true + } + current = candidate.cause + } + return false +} + +export async function cancelBody(response: Response): Promise { + try { + await response.body?.cancel() + } catch { + // A failed or already-consumed stream can reject cancellation. + } +} + +export function failedDownload( + url: string, + attempts: number, + cause: unknown, +): Error { + return new Error( + `Download failed for ${url} after ${attempts} attempt${attempts === 1 ? '' : 's'}: ${errorMessage(cause)}`, + { cause }, + ) +} + +export async function waitBeforeRetry(attempt: number): Promise { + await new Promise(resolve => setTimeout(resolve, RETRY_DELAYS[attempt - 1]!)) +} + +/** Fetch bytes with bounded retries for transient transport failures only. */ +export async function downloadBytes( + url: string, + { + acceptedStatuses, + fetchOptions, + maxBytes, + timeoutMs = 120_000, + }: DownloadBytesOptions = {}, +): Promise { + let lastError: unknown + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + let response: Response | undefined + try { + response = await fetch(url, { + ...fetchOptions, + signal: AbortSignal.timeout(timeoutMs), + }) + const statusAccepted = acceptedStatuses + ? acceptedStatuses.includes(response.status) + : response.ok + if (!statusAccepted) { + const statusError = Object.assign( + new Error(`HTTP ${response.status}`), + { status: response.status }, + ) + await cancelBody(response) + throw statusError + } + const declaredLength = Number(response.headers.get('content-length')) + if ( + maxBytes !== undefined && + Number.isFinite(declaredLength) && + declaredLength > maxBytes + ) { + await cancelBody(response) + throw new Error(`Response exceeds the ${maxBytes}-byte limit`) + } + if (!response.body) { + throw new Error('Response has no body') + } + + const reader = response.body.getReader() + const chunks: Buffer[] = [] + let total = 0 + try { + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + total += value.byteLength + if (maxBytes !== undefined && total > maxBytes) { + try { + await reader.cancel() + } catch { + // The stream may already be closing. + } + throw new Error(`Response exceeds the ${maxBytes}-byte limit`) + } + chunks.push(Buffer.from(value)) + } + } catch (error) { + try { + await reader.cancel(error) + } catch { + // The stream can already be errored, including on truncated bodies. + } + throw error + } finally { + reader.releaseLock() + } + return Buffer.concat(chunks, total) + } catch (error) { + if (response) { + await cancelBody(response) + } + lastError = error + if (!isRetryable(error) || attempt === MAX_ATTEMPTS) { + throw failedDownload(url, attempt, error) + } + } + await waitBeforeRetry(attempt) + } + throw failedDownload(url, MAX_ATTEMPTS, lastError) +} diff --git a/src/sea/download-cache.mts b/src/sea/download-cache.mts new file mode 100644 index 0000000000..924238d237 --- /dev/null +++ b/src/sea/download-cache.mts @@ -0,0 +1,426 @@ +import { createHash, randomUUID } from 'node:crypto' +import { promises as fs, constants as fsConstants } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +export type DownloadDigestAlgorithm = 'sha256' | 'sha512' + +export interface CachedDownloadOptions { + readonly algorithm: DownloadDigestAlgorithm + readonly digest: string + readonly maxBytes?: number + readonly cacheRoot?: string + readonly lockTimeoutMs?: number + readonly lockPollMs?: number +} + +export class DownloadIntegrityError extends Error { + constructor(message: string) { + super(message) + this.name = 'DownloadIntegrityError' + } +} + +class CacheUnavailableError extends Error {} + +const DEFAULT_MAX_BYTES = 512 * 1024 * 1024 +const DEFAULT_LOCK_TIMEOUT_MS = 30_000 +const DEFAULT_LOCK_POLL_MS = 50 + +export function validateDownloadDigest( + algorithm: DownloadDigestAlgorithm, + value: string, +): string { + if (algorithm !== 'sha256' && algorithm !== 'sha512') { + throw new TypeError('Unsupported download digest algorithm') + } + const digest = value.toLowerCase() + const digestLength = algorithm === 'sha256' ? 64 : 128 + if (!new RegExp(`^[a-f0-9]{${digestLength}}$`).test(digest)) { + throw new TypeError(`Invalid ${algorithm} download digest`) + } + return digest +} + +export function hashDownloadBytes( + bytes: Uint8Array, + algorithm: DownloadDigestAlgorithm, +): string { + return createHash(algorithm).update(bytes).digest('hex') +} + +function validateOptions(options: CachedDownloadOptions): { + algorithm: DownloadDigestAlgorithm + digest: string + maxBytes: number + cacheRoot: string + lockTimeoutMs: number + lockPollMs: number +} { + const { algorithm } = options + const digest = validateDownloadDigest(algorithm, options.digest) + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { + throw new TypeError('maxBytes must be a non-negative safe integer') + } + const lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS + const lockPollMs = options.lockPollMs ?? DEFAULT_LOCK_POLL_MS + if (!Number.isFinite(lockTimeoutMs) || lockTimeoutMs < 0) { + throw new TypeError('lockTimeoutMs must be a non-negative number') + } + if (!Number.isFinite(lockPollMs) || lockPollMs <= 0) { + throw new TypeError('lockPollMs must be a positive number') + } + return { + algorithm, + digest, + maxBytes, + cacheRoot: path.resolve( + options.cacheRoot ?? + path.join(os.homedir(), '.cache/socket-sea/downloads'), + ), + lockTimeoutMs, + lockPollMs, + } +} + +function matchesDigest( + bytes: Buffer, + algorithm: DownloadDigestAlgorithm, + expected: string, + maxBytes: number, +): boolean { + return ( + bytes.byteLength <= maxBytes && + hashDownloadBytes(bytes, algorithm) === expected + ) +} + +function integrityCheck( + bytes: Buffer, + algorithm: DownloadDigestAlgorithm, + expected: string, + maxBytes: number, + url: string, +): Buffer { + const label = safeUrlLabel(url) + if (bytes.byteLength > maxBytes) { + throw new DownloadIntegrityError( + `Downloaded bytes from ${label} exceed the ${maxBytes}-byte limit`, + ) + } + const actual = hashDownloadBytes(bytes, algorithm) + if (actual !== expected) { + throw new DownloadIntegrityError( + `Download ${algorithm} digest mismatch for ${label}: expected ${expected}, got ${actual}`, + ) + } + return bytes +} + +function safeUrlLabel(value: string): string { + try { + const url = new URL(value) + return `${url.origin}${url.pathname}` + } catch { + return '' + } +} + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException)?.code === 'ENOENT' +} + +async function ensureDirectory(directory: string): Promise { + let stat + try { + stat = await fs.lstat(directory) + } catch (error) { + if (!isMissing(error)) { + throw error + } + try { + await fs.mkdir(directory) + } catch (mkdirError) { + if ((mkdirError as NodeJS.ErrnoException)?.code !== 'EEXIST') { + throw mkdirError + } + } + stat = await fs.lstat(directory) + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new CacheUnavailableError(`Unsafe cache directory: ${directory}`) + } +} + +async function ensureCacheDirectories(root: string, algorithm: string) { + try { + await fs.mkdir(root, { recursive: true }) + const rootStat = await fs.lstat(root) + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { + throw new CacheUnavailableError(`Unsafe cache root: ${root}`) + } + const version = path.join(root, 'v1') + await ensureDirectory(version) + const blobRoot = path.join(version, 'blobs') + await ensureDirectory(blobRoot) + const algorithmRoot = path.join(blobRoot, algorithm) + await ensureDirectory(algorithmRoot) + const locks = path.join(version, 'locks') + await ensureDirectory(locks) + const tempRoot = path.join(version, 'tmp') + await ensureDirectory(tempRoot) + return { blobRoot: algorithmRoot, lockRoot: locks, tempRoot } + } catch (error) { + if (error instanceof CacheUnavailableError) { + throw error + } + throw new CacheUnavailableError('Download cache is unavailable', { + cause: error, + }) + } +} + +async function readVerifiedBlob( + blobPath: string, + algorithm: DownloadDigestAlgorithm, + digest: string, + maxBytes: number, +): Promise { + let stat + try { + stat = await fs.lstat(blobPath) + } catch (error) { + if (isMissing(error)) { + return undefined + } + throw new CacheUnavailableError('Could not inspect cached download', { + cause: error, + }) + } + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new CacheUnavailableError('Cached download is not a regular file') + } + if (stat.size > maxBytes) { + return undefined + } + try { + const bytes = await fs.readFile(blobPath) + return matchesDigest(bytes, algorithm, digest, maxBytes) ? bytes : undefined + } catch (error) { + if (isMissing(error)) { + return undefined + } + throw new CacheUnavailableError('Could not read cached download', { + cause: error, + }) + } +} + +/* eslint-disable no-await-in-loop -- lock acquisition must serialize mkdir, inspect, and poll */ +async function acquireLock( + lockPath: string, + timeoutMs: number, + pollMs: number, +): Promise { + const deadline = Date.now() + timeoutMs + while (true) { + try { + await fs.mkdir(lockPath, { mode: 0o700 }) + const owner = randomUUID() + try { + await fs.writeFile(path.join(lockPath, '.owner'), owner, { + flag: 'wx', + mode: 0o600, + }) + } catch (error) { + await fs.unlink(path.join(lockPath, '.owner')).catch(() => undefined) + await fs.rmdir(lockPath).catch(() => undefined) + throw error + } + return owner + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== 'EEXIST') { + throw new CacheUnavailableError('Could not acquire download lock', { + cause: error, + }) + } + } + try { + const stat = await fs.lstat(lockPath) + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new CacheUnavailableError('Unsafe download lock entry') + } + } catch (error) { + if (isMissing(error)) { + continue + } + if (error instanceof CacheUnavailableError) { + throw error + } + throw new CacheUnavailableError('Could not inspect download lock', { + cause: error, + }) + } + if (Date.now() >= deadline) { + return undefined + } + await new Promise(resolve => setTimeout(resolve, pollMs)) + } +} +/* eslint-enable no-await-in-loop */ + +async function removeInvalidRegularBlob(blobPath: string): Promise { + try { + const stat = await fs.lstat(blobPath) + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new CacheUnavailableError('Unsafe cached download entry') + } + await fs.unlink(blobPath) + } catch (error) { + if (isMissing(error)) { + return + } + if (error instanceof CacheUnavailableError) { + throw error + } + throw new CacheUnavailableError('Could not repair cached download', { + cause: error, + }) + } +} + +async function publishBlob( + blobPath: string, + tempRoot: string, + bytes: Buffer, +): Promise { + const tempPath = path.join(tempRoot, `.download-${randomUUID()}.tmp`) + let handle + try { + handle = await fs.open( + tempPath, + fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, + 0o600, + ) + await handle.writeFile(bytes) + await handle.sync() + await handle.close() + handle = undefined + await fs.rename(tempPath, blobPath) + } catch (error) { + throw new CacheUnavailableError('Could not publish cached download', { + cause: error, + }) + } finally { + await handle?.close().catch(() => undefined) + await fs.unlink(tempPath).catch(() => undefined) + } +} + +async function fetchAndVerify( + url: string, + fetchBytes: () => Promise, + algorithm: DownloadDigestAlgorithm, + digest: string, + maxBytes: number, +): Promise { + return integrityCheck(await fetchBytes(), algorithm, digest, maxBytes, url) +} + +export async function cachedDownload( + _url: string, + options: CachedDownloadOptions, + fetchBytes: () => Promise, +): Promise { + const config = validateOptions(options) + const fetchUncached = () => + fetchAndVerify( + _url, + fetchBytes, + config.algorithm, + config.digest, + config.maxBytes, + ) + let paths: Awaited> + try { + paths = await ensureCacheDirectories(config.cacheRoot, config.algorithm) + } catch { + return await fetchUncached() + } + const blobPath = path.join(paths.blobRoot, config.digest) + const lockPath = path.join( + paths.lockRoot, + `${config.algorithm}-${config.digest}.lock`, + ) + try { + const cached = await readVerifiedBlob( + blobPath, + config.algorithm, + config.digest, + config.maxBytes, + ) + if (cached) { + return cached + } + } catch { + return await fetchUncached() + } + + let owner: string | undefined + try { + owner = await acquireLock(lockPath, config.lockTimeoutMs, config.lockPollMs) + } catch { + return await fetchUncached() + } + if (!owner) { + return await fetchUncached() + } + + try { + const cached = await readVerifiedBlob( + blobPath, + config.algorithm, + config.digest, + config.maxBytes, + ) + if (cached) { + return cached + } + await removeInvalidRegularBlob(blobPath) + const bytes = await fetchUncached() + try { + await publishBlob(blobPath, paths.tempRoot, bytes) + } catch { + // A verified response remains usable when the local cache cannot publish. + } + return bytes + } catch (error) { + if (error instanceof DownloadIntegrityError) { + throw error + } + if (error instanceof CacheUnavailableError) { + return await fetchUncached() + } + throw error + } finally { + await releaseLock(lockPath, owner) + } +} + +async function releaseLock(lockPath: string, owner: string): Promise { + const ownerPath = path.join(lockPath, '.owner') + try { + const stat = await fs.lstat(lockPath) + if (stat.isSymbolicLink() || !stat.isDirectory()) { + return + } + if ((await fs.readFile(ownerPath, 'utf8')) !== owner) { + return + } + await fs.unlink(ownerPath) + await fs.rmdir(lockPath) + } catch { + // The cache lock is best effort; never remove a lock that is no longer ours. + } +} diff --git a/src/sea/licenses/cdxgen-third-party/boolbase-1.0.0-ISC.txt b/src/sea/licenses/cdxgen-third-party/boolbase-1.0.0-ISC.txt new file mode 100644 index 0000000000..a689aae96e --- /dev/null +++ b/src/sea/licenses/cdxgen-third-party/boolbase-1.0.0-ISC.txt @@ -0,0 +1,13 @@ +Copyright (c) 2014-2015, Felix Boehm + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src/sea/licenses/cdxgen-third-party/err-code-2.0.3-MIT.txt b/src/sea/licenses/cdxgen-third-party/err-code-2.0.3-MIT.txt new file mode 100644 index 0000000000..f5b714c6bc --- /dev/null +++ b/src/sea/licenses/cdxgen-third-party/err-code-2.0.3-MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) IndigoUnited + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/sea/licenses/cdxgen-third-party/imurmurhash-0.1.4-MIT.txt b/src/sea/licenses/cdxgen-third-party/imurmurhash-0.1.4-MIT.txt new file mode 100644 index 0000000000..3976989301 --- /dev/null +++ b/src/sea/licenses/cdxgen-third-party/imurmurhash-0.1.4-MIT.txt @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2013 Gary Court, Jens Taylor + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/sea/licenses/cdxgen-third-party/spdx-exceptions-2.5.0-CC-BY-3.0.txt b/src/sea/licenses/cdxgen-third-party/spdx-exceptions-2.5.0-CC-BY-3.0.txt new file mode 100644 index 0000000000..1a16e05564 --- /dev/null +++ b/src/sea/licenses/cdxgen-third-party/spdx-exceptions-2.5.0-CC-BY-3.0.txt @@ -0,0 +1,319 @@ +Creative Commons Legal Code + +Attribution 3.0 Unported + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR + DAMAGES RESULTING FROM ITS USE. + +License + +THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE +COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY +COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS +AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. + +BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE +TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY +BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS +CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND +CONDITIONS. + +1. Definitions + + a. "Adaptation" means a work based upon the Work, or upon the Work and + other pre-existing works, such as a translation, adaptation, + derivative work, arrangement of music or other alterations of a + literary or artistic work, or phonogram or performance and includes + cinematographic adaptations or any other form in which the Work may be + recast, transformed, or adapted including in any form recognizably + derived from the original, except that a work that constitutes a + Collection will not be considered an Adaptation for the purpose of + this License. For the avoidance of doubt, where the Work is a musical + work, performance or phonogram, the synchronization of the Work in + timed-relation with a moving image ("synching") will be considered an + Adaptation for the purpose of this License. + b. "Collection" means a collection of literary or artistic works, such as + encyclopedias and anthologies, or performances, phonograms or + broadcasts, or other works or subject matter other than works listed + in Section 1(f) below, which, by reason of the selection and + arrangement of their contents, constitute intellectual creations, in + which the Work is included in its entirety in unmodified form along + with one or more other contributions, each constituting separate and + independent works in themselves, which together are assembled into a + collective whole. A work that constitutes a Collection will not be + considered an Adaptation (as defined above) for the purposes of this + License. + c. "Distribute" means to make available to the public the original and + copies of the Work or Adaptation, as appropriate, through sale or + other transfer of ownership. + d. "Licensor" means the individual, individuals, entity or entities that + offer(s) the Work under the terms of this License. + e. "Original Author" means, in the case of a literary or artistic work, + the individual, individuals, entity or entities who created the Work + or if no individual or entity can be identified, the publisher; and in + addition (i) in the case of a performance the actors, singers, + musicians, dancers, and other persons who act, sing, deliver, declaim, + play in, interpret or otherwise perform literary or artistic works or + expressions of folklore; (ii) in the case of a phonogram the producer + being the person or legal entity who first fixes the sounds of a + performance or other sounds; and, (iii) in the case of broadcasts, the + organization that transmits the broadcast. + f. "Work" means the literary and/or artistic work offered under the terms + of this License including without limitation any production in the + literary, scientific and artistic domain, whatever may be the mode or + form of its expression including digital form, such as a book, + pamphlet and other writing; a lecture, address, sermon or other work + of the same nature; a dramatic or dramatico-musical work; a + choreographic work or entertainment in dumb show; a musical + composition with or without words; a cinematographic work to which are + assimilated works expressed by a process analogous to cinematography; + a work of drawing, painting, architecture, sculpture, engraving or + lithography; a photographic work to which are assimilated works + expressed by a process analogous to photography; a work of applied + art; an illustration, map, plan, sketch or three-dimensional work + relative to geography, topography, architecture or science; a + performance; a broadcast; a phonogram; a compilation of data to the + extent it is protected as a copyrightable work; or a work performed by + a variety or circus performer to the extent it is not otherwise + considered a literary or artistic work. + g. "You" means an individual or entity exercising rights under this + License who has not previously violated the terms of this License with + respect to the Work, or who has received express permission from the + Licensor to exercise rights under this License despite a previous + violation. + h. "Publicly Perform" means to perform public recitations of the Work and + to communicate to the public those public recitations, by any means or + process, including by wire or wireless means or public digital + performances; to make available to the public Works in such a way that + members of the public may access these Works from a place and at a + place individually chosen by them; to perform the Work to the public + by any means or process and the communication to the public of the + performances of the Work, including by public digital performance; to + broadcast and rebroadcast the Work by any means including signs, + sounds or images. + i. "Reproduce" means to make copies of the Work by any means including + without limitation by sound or visual recordings and the right of + fixation and reproducing fixations of the Work, including storage of a + protected performance or phonogram in digital form or other electronic + medium. + +2. Fair Dealing Rights. Nothing in this License is intended to reduce, +limit, or restrict any uses free from copyright or rights arising from +limitations or exceptions that are provided for in connection with the +copyright protection under copyright law or other applicable laws. + +3. License Grant. Subject to the terms and conditions of this License, +Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +perpetual (for the duration of the applicable copyright) license to +exercise the rights in the Work as stated below: + + a. to Reproduce the Work, to incorporate the Work into one or more + Collections, and to Reproduce the Work as incorporated in the + Collections; + b. to create and Reproduce Adaptations provided that any such Adaptation, + including any translation in any medium, takes reasonable steps to + clearly label, demarcate or otherwise identify that changes were made + to the original Work. For example, a translation could be marked "The + original work was translated from English to Spanish," or a + modification could indicate "The original work has been modified."; + c. to Distribute and Publicly Perform the Work including as incorporated + in Collections; and, + d. to Distribute and Publicly Perform Adaptations. + e. For the avoidance of doubt: + + i. Non-waivable Compulsory License Schemes. In those jurisdictions in + which the right to collect royalties through any statutory or + compulsory licensing scheme cannot be waived, the Licensor + reserves the exclusive right to collect such royalties for any + exercise by You of the rights granted under this License; + ii. Waivable Compulsory License Schemes. In those jurisdictions in + which the right to collect royalties through any statutory or + compulsory licensing scheme can be waived, the Licensor waives the + exclusive right to collect such royalties for any exercise by You + of the rights granted under this License; and, + iii. Voluntary License Schemes. The Licensor waives the right to + collect royalties, whether individually or, in the event that the + Licensor is a member of a collecting society that administers + voluntary licensing schemes, via that society, from any exercise + by You of the rights granted under this License. + +The above rights may be exercised in all media and formats whether now +known or hereafter devised. The above rights include the right to make +such modifications as are technically necessary to exercise the rights in +other media and formats. Subject to Section 8(f), all rights not expressly +granted by Licensor are hereby reserved. + +4. Restrictions. The license granted in Section 3 above is expressly made +subject to and limited by the following restrictions: + + a. You may Distribute or Publicly Perform the Work only under the terms + of this License. You must include a copy of, or the Uniform Resource + Identifier (URI) for, this License with every copy of the Work You + Distribute or Publicly Perform. You may not offer or impose any terms + on the Work that restrict the terms of this License or the ability of + the recipient of the Work to exercise the rights granted to that + recipient under the terms of the License. You may not sublicense the + Work. You must keep intact all notices that refer to this License and + to the disclaimer of warranties with every copy of the Work You + Distribute or Publicly Perform. When You Distribute or Publicly + Perform the Work, You may not impose any effective technological + measures on the Work that restrict the ability of a recipient of the + Work from You to exercise the rights granted to that recipient under + the terms of the License. This Section 4(a) applies to the Work as + incorporated in a Collection, but this does not require the Collection + apart from the Work itself to be made subject to the terms of this + License. If You create a Collection, upon notice from any Licensor You + must, to the extent practicable, remove from the Collection any credit + as required by Section 4(b), as requested. If You create an + Adaptation, upon notice from any Licensor You must, to the extent + practicable, remove from the Adaptation any credit as required by + Section 4(b), as requested. + b. If You Distribute, or Publicly Perform the Work or any Adaptations or + Collections, You must, unless a request has been made pursuant to + Section 4(a), keep intact all copyright notices for the Work and + provide, reasonable to the medium or means You are utilizing: (i) the + name of the Original Author (or pseudonym, if applicable) if supplied, + and/or if the Original Author and/or Licensor designate another party + or parties (e.g., a sponsor institute, publishing entity, journal) for + attribution ("Attribution Parties") in Licensor's copyright notice, + terms of service or by other reasonable means, the name of such party + or parties; (ii) the title of the Work if supplied; (iii) to the + extent reasonably practicable, the URI, if any, that Licensor + specifies to be associated with the Work, unless such URI does not + refer to the copyright notice or licensing information for the Work; + and (iv) , consistent with Section 3(b), in the case of an Adaptation, + a credit identifying the use of the Work in the Adaptation (e.g., + "French translation of the Work by Original Author," or "Screenplay + based on original Work by Original Author"). The credit required by + this Section 4 (b) may be implemented in any reasonable manner; + provided, however, that in the case of a Adaptation or Collection, at + a minimum such credit will appear, if a credit for all contributing + authors of the Adaptation or Collection appears, then as part of these + credits and in a manner at least as prominent as the credits for the + other contributing authors. For the avoidance of doubt, You may only + use the credit required by this Section for the purpose of attribution + in the manner set out above and, by exercising Your rights under this + License, You may not implicitly or explicitly assert or imply any + connection with, sponsorship or endorsement by the Original Author, + Licensor and/or Attribution Parties, as appropriate, of You or Your + use of the Work, without the separate, express prior written + permission of the Original Author, Licensor and/or Attribution + Parties. + c. Except as otherwise agreed in writing by the Licensor or as may be + otherwise permitted by applicable law, if You Reproduce, Distribute or + Publicly Perform the Work either by itself or as part of any + Adaptations or Collections, You must not distort, mutilate, modify or + take other derogatory action in relation to the Work which would be + prejudicial to the Original Author's honor or reputation. Licensor + agrees that in those jurisdictions (e.g. Japan), in which any exercise + of the right granted in Section 3(b) of this License (the right to + make Adaptations) would be deemed to be a distortion, mutilation, + modification or other derogatory action prejudicial to the Original + Author's honor and reputation, the Licensor will waive or not assert, + as appropriate, this Section, to the fullest extent permitted by the + applicable national law, to enable You to reasonably exercise Your + right under Section 3(b) of this License (right to make Adaptations) + but not otherwise. + +5. Representations, Warranties and Disclaimer + +UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR +OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY +KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, +INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, +FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF +LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, +WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION +OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU. + +6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE +LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR +ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES +ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS +BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +7. Termination + + a. This License and the rights granted hereunder will terminate + automatically upon any breach by You of the terms of this License. + Individuals or entities who have received Adaptations or Collections + from You under this License, however, will not have their licenses + terminated provided such individuals or entities remain in full + compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will + survive any termination of this License. + b. Subject to the above terms and conditions, the license granted here is + perpetual (for the duration of the applicable copyright in the Work). + Notwithstanding the above, Licensor reserves the right to release the + Work under different license terms or to stop distributing the Work at + any time; provided, however that any such election will not serve to + withdraw this License (or any other license that has been, or is + required to be, granted under the terms of this License), and this + License will continue in full force and effect unless terminated as + stated above. + +8. Miscellaneous + + a. Each time You Distribute or Publicly Perform the Work or a Collection, + the Licensor offers to the recipient a license to the Work on the same + terms and conditions as the license granted to You under this License. + b. Each time You Distribute or Publicly Perform an Adaptation, Licensor + offers to the recipient a license to the original Work on the same + terms and conditions as the license granted to You under this License. + c. If any provision of this License is invalid or unenforceable under + applicable law, it shall not affect the validity or enforceability of + the remainder of the terms of this License, and without further action + by the parties to this agreement, such provision shall be reformed to + the minimum extent necessary to make such provision valid and + enforceable. + d. No term or provision of this License shall be deemed waived and no + breach consented to unless such waiver or consent shall be in writing + and signed by the party to be charged with such waiver or consent. + e. This License constitutes the entire agreement between the parties with + respect to the Work licensed here. There are no understandings, + agreements or representations with respect to the Work not specified + here. Licensor shall not be bound by any additional provisions that + may appear in any communication from You. This License may not be + modified without the mutual written agreement of the Licensor and You. + f. The rights granted under, and the subject matter referenced, in this + License were drafted utilizing the terminology of the Berne Convention + for the Protection of Literary and Artistic Works (as amended on + September 28, 1979), the Rome Convention of 1961, the WIPO Copyright + Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996 + and the Universal Copyright Convention (as revised on July 24, 1971). + These rights and subject matter take effect in the relevant + jurisdiction in which the License terms are sought to be enforced + according to the corresponding provisions of the implementation of + those treaty provisions in the applicable national law. If the + standard suite of rights granted under applicable copyright law + includes additional rights not granted under this License, such + additional rights are deemed to be included in the License; this + License is not intended to restrict the license of any rights under + applicable law. + + +Creative Commons Notice + + Creative Commons is not a party to this License, and makes no warranty + whatsoever in connection with the Work. Creative Commons will not be + liable to You or any party on any legal theory for any damages + whatsoever, including without limitation any general, special, + incidental or consequential damages arising in connection to this + license. Notwithstanding the foregoing two (2) sentences, if Creative + Commons has expressly identified itself as the Licensor hereunder, it + shall have all rights and obligations of Licensor. + + Except for the limited purpose of indicating to the public that the + Work is licensed under the CCPL, Creative Commons does not authorize + the use by either party of the trademark "Creative Commons" or any + related trademark or logo of Creative Commons without the prior + written consent of Creative Commons. Any permitted use will be in + compliance with Creative Commons' then-current trademark usage + guidelines, as may be published on its website or otherwise made + available upon request from time to time. For the avoidance of doubt, + this trademark restriction does not form part of this License. + + Creative Commons may be contacted at https://creativecommons.org/. diff --git a/src/sea/licenses/cdxgen-third-party/spdx-license-ids-3.0.22-CC0-1.0.txt b/src/sea/licenses/cdxgen-third-party/spdx-license-ids-3.0.22-CC0-1.0.txt new file mode 100644 index 0000000000..0e259d42c9 --- /dev/null +++ b/src/sea/licenses/cdxgen-third-party/spdx-license-ids-3.0.22-CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/src/sea/licenses/core-third-party/ansi-term-0.0.2-ISC.txt b/src/sea/licenses/core-third-party/ansi-term-0.0.2-ISC.txt new file mode 100644 index 0000000000..0bdc810a98 --- /dev/null +++ b/src/sea/licenses/core-third-party/ansi-term-0.0.2-ISC.txt @@ -0,0 +1,7 @@ +ISC License + +Copyright Yaron Naveh + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src/sea/licenses/core-third-party/drawille-canvas-blessed-contrib-0.1.3-MIT.txt b/src/sea/licenses/core-third-party/drawille-canvas-blessed-contrib-0.1.3-MIT.txt new file mode 100644 index 0000000000..eb1b988c14 --- /dev/null +++ b/src/sea/licenses/core-third-party/drawille-canvas-blessed-contrib-0.1.3-MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) Bence Dányi + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/sea/licenses/core-third-party/npmcli-agent-3.0.0-ISC.txt b/src/sea/licenses/core-third-party/npmcli-agent-3.0.0-ISC.txt new file mode 100644 index 0000000000..ede5b2b0cb --- /dev/null +++ b/src/sea/licenses/core-third-party/npmcli-agent-3.0.0-ISC.txt @@ -0,0 +1,7 @@ +ISC License + +Copyright GitHub Inc. + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src/sea/licenses/core-third-party/npmcli-agent-4.0.0-ISC.txt b/src/sea/licenses/core-third-party/npmcli-agent-4.0.0-ISC.txt new file mode 100644 index 0000000000..ede5b2b0cb --- /dev/null +++ b/src/sea/licenses/core-third-party/npmcli-agent-4.0.0-ISC.txt @@ -0,0 +1,7 @@ +ISC License + +Copyright GitHub Inc. + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src/sea/licenses/core-third-party/sigstore-verify-3.1.1-Apache-2.0.txt b/src/sea/licenses/core-third-party/sigstore-verify-3.1.1-Apache-2.0.txt new file mode 100644 index 0000000000..e9e7c1679a --- /dev/null +++ b/src/sea/licenses/core-third-party/sigstore-verify-3.1.1-Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/sea/licenses/core-third-party/x256-0.0.2-MIT.txt b/src/sea/licenses/core-third-party/x256-0.0.2-MIT.txt new file mode 100644 index 0000000000..a57c6861a8 --- /dev/null +++ b/src/sea/licenses/core-third-party/x256-0.0.2-MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) James Halliday + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/sea/licenses/socket-patch-MIT.txt b/src/sea/licenses/socket-patch-MIT.txt new file mode 100644 index 0000000000..47776098d2 --- /dev/null +++ b/src/sea/licenses/socket-patch-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Socket + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/sea/rollup-provenance.mjs b/src/sea/rollup-provenance.mjs new file mode 100644 index 0000000000..87f9763852 --- /dev/null +++ b/src/sea/rollup-provenance.mjs @@ -0,0 +1,762 @@ +import { createHash } from 'node:crypto' +/* eslint-disable no-await-in-loop -- Inventory traversal and hashing must remain deterministic. */ +import { promises as fs } from 'node:fs' +import path from 'node:path' +import url from 'node:url' + +import { parse as parseYaml } from 'yaml' + +const legalFilenamePattern = + /^(?:license|licence|copying|copyright|notice|patents?)(?:[._ -].*)?$/i + +const curatedLegalDocuments = { + '@npmcli/agent@3.0.0': [ + { + filename: 'npmcli-agent-3.0.0-ISC.txt', + kind: 'license', + sourcePath: + 'ISC terms (npm package metadata declares ISC; npm tarball and pinned upstream commit omit LICENSE)', + source: 'https://opensource.org/license/isc-license-txt/', + attributionSource: 'https://registry.npmjs.org/@npmcli/agent/3.0.0', + attributionInferredFrom: + 'Copyright holder is inferred from the npm author field (GitHub Inc.); no published copyright notice or year was found.', + }, + ], + '@npmcli/agent@4.0.0': [ + { + filename: 'npmcli-agent-4.0.0-ISC.txt', + kind: 'license', + sourcePath: + 'ISC terms (npm package metadata declares ISC; npm tarball and pinned upstream commit omit LICENSE)', + source: 'https://opensource.org/license/isc-license-txt/', + attributionSource: 'https://registry.npmjs.org/@npmcli/agent/4.0.0', + attributionInferredFrom: + 'Copyright holder is inferred from the npm author field (GitHub Inc.); no published copyright notice or year was found.', + }, + ], + '@sigstore/verify@3.1.1': [ + { + filename: 'sigstore-verify-3.1.1-Apache-2.0.txt', + kind: 'license', + sourcePath: 'LICENSE (upstream sigstore-js root file at package gitHead)', + source: + 'https://github.com/sigstore/sigstore-js/blob/c1dc7d4778a450787fc72b083f2490ad02b714c6/LICENSE', + }, + ], + 'x256@0.0.2': [ + { + filename: 'x256-0.0.2-MIT.txt', + kind: 'license', + sourcePath: + 'MIT terms (npm README declares MIT; npm tarball and pinned upstream commit omit LICENSE)', + source: + 'https://raw.githubusercontent.com/spdx/license-list-data/v3.26.0/text/MIT.txt', + attributionSource: 'https://registry.npmjs.org/x256/0.0.2', + attributionInferredFrom: + 'Copyright holder is inferred from the npm author field (James Halliday); no published copyright notice or year was found.', + }, + ], + 'ansi-term@0.0.2': [ + { + filename: 'ansi-term-0.0.2-ISC.txt', + kind: 'license', + sourcePath: + 'ISC terms (npm package metadata declares ISC; npm tarball and upstream repository omit LICENSE)', + source: 'https://opensource.org/license/isc-license-txt/', + attributionSource: 'https://registry.npmjs.org/ansi-term/0.0.2', + attributionInferredFrom: + 'Copyright holder is inferred from the npm author field (Yaron Naveh); no published copyright notice or year was found.', + }, + ], + 'drawille-canvas-blessed-contrib@0.1.3': [ + { + filename: 'drawille-canvas-blessed-contrib-0.1.3-MIT.txt', + kind: 'license', + sourcePath: + 'MIT terms (npm README declares MIT; npm tarball and pinned upstream commit omit LICENSE)', + source: + 'https://raw.githubusercontent.com/spdx/license-list-data/v3.26.0/text/MIT.txt', + attributionSource: + 'https://registry.npmjs.org/drawille-canvas-blessed-contrib/0.1.3', + attributionInferredFrom: + 'Copyright holder is inferred from the npm author field (Bence Dányi); no published copyright notice or year was found.', + }, + ], + 'err-code@2.0.3': [ + { + filename: 'err-code-2.0.3-MIT.txt', + kind: 'license', + sourcePath: 'MIT text (package README declares MIT)', + source: + 'https://raw.githubusercontent.com/spdx/license-list-data/v3.26.0/text/MIT.txt', + attributionSource: 'https://registry.npmjs.org/err-code/2.0.3', + attributionInferredFrom: + 'npm author field (IndigoUnited); no package copyright line or LICENSE file is published.', + }, + ], + 'imurmurhash@0.1.4': [ + { + filename: 'imurmurhash-0.1.4-MIT.txt', + kind: 'license', + sourcePath: 'LICENSE (upstream file; omitted from npm tarball)', + source: 'https://github.com/jensyt/imurmurhash-js/blob/0.1.4/LICENSE', + }, + ], + 'spdx-exceptions@2.5.0': [ + { + filename: 'spdx-exceptions-2.5.0-CC-BY-3.0.txt', + kind: 'license', + sourcePath: 'CC-BY-3.0 legal code', + source: + 'https://raw.githubusercontent.com/spdx/license-list-data/v3.26.0/text/CC-BY-3.0.txt', + }, + ], + 'spdx-license-ids@3.0.22': [ + { + filename: 'spdx-license-ids-3.0.22-CC0-1.0.txt', + kind: 'license', + sourcePath: 'CC0-1.0 legal code', + source: + 'https://raw.githubusercontent.com/spdx/license-list-data/v3.26.0/text/CC0-1.0.txt', + }, + ], +} + +const coreThirdPartyPackageIds = new Set([ + '@npmcli/agent@3.0.0', + '@npmcli/agent@4.0.0', + '@sigstore/verify@3.1.1', + 'x256@0.0.2', + 'ansi-term@0.0.2', + 'drawille-canvas-blessed-contrib@0.1.3', +]) + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0 +} + +function stableValue(value) { + if (Array.isArray(value)) { + return value.map(stableValue) + } + if (!value || typeof value !== 'object') { + return value + } + return Object.fromEntries( + Object.keys(value) + .sort(compareText) + .map(key => [key, stableValue(value[key])]), + ) +} + +function stableJson(value) { + return `${JSON.stringify(stableValue(value), null, 2)}\n` +} + +function sourcePathFromId(id) { + const withoutQuery = id.split('?')[0] + if (withoutQuery.startsWith('file:')) { + return url.fileURLToPath(withoutQuery) + } + return withoutQuery +} + +async function findPackageRoot(modulePath) { + let current = path.dirname(modulePath) + while (current !== path.dirname(current)) { + const packageJsonPath = path.join(current, 'package.json') + try { + const metadata = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) + if ( + typeof metadata.name === 'string' && + typeof metadata.version === 'string' + ) { + return { directory: current, metadata } + } + } catch (error) { + if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) { + throw error + } + } + if (path.basename(current) === 'node_modules') { + break + } + current = path.dirname(current) + } +} + +async function findLegalFiles(packageRoot) { + const files = [] + const skippedLinks = [] + const visit = async (directory, relativeDirectory = '') => { + for (const entry of ( + await fs.readdir(directory, { withFileTypes: true }) + ).sort((left, right) => compareText(left.name, right.name))) { + if (entry.name === 'node_modules' || entry.name === '.git') { + continue + } + const relativePath = path.posix.join( + relativeDirectory.split(path.sep).join('/'), + entry.name, + ) + const fullPath = path.join(directory, entry.name) + if (entry.isDirectory()) { + await visit(fullPath, path.join(relativeDirectory, entry.name)) + } else if (entry.isSymbolicLink()) { + if (legalFilenamePattern.test(entry.name)) { + skippedLinks.push(relativePath) + } + } else if (entry.isFile() && legalFilenamePattern.test(entry.name)) { + files.push({ fullPath, relativePath }) + } + } + } + await visit(packageRoot) + return { files, skippedLinks } +} + +function normalizedInstalledInstance(packageRoot, root) { + const parts = path.resolve(packageRoot).split(path.sep).filter(Boolean) + const pnpmIndex = parts.lastIndexOf('.pnpm') + if (pnpmIndex >= 0) { + return `pnpm-virtual-store/${parts.slice(pnpmIndex + 1).join('/')}` + } + const storeIndex = parts.lastIndexOf('store') + if (storeIndex >= 0 && parts[storeIndex + 1]?.startsWith('v')) { + return `pnpm-content-store/${parts.slice(storeIndex + 2).join('/')}` + } + const relative = path.relative(root, packageRoot).split(path.sep).join('/') + if (relative && !relative.startsWith('../')) { + return `repository/${relative}` + } + return `external-store/${path.basename(packageRoot)}` +} + +function lockContextFor(lock, packageName, version, root, packageRoot) { + const packageKey = `${packageName}@${version}` + const packageEntry = lock.packages?.[packageKey] + const snapshotCandidates = Object.keys(lock.snapshots ?? {}) + .filter(key => key === packageKey || key.startsWith(`${packageKey}(`)) + .sort(compareText) + const patch = lock.patchedDependencies?.[packageKey] + const resolution = packageEntry?.resolution ?? null + const { integrity: declaredIntegrity, ...otherResolutionFields } = + resolution ?? {} + const output = { + packageResolutionLocator: packageEntry ? packageKey : null, + snapshotLocators: snapshotCandidates, + snapshotAttribution: + snapshotCandidates.length > 1 + ? 'ambiguous-unverified' + : snapshotCandidates.length === 1 + ? 'single-candidate-unverified-not-bound-to-installed-instance' + : 'no-lock-snapshot-candidate', + normalizedInstalledInstance: normalizedInstalledInstance(packageRoot, root), + declaredLockIntegrity: declaredIntegrity ?? null, + declaredResolutionContext: otherResolutionFields, + patch: null, + } + if (patch) { + const patchPath = path.join(root, patch.path) + output.patch = { + path: patch.path, + declaredPatchHash: patch.hash ?? null, + patchFileSha256: null, + patchFileHashMatchesLock: false, + appliedSourceVerification: 'unverified', + } + return fs.readFile(patchPath).then( + bytes => { + output.patch.patchFileSha256 = sha256(bytes) + output.patch.patchFileHashMatchesLock = + output.patch.patchFileSha256 === patch.hash + return output + }, + error => { + if (error?.code === 'ENOENT') { + return output + } + throw error + }, + ) + } + return Promise.resolve(output) +} + +function packageSlug(packageName, version, lockContext) { + const identity = `${packageName}@${version}\0${lockContext.normalizedInstalledInstance}\0${lockContext.snapshotLocators.join('|')}` + return `${packageName.replace(/[^A-Za-z0-9._-]+/g, '_')}-${version}-${sha256(Buffer.from(identity)).slice(0, 12)}` +} + +async function collectPackageRecords(ids, { lock, root }) { + const packages = new Map() + const unmappedModules = [] + const seen = new Set() + for (const id of ids) { + if (typeof id !== 'string' || id.startsWith('\0')) { + continue + } + const rawPath = sourcePathFromId(id) + if ( + !path.isAbsolute(rawPath) || + !rawPath.split(path.sep).includes('node_modules') + ) { + continue + } + let modulePath + try { + modulePath = await fs.realpath(rawPath) + } catch (error) { + if (error?.code === 'ENOENT') { + continue + } + throw error + } + if (seen.has(modulePath)) { + continue + } + seen.add(modulePath) + const packageInfo = await findPackageRoot(modulePath) + const moduleBytes = await fs.readFile(modulePath) + if (!packageInfo) { + unmappedModules.push({ + path: `unmapped/${path.basename(modulePath)}-${sha256(moduleBytes).slice(0, 12)}`, + sha256: sha256(moduleBytes), + verification: 'unverified', + verificationReason: + 'No package metadata root was found for this included Rollup module.', + }) + continue + } + const { directory, metadata } = packageInfo + const lockContext = await lockContextFor( + lock, + metadata.name, + metadata.version, + root, + directory, + ) + const sourceFile = path + .relative(directory, modulePath) + .split(path.sep) + .join('/') + const instanceKey = `${metadata.name}@${metadata.version}\0${lockContext.normalizedInstalledInstance}\0${lockContext.snapshotLocators.join('|')}` + let record = packages.get(instanceKey) + if (!record) { + record = { + name: metadata.name, + version: metadata.version, + packageRoot: directory, + instanceKey, + license: metadata.license ?? metadata.licenses ?? null, + lockContext, + modules: [], + legalDocuments: [], + obligations: [], + } + packages.set(instanceKey, record) + } + record.modules.push({ + packageInstance: lockContext.normalizedInstalledInstance, + path: `${lockContext.normalizedInstalledInstance}/${sourceFile}`, + packagePath: sourceFile, + sha256: sha256(moduleBytes), + verification: 'unverified', + verificationReason: + 'Hash identifies installed module bytes; declared lock SRI does not authenticate installed, patched, or Rollup-generated bytes.', + }) + } + for (const record of packages.values()) { + record.modules.sort((left, right) => compareText(left.path, right.path)) + const { files, skippedLinks } = await findLegalFiles(record.packageRoot) + const slug = packageSlug(record.name, record.version, record.lockContext) + const stagedPaths = new Set() + for (const file of files) { + const bytes = await fs.readFile(file.fullPath) + const digest = sha256(bytes) + const relative = file.relativePath + .split('/') + .map(segment => segment.replace(/[^A-Za-z0-9._-]+/g, '_')) + .join('/') + const outputPath = `third-party-notices/${slug}/${relative}.${digest.slice(0, 12)}` + if (stagedPaths.has(outputPath)) { + continue + } + stagedPaths.add(outputPath) + record.legalDocuments.push({ + packagePath: file.relativePath, + outputPath, + sha256: digest, + type: /^(?:notice|copyright|patents?)/i.test( + path.posix.basename(file.relativePath), + ) + ? 'notice' + : 'license', + contents: bytes, + }) + } + if (!record.legalDocuments.some(document => document.type === 'license')) { + for (const document of curatedLegalDocuments[ + `${record.name}@${record.version}` + ] ?? []) { + const packageId = `${record.name}@${record.version}` + const legalDirectory = coreThirdPartyPackageIds.has(packageId) + ? 'core-third-party' + : 'cdxgen-third-party' + const curatedPath = path.join( + root, + 'src/sea/licenses', + legalDirectory, + document.filename, + ) + const contents = await fs.readFile(curatedPath) + const digest = sha256(contents) + const outputPath = `third-party-notices/${slug}/${document.filename}.${digest.slice(0, 12)}` + record.legalDocuments.push({ + packagePath: document.sourcePath, + outputPath, + sha256: digest, + type: document.kind, + contents, + source: document.source, + ...(document.attributionSource + ? { attributionSource: document.attributionSource } + : {}), + ...(document.attributionInferredFrom + ? { attributionInferredFrom: document.attributionInferredFrom } + : {}), + }) + if (document.attributionInferredFrom) { + record.obligations.push({ + code: 'inferred-copyright-attribution', + message: document.attributionInferredFrom, + }) + } + } + } + record.legalDocuments.sort((left, right) => + compareText(left.outputPath, right.outputPath), + ) + if (!record.legalDocuments.some(document => document.type === 'license')) { + record.obligations.push({ + code: 'missing-license-text', + message: 'No license text file was found in the included package tree.', + }) + } + if (skippedLinks.length) { + record.obligations.push({ + code: 'unresolved-license-symlink', + paths: [...new Set(skippedLinks)].sort(compareText), + message: + 'A license or notice symlink was not followed when collecting package texts.', + }) + } + } + return { + packages: [...packages.values()].sort((left, right) => + compareText( + `${left.name}@${left.version}\0${left.instanceKey}`, + `${right.name}@${right.version}\0${right.instanceKey}`, + ), + ), + unmappedModules: unmappedModules.sort((left, right) => + compareText(left.path, right.path), + ), + } +} + +function serializeInventory(records, { bundleId, lockfilePath }) { + return { + schemaVersion: 1, + generator: 'rollup-included-module-source-hashes', + bundle: bundleId, + lockfile: { path: lockfilePath, lockfileVersion: records.lockfileVersion }, + sourceAuthentication: 'unverified', + sourceAuthenticationReason: + 'Per-file hashes identify observed installed source. Declared lock SRI authenticates the registry tarball only and is not proof of installed bytes, patch application, or generated Rollup output.', + packageCount: records.packages.length, + moduleCount: + records.packages.reduce( + (count, record) => count + record.modules.length, + 0, + ) + records.unmappedModules.length, + packages: records.packages.map(record => ({ + name: record.name, + version: record.version, + license: record.license, + lockContext: record.lockContext, + modules: record.modules, + legalDocuments: record.legalDocuments.map( + ({ contents: _contents, ...document }) => document, + ), + obligations: record.obligations, + })), + unmappedModules: records.unmappedModules, + obligations: [ + ...records.packages.flatMap(record => + record.obligations.map(item => ({ + package: `${record.name}@${record.version}`, + ...item, + })), + ), + ...(records.unmappedModules.length + ? [ + { + code: 'unmapped-rollup-modules', + count: records.unmappedModules.length, + message: + 'Included Rollup modules could not be mapped to package metadata.', + }, + ] + : []), + ], + } +} + +async function readLock(root, lockfilePath) { + const lock = parseYaml( + await fs.readFile(path.join(root, lockfilePath), 'utf8'), + ) + if (!lock || typeof lock.packages !== 'object' || !lock.packages) { + throw new Error( + `Lockfile does not contain a packages graph: ${lockfilePath}`, + ) + } + return lock +} + +async function stageRecords( + records, + { bundleId, lockfilePath, manifestName, outputRoot }, +) { + const docsDir = path.join(outputRoot, 'third-party-notices') + await fs.mkdir(docsDir, { recursive: true }) + const outputDocuments = [] + for (const record of records.packages) { + for (const document of record.legalDocuments) { + const destination = path.join(outputRoot, document.outputPath) + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.writeFile(destination, document.contents) + outputDocuments.push({ + package: `${record.name}@${record.version}`, + path: document.outputPath, + sha256: document.sha256, + }) + } + } + const inventory = serializeInventory( + { ...records, lockfileVersion: records.lockfileVersion }, + { bundleId, lockfilePath }, + ) + inventory.outputDocuments = outputDocuments.sort((left, right) => + compareText(left.path, right.path), + ) + const manifestPath = path.join(outputRoot, manifestName) + await fs.writeFile(manifestPath, stableJson(inventory)) + inventory.inventoryFile = { + path: manifestName, + sha256: sha256(Buffer.from(stableJson(inventory))), + } + return inventory +} + +export function includedModulesInventoryPlugin({ + bundleId = 'core-rollup', + inventoryFile = 'THIRD-PARTY-NOTICES.json', + lockfilePath = 'pnpm-lock.yaml', + mode = 'emit', + outputRoot, + root, +} = {}) { + const resolvedRoot = path.resolve(root) + let generated + return { + name: `sea-provenance-${bundleId}`, + async generateBundle(_options, bundle) { + const moduleIds = new Set() + for (const output of Object.values(bundle)) { + if (output.type === 'chunk') { + for (const id of Object.keys(output.modules)) { + moduleIds.add(id) + } + } + } + const lock = await readLock(resolvedRoot, lockfilePath) + const records = await collectPackageRecords(moduleIds, { + root: resolvedRoot, + lock, + }) + records.lockfileVersion = lock.lockfileVersion + const inventory = serializeInventory(records, { bundleId, lockfilePath }) + const assets = records.packages.flatMap(record => record.legalDocuments) + if (mode === 'emit') { + for (const document of assets) { + this.emitFile({ + type: 'asset', + fileName: document.outputPath, + source: document.contents, + }) + } + this.emitFile({ + type: 'asset', + fileName: inventoryFile, + source: stableJson(inventory), + }) + } else { + generated = { records, inventory, assets } + } + generated ??= { records, inventory, assets } + }, + async writeBundle(outputOptions) { + if (mode !== 'stage' || !generated) { + return + } + const destination = + outputRoot ?? + path.resolve( + outputOptions.dir ?? path.dirname(outputOptions.file ?? resolvedRoot), + ) + await stageRecords(generated.records, { + outputRoot: destination, + manifestName: inventoryFile, + bundleId, + lockfilePath, + }) + }, + } +} + +export async function writeCopiedPackageInventory({ + bundleId, + inventoryFile, + lockfilePath = 'pnpm-lock.yaml', + outputPackageRoot, + outputRoot, + packageName: _packageName, + packageRoot, + root, +}) { + const resolvedRoot = path.resolve(root) + const lock = await readLock(resolvedRoot, lockfilePath) + const packageJsonPath = path.join(packageRoot, 'package.json') + const metadata = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) + const resolvedPackageRoot = await fs.realpath(packageRoot) + const lockContext = await lockContextFor( + lock, + metadata.name, + metadata.version, + resolvedRoot, + resolvedPackageRoot, + ) + const { files, skippedLinks } = await findLegalFiles(packageRoot) + const slug = packageSlug(metadata.name, metadata.version, lockContext) + const legalDocuments = [] + for (const file of files) { + const contents = await fs.readFile(file.fullPath) + const digest = sha256(contents) + const relative = file.relativePath + .split('/') + .map(segment => segment.replace(/[^A-Za-z0-9._-]+/g, '_')) + .join('/') + const outputPath = `third-party-notices/${slug}/${relative}.${digest.slice(0, 12)}` + const destination = path.join(outputRoot, outputPath) + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.writeFile(destination, contents) + legalDocuments.push({ + packagePath: file.relativePath, + outputPath, + sha256: digest, + type: /^(?:notice|copyright|patents?)/i.test( + path.posix.basename(file.relativePath), + ) + ? 'notice' + : 'license', + }) + } + const filesInOutput = [] + const visit = async (directory, relativeDirectory = '') => { + for (const entry of ( + await fs.readdir(directory, { withFileTypes: true }) + ).sort((a, b) => compareText(a.name, b.name))) { + const absolute = path.join(directory, entry.name) + const relativePath = path.posix.join(relativeDirectory, entry.name) + if (entry.isDirectory()) { + await visit(absolute, relativePath) + } else if (entry.isFile()) { + const bytes = await fs.readFile(absolute) + const sourcePath = path.join(packageRoot, ...relativePath.split('/')) + const sourceBytes = await fs.readFile(sourcePath).catch(error => { + if (error?.code === 'ENOENT') { + return undefined + } + throw error + }) + filesInOutput.push({ + path: relativePath, + outputSha256: sha256(bytes), + ...(sourceBytes + ? { sourceSha256: sha256(sourceBytes) } + : { sourceSha256: null }), + verification: 'unverified', + }) + } + } + } + await visit(outputPackageRoot) + const obligations = [] + if (!legalDocuments.some(document => document.type === 'license')) { + obligations.push({ + code: 'missing-license-text', + message: 'No license text file was found in the copied package source.', + }) + } + if (skippedLinks.length) { + obligations.push({ + code: 'unresolved-license-symlink', + paths: skippedLinks.sort(compareText), + }) + } + const inventory = { + schemaVersion: 1, + generator: 'sea-copied-package-source-hashes', + bundle: bundleId, + lockfile: { path: lockfilePath, lockfileVersion: lock.lockfileVersion }, + sourceAuthentication: 'unverified', + sourceAuthenticationReason: + 'Hashes identify installed package bytes and copied output. Declared lock SRI is not authentication of installed or patched files.', + packageCount: 1, + moduleCount: filesInOutput.filter(file => /\.m?js$/i.test(file.path)) + .length, + packages: [ + { + name: metadata.name, + version: metadata.version, + license: metadata.license ?? metadata.licenses ?? null, + lockContext, + files: filesInOutput, + legalDocuments, + obligations, + }, + ], + unmappedModules: [], + obligations: obligations.map(item => ({ + package: `${metadata.name}@${metadata.version}`, + ...item, + })), + outputDocuments: legalDocuments.map(document => ({ + package: `${metadata.name}@${metadata.version}`, + path: document.outputPath, + sha256: document.sha256, + })), + } + const bytes = Buffer.from(stableJson(inventory)) + await fs.writeFile(path.join(outputRoot, inventoryFile), bytes) + return { + ...inventory, + inventoryFile: { path: inventoryFile, sha256: sha256(bytes) }, + } +} diff --git a/src/sea/runtime.mts b/src/sea/runtime.mts new file mode 100644 index 0000000000..58e3c6e99d --- /dev/null +++ b/src/sea/runtime.mts @@ -0,0 +1,465 @@ +import { createHash } from 'node:crypto' +import { existsSync, promises as fs, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import os from 'node:os' +import path from 'node:path' +import { Readable, type Writable } from 'node:stream' +import { fileURLToPath } from 'node:url' +import { Worker } from 'node:worker_threads' + +import type { StdioOptions } from 'node:child_process' + +/** + * SEA runtime adapters. Node 27 VFS Workers share the parent PID, so these are + * for allowlisted JavaScript tools that are compatible with Worker threads; + * native helpers use getBundledTool() and a real child process instead. + */ +export interface EmbeddedToolRunOptions { + cwd?: string | URL | undefined + env?: NodeJS.ProcessEnv | undefined + /** True (default) merges process.env with env; false uses only defined env entries. */ + inheritEnv?: boolean | undefined + /** Explicit Socket metadata for the Worker; this is workerData, not process IPC. */ + ipc?: Readonly> | undefined + stdio?: StdioOptions | undefined + stdin?: + | string + | Buffer + | NodeJS.ReadableStream + | AsyncIterable + | undefined + stdout?: Writable | undefined + stderr?: Writable | undefined + signal?: AbortSignal | undefined +} + +export interface EmbeddedToolRunResult { + code: number | null + signal: NodeJS.Signals | null + cancelled?: boolean + stdout?: string + stderr?: string +} + +const sea = (() => { + try { + return createRequire(import.meta.url)('node:sea') as { + isSea?: () => boolean + getAsset?: (key: string) => ArrayBuffer | Uint8Array + } + } catch { + // The regular Socket CLI supports Node releases predating `node:sea`. + return {} + } +})() + +const extractedToolDirectories = new Map() +type SeaGlobal = typeof globalThis & { __socketSeaVfsRoot?: string } + +// These are the only JS entrypoints the Worker adapter may launch. The paths +// are staged under the archive root by a later helper-payload build slice; +// absent files remain unavailable and fail closed here. +const embeddedJsToolEntrypoints = Object.freeze({ + cdxgen: 'tools/cdxgen/sea-worker.mjs', + synp: 'tools/synp/bin/synp.js', +}) + +const embeddedNativeToolPaths = Object.freeze({ + uv: (platform: string) => + `tools/uv/${platform === 'win32' ? 'uv.exe' : 'uv'}`, +}) + +function getVfsRoot(): string { + const root = (globalThis as SeaGlobal).__socketSeaVfsRoot + if (!root) { + throw new Error('The standalone Socket VFS root is not available') + } + return root +} + +function signalFromAbortReason(reason: unknown): NodeJS.Signals | null { + const code = + reason !== null && typeof reason === 'object' + ? (reason as { code?: unknown }).code + : undefined + return typeof code === 'string' && + [ + 'SIGABRT', + 'SIGALRM', + 'SIGBUS', + 'SIGCHLD', + 'SIGCONT', + 'SIGFPE', + 'SIGHUP', + 'SIGILL', + 'SIGINT', + 'SIGIO', + 'SIGIOT', + 'SIGKILL', + 'SIGPIPE', + 'SIGPOLL', + 'SIGPROF', + 'SIGPWR', + 'SIGQUIT', + 'SIGSEGV', + 'SIGSTKFLT', + 'SIGSTOP', + 'SIGSYS', + 'SIGTERM', + 'SIGTRAP', + 'SIGTSTP', + 'SIGTTIN', + 'SIGTTOU', + 'SIGURG', + 'SIGUSR1', + 'SIGUSR2', + 'SIGVTALRM', + 'SIGWINCH', + 'SIGXCPU', + 'SIGXFSZ', + ].includes(code) + ? (code as NodeJS.Signals) + : null +} + +export function isStandaloneSea(): boolean { + return typeof sea.isSea === 'function' && sea.isSea() +} + +export function getEmbeddedAsset(id: string): Buffer { + if (!isStandaloneSea()) { + throw new Error( + `Embedded asset '${id}' is available only in the standalone Socket build`, + ) + } + const files: Record = { + 'socket-patch': `resources/${process.platform === 'win32' ? 'socket-patch.exe' : 'socket-patch'}`, + 'socket-patch-sha256': 'resources/socket-patch.sha256', + } + const relative = files[id] + const root = (globalThis as SeaGlobal).__socketSeaVfsRoot + if (!relative || !root) { + throw new Error( + `Standalone Socket asset '${id}' is missing from this build`, + ) + } + try { + return readFileSync(path.join(root, relative)) + } catch { + throw new Error( + `Standalone Socket asset '${id}' is missing from this build`, + ) + } +} + +/** Fail closed unless the build manifest explicitly advertises this tool. */ +export function assertEmbeddedToolAvailable( + id: 'cdxgen' | 'synp', + capability?: 'manifest-cdxgen-npm-lock-v3' | 'manifest-cdxgen-yarn-classic', +): void { + if (!isStandaloneSea()) { + return + } + const root = (globalThis as SeaGlobal).__socketSeaVfsRoot + let capabilities: unknown + try { + if (!root) { + throw new Error('missing VFS root') + } + capabilities = JSON.parse( + readFileSync(path.join(root, 'resources/capabilities.json'), 'utf8'), + ) + } catch { + throw new Error( + `Embedded ${id} is unavailable because tool capabilities are missing or invalid in this build.`, + ) + } + if ( + capabilities === null || + typeof capabilities !== 'object' || + Array.isArray(capabilities) || + !Array.isArray((capabilities as { available?: unknown }).available) || + !Array.isArray((capabilities as { unavailable?: unknown }).unavailable) + ) { + throw new Error( + `Embedded ${id} is unavailable because tool capabilities are missing or invalid in this build.`, + ) + } + const manifest = capabilities as { + available: unknown[] + unavailable: unknown[] + } + const required = capability + ? [capability] + : id === 'cdxgen' + ? ['manifest-cdxgen-npm-lock-v3', 'manifest-cdxgen-yarn-classic'] + : ['synp'] + if (required.some(tool => manifest.available.includes(tool))) { + return + } + const unavailableIds = id === 'cdxgen' ? [...required, id] : [id] + const reasons = manifest.unavailable.flatMap(entry => { + if (entry === null || typeof entry !== 'object') { + return [] + } + const { id: unavailableId, reason } = entry as { + id?: unknown + reason?: unknown + } + return unavailableIds.includes(unavailableId as string) && + typeof reason === 'string' + ? [reason] + : [] + }) + const reason = reasons.length ? ` ${[...new Set(reasons)].join(' ')}` : '' + throw new Error( + `Embedded ${id} is unavailable in this standalone build.${reason}`, + ) +} + +export async function getBundledTool(id: string): Promise { + if (id !== 'socket-patch' && id !== 'uv') { + throw new Error( + `Bundled tool '${id}' is not configured in this SEA foundation`, + ) + } + if (!isStandaloneSea()) { + throw new Error( + `Bundled tool '${id}' is available only in the standalone Socket build`, + ) + } + + if (id === 'uv') { + const relative = embeddedNativeToolPaths.uv(process.platform) + const source = path.join(getVfsRoot(), relative) + const binary = await fs.readFile(source) + const expectedDigest = await fs.readFile( + path.join(getVfsRoot(), 'tools/uv/uv.sha256'), + 'utf8', + ) + const actualDigest = createHash('sha256').update(binary).digest('hex') + if ( + !/^[a-f0-9]{64}$/.test(expectedDigest.trim()) || + actualDigest !== expectedDigest.trim() + ) { + throw new Error('Embedded uv payload failed SHA-256 verification') + } + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'socket-cli-sea-'), + ) + const filename = path.join(directory, path.basename(relative)) + try { + await fs.writeFile(filename, binary, { flag: 'wx', mode: 0o700 }) + if (process.platform !== 'win32') { + await fs.chmod(filename, 0o700) + } + extractedToolDirectories.set(filename, directory) + return filename + } catch (error) { + await fs.rm(directory, { recursive: true, force: true }) + throw error + } + } + + const binary = getEmbeddedAsset('socket-patch') + const expectedDigest = getEmbeddedAsset('socket-patch-sha256') + .toString('utf8') + .trim() + const actualDigest = createHash('sha256').update(binary).digest('hex') + if ( + !/^[a-f0-9]{64}$/.test(expectedDigest) || + actualDigest !== expectedDigest + ) { + throw new Error('Embedded socket-patch payload failed SHA-256 verification') + } + + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-cli-sea-')) + const filename = path.join( + directory, + process.platform === 'win32' ? 'socket-patch.exe' : 'socket-patch', + ) + try { + await fs.writeFile(filename, binary, { flag: 'wx', mode: 0o700 }) + if (process.platform !== 'win32') { + await fs.chmod(filename, 0o700) + } + extractedToolDirectories.set(filename, directory) + return filename + } catch (error) { + await fs.rm(directory, { recursive: true, force: true }) + throw error + } +} + +export async function cleanupBundledTool(filename: string): Promise { + const directory = extractedToolDirectories.get(filename) + if (!directory) { + return + } + extractedToolDirectories.delete(filename) + await fs.rm(directory, { recursive: true, force: true }) +} + +export async function runEmbeddedJsTool( + id: string, + args: readonly string[], + options: EmbeddedToolRunOptions = {}, +): Promise { + if (!isStandaloneSea()) { + throw new Error( + `Embedded JavaScript tool '${id}' is available only in the standalone Socket build`, + ) + } + if (!Object.hasOwn(embeddedJsToolEntrypoints, id)) { + throw new Error(`Embedded JavaScript tool '${id}' is not allowlisted`) + } + if (id === 'cdxgen' || id === 'synp') { + assertEmbeddedToolAvailable(id) + } + if (options.signal?.aborted) { + return { + code: null, + signal: signalFromAbortReason(options.signal.reason), + cancelled: true, + } + } + + // Worker threads inherit cwd and Node does not support changing it per + // Worker. Reject a different request rather than process.chdir() in the + // shared parent process. + const requestedCwd = + options.cwd instanceof URL ? fileURLToPath(options.cwd) : options.cwd + if (requestedCwd && path.resolve(requestedCwd) !== process.cwd()) { + throw new Error( + `Embedded JavaScript tool '${id}' cannot change cwd in a VFS Worker; ` + + 'run Socket from the target project directory.', + ) + } + + const entrypoint = path.join( + getVfsRoot(), + embeddedJsToolEntrypoints[id as keyof typeof embeddedJsToolEntrypoints], + ) + if (!existsSync(entrypoint)) { + throw new Error( + `Embedded JavaScript tool '${id}' is unavailable because its VFS entrypoint is not bundled for this target.`, + ) + } + + const requestedStdio = options.stdio ?? 'inherit' + if ( + requestedStdio !== 'inherit' && + requestedStdio !== 'ignore' && + requestedStdio !== 'pipe' + ) { + throw new Error( + `Embedded JavaScript tool '${id}' supports only inherit, ignore, or pipe stdio in a VFS Worker.`, + ) + } + const stdio = requestedStdio + const wantsStreams = stdio !== 'ignore' + const pipeInput = options.stdin + const captureStdout: Buffer[] = [] + const captureStderr: Buffer[] = [] + const worker = new Worker(entrypoint, { + argv: [...args], + env: + options.inheritEnv === false + ? Object.fromEntries( + Object.entries(options.env ?? {}).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ) + : { ...process.env, ...options.env }, + execArgv: [], + workerData: { socketIpc: options.ipc }, + stdin: wantsStreams, + // Always capture Worker stdio. In ignore mode we drain it below so it + // cannot inherit the CLI's streams or stall on a full pipe. + stdout: true, + stderr: true, + }) + + if (wantsStreams && worker.stdin) { + if (pipeInput !== undefined) { + const input = + typeof pipeInput === 'string' || Buffer.isBuffer(pipeInput) + ? Readable.from([pipeInput]) + : Symbol.asyncIterator in Object(pipeInput) + ? Readable.from(pipeInput as AsyncIterable) + : (pipeInput as NodeJS.ReadableStream) + input.pipe(worker.stdin) + } else if (stdio === 'inherit') { + process.stdin.pipe(worker.stdin) + } else { + worker.stdin.end() + } + } + + const outputTarget = (kind: 'stdout' | 'stderr'): Writable | undefined => { + const explicit = options[kind] + if (explicit) { + return explicit + } + if (stdio === 'inherit') { + return kind === 'stdout' ? process.stdout : process.stderr + } + return undefined + } + const stdoutTarget = outputTarget('stdout') + const stderrTarget = outputTarget('stderr') + if (!wantsStreams) { + worker.stdout?.resume() + worker.stderr?.resume() + } + if (wantsStreams && worker.stdout) { + if (stdoutTarget) { + worker.stdout.pipe(stdoutTarget, { end: false }) + } else if (stdio === 'pipe') { + worker.stdout.on('data', chunk => captureStdout.push(Buffer.from(chunk))) + } + } + if (wantsStreams && worker.stderr) { + if (stderrTarget) { + worker.stderr.pipe(stderrTarget, { end: false }) + } else if (stdio === 'pipe') { + worker.stderr.on('data', chunk => captureStderr.push(Buffer.from(chunk))) + } + } + + let cancelled = false + const onAbort = () => { + cancelled = true + void worker.terminate() + } + options.signal?.addEventListener('abort', onAbort, { once: true }) + + try { + const code = await new Promise((resolve, reject) => { + worker.once('error', reject) + worker.once('exit', resolve) + }) + return { + code: cancelled ? null : code, + signal: cancelled ? signalFromAbortReason(options.signal?.reason) : null, + ...(cancelled ? { cancelled: true } : {}), + ...(stdio === 'pipe' + ? { + stdout: Buffer.concat(captureStdout).toString(), + stderr: Buffer.concat(captureStderr).toString(), + } + : {}), + } + } finally { + options.signal?.removeEventListener('abort', onAbort) + if (stdio === 'inherit' && !pipeInput && worker.stdin) { + process.stdin.unpipe(worker.stdin) + } + if (worker.stdout && stdoutTarget) { + worker.stdout.unpipe(stdoutTarget) + } + if (worker.stderr && stderrTarget) { + worker.stderr.unpipe(stderrTarget) + } + } +} diff --git a/src/sea/stage-synp.mts b/src/sea/stage-synp.mts new file mode 100644 index 0000000000..96624f5e0d --- /dev/null +++ b/src/sea/stage-synp.mts @@ -0,0 +1,161 @@ +import { execFileSync } from 'node:child_process' +/* eslint-disable no-await-in-loop -- Package staging shares a mutable VFS tree. */ +import { createHash } from 'node:crypto' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(HERE, '../..') +const CONFIG = path.join(ROOT, '.config/rollup.synp-sea.config.mjs') +const EXPECTED_PACKAGE = 'synp' + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex') +} + +async function listFiles(directory: string): Promise { + const files: string[] = [] + const visit = async (current: string): Promise => { + for (const entry of await fs.readdir(current, { withFileTypes: true })) { + const filename = path.join(current, entry.name) + if (entry.isDirectory()) { + await visit(filename) + } else if (entry.isFile()) { + files.push(filename) + } else { + throw new Error(`Unsupported Synp payload entry: ${filename}`) + } + } + } + await visit(directory) + return files.sort() +} + +async function normalizeDirectoryTimes(directory: string): Promise { + const fixedTime = new Date('1980-01-01T00:00:00.000Z') + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + await normalizeDirectoryTimes(path.join(directory, entry.name)) + } + } + await fs.utimes(directory, fixedTime, fixedTime) +} + +/** Stage the pinned Synp API, its bundled dependency closure, and licenses. */ +export async function stageSynp( + stageRoot: string, + expectedVersion: string, +): Promise<{ + packageName: string + version: string + license: string + integrity: string + fileCount: number + noticePackageCount: number + obligationCount: number + noticeInventorySha256: string + noticeDocuments: Array<{ path: string; sha256: string }> +}> { + const packageRoot = path.join(ROOT, 'node_modules/synp') + const metadata = JSON.parse( + await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8'), + ) as { name?: string; version?: string; license?: string } + if ( + metadata.name !== EXPECTED_PACKAGE || + metadata.version !== expectedVersion || + typeof metadata.license !== 'string' + ) { + throw new Error( + `Installed Synp metadata does not match ${EXPECTED_PACKAGE}@${expectedVersion}`, + ) + } + const outputRoot = path.join(stageRoot, 'tools/synp') + await fs.rm(outputRoot, { recursive: true, force: true }) + await fs.mkdir(outputRoot, { recursive: true }) + execFileSync( + process.execPath, + [path.join(ROOT, 'node_modules/rollup/dist/bin/rollup'), '-c', CONFIG], + { + cwd: ROOT, + env: { + ...process.env, + SEA_SYNP_INPUT: path.join(HERE, 'adapters/synp-worker.mjs'), + SEA_SYNP_OUTPUT: outputRoot, + }, + stdio: 'inherit', + }, + ) + await Promise.all([ + fs.copyFile( + path.join(packageRoot, 'LICENSE.md'), + path.join(outputRoot, 'LICENSE'), + ), + fs.writeFile( + path.join(outputRoot, 'package.json'), + `${JSON.stringify( + { + name: metadata.name, + version: metadata.version, + license: metadata.license, + type: 'commonjs', + main: 'bin/synp.js', + }, + null, + 2, + )}\n`, + ), + ]) + + const workerPath = path.join(outputRoot, 'bin/synp.js') + const workerStat = await fs.stat(workerPath).catch(() => undefined) + if (!workerStat?.isFile() || workerStat.size === 0) { + throw new Error( + 'Rollup did not produce the embedded Synp Worker entrypoint', + ) + } + + const files = await listFiles(outputRoot) + const entries: Array<{ path: string; sha256: string; size: number }> = [] + const fixedTime = new Date('1980-01-01T00:00:00.000Z') + for (const filename of files) { + const bytes = await fs.readFile(filename) + await fs.utimes(filename, fixedTime, fixedTime) + entries.push({ + path: path.relative(stageRoot, filename).split(path.sep).join('/'), + sha256: sha256(bytes), + size: bytes.length, + }) + } + await normalizeDirectoryTimes(outputRoot) + const inventoryPath = path.join(outputRoot, 'THIRD-PARTY-NOTICES.json') + const notice = JSON.parse(await fs.readFile(inventoryPath, 'utf8')) as { + packages?: Array<{ + licenseFiles?: Array<{ path?: unknown; sha256?: unknown }> + noticeFiles?: Array<{ path?: unknown; sha256?: unknown }> + }> + obligations?: unknown[] + } + const noticeDocuments = (notice.packages ?? []) + .flatMap(record => [ + ...(record.licenseFiles ?? []), + ...(record.noticeFiles ?? []), + ]) + .filter( + (record): record is { path: string; sha256: string } => + typeof record.path === 'string' && typeof record.sha256 === 'string', + ) + .map(record => ({ path: record.path, sha256: record.sha256 })) + .sort((left, right) => left.path.localeCompare(right.path)) + return { + packageName: metadata.name, + version: metadata.version, + license: metadata.license, + integrity: sha256(Buffer.from(JSON.stringify(entries))), + fileCount: entries.length, + noticePackageCount: notice.packages?.length ?? 0, + obligationCount: notice.obligations?.length ?? 0, + noticeInventorySha256: await sha256(await fs.readFile(inventoryPath)), + noticeDocuments, + } +} diff --git a/src/sea/stage-uv.mts b/src/sea/stage-uv.mts new file mode 100644 index 0000000000..57e1b9f7d6 --- /dev/null +++ b/src/sea/stage-uv.mts @@ -0,0 +1,548 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +/* eslint-disable no-await-in-loop -- Package staging shares a mutable VFS tree. */ +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { gunzipSync, inflateRawSync } from 'node:zlib' + +import { downloadBytes } from './download-bytes.mts' +import { cachedDownload } from './download-cache.mts' +import pins from './uv-pins.json' with { type: 'json' } + +const UV_VERSION = pins.version +const MAX_ARCHIVE_BYTES = 40 * 1024 * 1024 +const MAX_EXPANDED_BYTES = 128 * 1024 * 1024 + +type Platform = 'darwin' | 'linux' | 'win32' +type Architecture = 'arm64' | 'x64' +type Target = `${Platform}-${Architecture}` + +type UvTargetPin = { + archive: string + sha256: string + executable: string + binary: string +} + +type UvPins = { + package: string + version: string + license: string + repository: string + releaseBaseUrl: string + licenseBaseUrl: string + licenseFiles: Array<{ name: string; sha256: string }> + targets: Record +} + +const uvPins = pins as UvPins + +export type StagedUv = { + packageName: string + version: string + target: Target + license: string + archive: string + sourceUrl: string + declaredArchiveSha256: string + binaryPath: string + binarySha256: string + binarySize: number + licenseFiles: Array<{ path: string; sha256: string; size: number }> + noticeInventoryPath: string + noticeInventorySha256: string + noticeDocuments: Array<{ path: string; sha256: string }> + noticeObligations: string[] +} + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex') +} + +function unsupportedTarget(target: string): Error { + return new Error( + `Bundled uv is unavailable for ${target}; supported targets are ${Object.keys(uvPins.targets).join(', ')}.`, + ) +} + +function targetFor(platform: string, arch: string): Target { + const target = `${platform}-${arch}` + if (!Object.hasOwn(uvPins.targets, target)) { + throw unsupportedTarget(target) + } + return target as Target +} + +function validateTarPath(name: string, expected: string): void { + const normalized = name.replace(/\\/g, '/') + if ( + normalized.startsWith('/') || + /^[a-zA-Z]:/.test(normalized) || + normalized.split('/').some(part => part === '..') + ) { + throw new Error(`uv archive contains an unsafe path: ${name}`) + } + if ( + normalized !== expected && + normalized !== `${expected.slice(0, expected.lastIndexOf('/') + 1)}` && + normalized !== `${expected.slice(0, expected.lastIndexOf('/'))}/uvx` + ) { + throw new Error(`uv archive contains an unexpected entry: ${name}`) + } +} + +function readTarExecutable(archive: Buffer, expected: string): Buffer { + let decompressed: Buffer + try { + decompressed = gunzipSync(archive, { maxOutputLength: MAX_EXPANDED_BYTES }) + } catch (error) { + throw new Error('Pinned uv tarball is not a valid bounded gzip archive', { + cause: error, + }) + } + + let offset = 0 + let executable: Buffer | undefined + while (offset + 512 <= decompressed.length) { + const header = decompressed.subarray(offset, offset + 512) + if (header.every(byte => byte === 0)) { + break + } + + const storedChecksum = Number.parseInt( + header.toString('ascii', 148, 156).trim(), + 8, + ) + let actualChecksum = 0 + for (let i = 0; i < 512; i += 1) { + actualChecksum += i >= 148 && i < 156 ? 32 : header[i]! + } + if (!Number.isFinite(storedChecksum) || storedChecksum !== actualChecksum) { + throw new Error( + 'Pinned uv tarball contains an invalid tar header checksum', + ) + } + + const prefix = header.toString('utf8', 345, 500).replace(/\0.*$/, '') + const base = header.toString('utf8', 0, 100).replace(/\0.*$/, '') + const name = prefix ? `${prefix}/${base}` : base + validateTarPath(name, expected) + const type = String.fromCharCode(header[156] ?? 0) + const size = Number.parseInt(header.toString('ascii', 124, 136).trim(), 8) + if (!Number.isSafeInteger(size) || size < 0 || size > MAX_EXPANDED_BYTES) { + throw new Error(`uv archive entry has an invalid size: ${name}`) + } + const contentStart = offset + 512 + const contentEnd = contentStart + size + if (contentEnd > decompressed.length) { + throw new Error(`uv archive entry is truncated: ${name}`) + } + if (name === expected) { + if (executable || (type !== '0' && type !== '\0')) { + throw new Error( + 'uv archive executable is duplicated or is not a regular file', + ) + } + executable = Buffer.from(decompressed.subarray(contentStart, contentEnd)) + } else if ( + name !== `${expected.slice(0, expected.lastIndexOf('/'))}/uvx` && + type !== '5' + ) { + throw new Error(`uv archive contains an unsupported entry type: ${name}`) + } else if ( + name === `${expected.slice(0, expected.lastIndexOf('/'))}/uvx` && + type !== '0' && + type !== '\0' + ) { + throw new Error(`uv archive uvx entry is not a regular file: ${name}`) + } + offset = contentStart + Math.ceil(size / 512) * 512 + } + if (!executable?.length) { + throw new Error( + `Pinned uv tarball is missing its expected executable ${expected}`, + ) + } + return executable +} + +const crcTable = (() => { + const table = new Uint32Array(256) + for (let i = 0; i < table.length; i += 1) { + let value = i + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1 + } + table[i] = value >>> 0 + } + return table +})() + +function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff + for (const byte of bytes) { + crc = crcTable[(crc ^ byte) & 0xff]! ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function readZipExecutable(archive: Buffer, expected: string): Buffer { + const eocdSignature = 0x06054b50 + let eocdOffset = -1 + const minimumOffset = Math.max(0, archive.length - 65_557) + for (let offset = archive.length - 22; offset >= minimumOffset; offset -= 1) { + if (archive.readUInt32LE(offset) === eocdSignature) { + eocdOffset = offset + break + } + } + if (eocdOffset < 0) { + throw new Error('Pinned uv ZIP has no end-of-directory record') + } + const disk = archive.readUInt16LE(eocdOffset + 4) + const centralDisk = archive.readUInt16LE(eocdOffset + 6) + const diskEntries = archive.readUInt16LE(eocdOffset + 8) + const entries = archive.readUInt16LE(eocdOffset + 10) + const centralSize = archive.readUInt32LE(eocdOffset + 12) + const centralOffset = archive.readUInt32LE(eocdOffset + 16) + const commentLength = archive.readUInt16LE(eocdOffset + 20) + if ( + disk !== 0 || + centralDisk !== 0 || + diskEntries !== entries || + entries === 0xffff || + centralSize === 0xffffffff || + centralOffset === 0xffffffff || + eocdOffset + 22 + commentLength !== archive.length || + centralOffset + centralSize > eocdOffset + ) { + throw new Error('Pinned uv ZIP uses an unsupported or malformed ZIP layout') + } + + let cursor = centralOffset + let executable: Buffer | undefined + for (let index = 0; index < entries; index += 1) { + if ( + cursor + 46 > archive.length || + archive.readUInt32LE(cursor) !== 0x02014b50 + ) { + throw new Error('Pinned uv ZIP has a malformed central directory') + } + const flags = archive.readUInt16LE(cursor + 8) + const method = archive.readUInt16LE(cursor + 10) + const expectedCrc = archive.readUInt32LE(cursor + 16) + const compressedSize = archive.readUInt32LE(cursor + 20) + const uncompressedSize = archive.readUInt32LE(cursor + 24) + const nameLength = archive.readUInt16LE(cursor + 28) + const extraLength = archive.readUInt16LE(cursor + 30) + const entryCommentLength = archive.readUInt16LE(cursor + 32) + const diskStart = archive.readUInt16LE(cursor + 34) + const externalAttributes = archive.readUInt32LE(cursor + 38) + const localOffset = archive.readUInt32LE(cursor + 42) + const nameStart = cursor + 46 + const name = archive.toString('utf8', nameStart, nameStart + nameLength) + if ( + !name || + name.startsWith('/') || + /^[a-zA-Z]:/.test(name) || + name + .replace(/\\/g, '/') + .split('/') + .some(part => part === '..') + ) { + throw new Error(`uv ZIP contains an unsafe path: ${name}`) + } + if (name !== expected && name !== 'uvw.exe' && name !== 'uvx.exe') { + throw new Error(`uv ZIP contains an unexpected entry: ${name}`) + } + if ( + (flags & 1) !== 0 || + (method !== 0 && method !== 8) || + diskStart !== 0 || + compressedSize === 0xffffffff || + uncompressedSize === 0xffffffff || + uncompressedSize > MAX_EXPANDED_BYTES + ) { + throw new Error(`uv ZIP entry uses an unsupported encoding: ${name}`) + } + const unixMode = externalAttributes >>> 16 + if ((unixMode & 0xf000) === 0xa000) { + throw new Error(`uv ZIP contains a symbolic link: ${name}`) + } + if ((unixMode & 0xf000) !== 0 && (unixMode & 0xf000) !== 0x8000) { + throw new Error(`uv ZIP contains a non-regular file: ${name}`) + } + if (name === expected) { + if (executable) { + throw new Error('uv ZIP executable is duplicated') + } + if ( + localOffset + 30 > archive.length || + archive.readUInt32LE(localOffset) !== 0x04034b50 + ) { + throw new Error('uv ZIP executable has a malformed local header') + } + const localNameLength = archive.readUInt16LE(localOffset + 26) + const localExtraLength = archive.readUInt16LE(localOffset + 28) + const localFlags = archive.readUInt16LE(localOffset + 6) + const localMethod = archive.readUInt16LE(localOffset + 8) + const localName = archive.toString( + 'utf8', + localOffset + 30, + localOffset + 30 + localNameLength, + ) + if ( + localName !== name || + localFlags !== flags || + localMethod !== method + ) { + throw new Error('uv ZIP local and central entry headers differ') + } + const dataStart = localOffset + 30 + localNameLength + localExtraLength + const dataEnd = dataStart + compressedSize + if (dataEnd > centralOffset) { + throw new Error('uv ZIP executable data is out of bounds') + } + const compressed = archive.subarray(dataStart, dataEnd) + try { + executable = + method === 0 + ? Buffer.from(compressed) + : inflateRawSync(compressed, { + maxOutputLength: MAX_EXPANDED_BYTES, + }) + } catch (error) { + throw new Error('uv ZIP executable could not be decompressed safely', { + cause: error, + }) + } + if ( + executable.length !== uncompressedSize || + crc32(executable) !== expectedCrc + ) { + throw new Error( + 'uv ZIP executable size or CRC does not match its directory record', + ) + } + } + cursor = nameStart + nameLength + extraLength + entryCommentLength + } + if (cursor !== centralOffset + centralSize) { + throw new Error('Pinned uv ZIP central directory size does not match') + } + if (!executable?.length) { + throw new Error(`Pinned uv ZIP is missing ${expected}`) + } + return executable +} + +function validateBinary(bytes: Buffer, target: Target): void { + const [platform, arch] = target.split('-') as [Platform, Architecture] + let valid = false + if ( + platform === 'win32' && + bytes.length >= 64 && + bytes.toString('ascii', 0, 2) === 'MZ' + ) { + const peOffset = bytes.readUInt32LE(0x3c) + valid = + peOffset + 6 <= bytes.length && + bytes.toString('ascii', peOffset, peOffset + 4) === 'PE\0\0' && + ((arch === 'arm64' && bytes.readUInt16LE(peOffset + 4) === 0xaa64) || + (arch === 'x64' && bytes.readUInt16LE(peOffset + 4) === 0x8664)) + } else if ( + platform === 'linux' && + bytes.length >= 20 && + bytes[0] === 0x7f && + bytes.toString('ascii', 1, 4) === 'ELF' + ) { + const machine = bytes.readUInt16LE(18) + valid = + (arch === 'arm64' && machine === 183) || + (arch === 'x64' && machine === 62) + } else if (platform === 'darwin' && bytes.length >= 8) { + const magic = bytes.readUInt32LE(0) + const cpu = bytes.readUInt32LE(4) + valid = + (magic === 0xfeedfacf && + ((arch === 'arm64' && cpu === 0x0100000c) || + (arch === 'x64' && cpu === 0x01000007))) || + (magic === 0xcffaedfe && + ((arch === 'arm64' && bytes.readUInt32BE(4) === 0x0100000c) || + (arch === 'x64' && bytes.readUInt32BE(4) === 0x01000007))) + } + if (!valid) { + throw new Error(`Downloaded uv executable does not match target ${target}`) + } +} + +function validateUvVersion(filename: string): void { + let output: string + try { + output = execFileSync(filename, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 15_000, + windowsHide: true, + env: { ...process.env, UV_NO_UPDATE_CHECK: '1' }, + }) + } catch (error) { + throw new Error( + `Pinned uv ${UV_VERSION} failed its build-time version probe`, + { cause: error }, + ) + } + const [command, version] = output.trim().split(/\s+/) + if (command !== 'uv' || version !== UV_VERSION) { + throw new Error( + `Pinned uv version mismatch: expected ${UV_VERSION}, received ${output.trim()}`, + ) + } +} + +/** + * Fetch and stage the exact upstream uv executable for one native SEA target. + * Downloads happen only during the build. The runtime receives the extracted, + * verified executable and does not acquire uv or Python. + */ +export async function stageUv( + stageRoot: string, + platform: string = process.platform, + arch: string = process.arch, +): Promise { + if (platform !== process.platform || arch !== process.arch) { + throw new Error( + `uv staging requires a native build host (${process.platform}-${process.arch}); requested ${platform}-${arch}.`, + ) + } + const target = targetFor(platform, arch) + const pin = uvPins.targets[target] + const sourceUrl = `${uvPins.releaseBaseUrl}/${pin.archive}` + const archive = await cachedDownload( + sourceUrl, + { + algorithm: 'sha256', + digest: pin.sha256, + maxBytes: MAX_ARCHIVE_BYTES, + }, + () => + downloadBytes(sourceUrl, { + acceptedStatuses: [200], + fetchOptions: { redirect: 'follow' }, + maxBytes: MAX_ARCHIVE_BYTES, + timeoutMs: 120_000, + }), + ) + const actualArchiveSha256 = sha256(archive) + if (actualArchiveSha256 !== pin.sha256) { + throw new Error( + `uv ${UV_VERSION} archive checksum mismatch for ${target}: expected ${pin.sha256}, got ${actualArchiveSha256}`, + ) + } + + const executable = pin.archive.endsWith('.zip') + ? readZipExecutable(archive, pin.executable) + : readTarExecutable(archive, pin.executable) + validateBinary(executable, target) + + const payloadRoot = path.join(stageRoot, 'tools/uv') + await fs.rm(payloadRoot, { recursive: true, force: true }) + await fs.mkdir(payloadRoot, { recursive: true }) + const binaryPath = path.join(payloadRoot, pin.binary) + await fs.writeFile(binaryPath, executable, { + flag: 'wx', + mode: 0o755, + }) + if (platform !== 'win32') { + await fs.chmod(binaryPath, 0o755) + } + validateUvVersion(binaryPath) + const binaryDigest = sha256(executable) + await fs.writeFile(path.join(payloadRoot, 'uv.sha256'), `${binaryDigest}\n`, { + flag: 'wx', + }) + + const licenseFiles: StagedUv['licenseFiles'] = [] + for (const license of uvPins.licenseFiles) { + const sourceUrl = `${uvPins.licenseBaseUrl}/${license.name}` + const bytes = await cachedDownload( + sourceUrl, + { + algorithm: 'sha256', + digest: license.sha256, + maxBytes: 256 * 1024, + }, + () => + downloadBytes(sourceUrl, { + acceptedStatuses: [200], + fetchOptions: { redirect: 'follow' }, + maxBytes: 256 * 1024, + timeoutMs: 120_000, + }), + ) + const digest = sha256(bytes) + if (digest !== license.sha256) { + throw new Error( + `uv ${UV_VERSION} license checksum mismatch for ${license.name}: expected ${license.sha256}, got ${digest}`, + ) + } + const destination = path.join(payloadRoot, license.name) + await fs.writeFile(destination, bytes, { flag: 'wx' }) + licenseFiles.push({ + path: path.relative(stageRoot, destination).split(path.sep).join('/'), + sha256: digest, + size: bytes.length, + }) + } + + // uv is dual-licensed under MIT OR Apache-2.0. Its release archive does not + // ship a separate transitive-crate notice file. Record that limitation as a + // review obligation instead of silently claiming a complete notice set. + const noticeObligations = [ + 'The uv release archive has no generated transitive-crate license inventory; review Cargo dependency license notices before qualifying redistribution.', + ] + const noticeDocuments = licenseFiles.map(({ path, sha256 }) => ({ + path, + sha256, + })) + const noticeInventory = { + schemaVersion: 1, + package: uvPins.package, + version: UV_VERSION, + license: uvPins.license, + source: uvPins.repository, + sourceTag: UV_VERSION, + target, + archive: pin.archive, + archiveUrl: sourceUrl, + archiveSha256: actualArchiveSha256, + licenseDocuments: noticeDocuments, + obligations: noticeObligations, + } + const noticeInventoryPath = path.join(payloadRoot, 'THIRD-PARTY-NOTICES.json') + const noticeInventoryBytes = Buffer.from( + `${JSON.stringify(noticeInventory, null, 2)}\n`, + ) + await fs.writeFile(noticeInventoryPath, noticeInventoryBytes, { flag: 'wx' }) + + return { + packageName: uvPins.package, + version: UV_VERSION, + target, + license: uvPins.license, + archive: pin.archive, + sourceUrl, + declaredArchiveSha256: pin.sha256, + binaryPath: path.relative(stageRoot, binaryPath).split(path.sep).join('/'), + binarySha256: binaryDigest, + binarySize: executable.length, + licenseFiles, + noticeInventoryPath: path + .relative(stageRoot, noticeInventoryPath) + .split(path.sep) + .join('/'), + noticeInventorySha256: sha256(noticeInventoryBytes), + noticeDocuments, + noticeObligations, + } +} diff --git a/src/sea/uv-pins.json b/src/sea/uv-pins.json new file mode 100644 index 0000000000..eb7ae82473 --- /dev/null +++ b/src/sea/uv-pins.json @@ -0,0 +1,56 @@ +{ + "package": "uv", + "version": "0.12.15", + "license": "MIT OR Apache-2.0", + "repository": "https://github.com/astral-sh/uv", + "releaseBaseUrl": "https://github.com/astral-sh/uv/releases/download/0.12.15", + "licenseBaseUrl": "https://raw.githubusercontent.com/astral-sh/uv/0.12.15", + "licenseFiles": [ + { + "name": "LICENSE-MIT", + "sha256": "860e3d7a86b84e6a7012c7a635fc64df475cebc6cce34dfeb73a5982ec58176c" + }, + { + "name": "LICENSE-APACHE", + "sha256": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" + } + ], + "targets": { + "darwin-arm64": { + "archive": "uv-aarch64-apple-darwin.tar.gz", + "sha256": "dc304b9ed1b24174572290fba60ac3f6fe63c73a671f0439e62a91375841964d", + "executable": "uv-aarch64-apple-darwin/uv", + "binary": "uv" + }, + "darwin-x64": { + "archive": "uv-x86_64-apple-darwin.tar.gz", + "sha256": "e9ca61775532368fe518ab03e7a354c7ecab8ccb3c7d941c775fcc4a362b801b", + "executable": "uv-x86_64-apple-darwin/uv", + "binary": "uv" + }, + "linux-arm64": { + "archive": "uv-aarch64-unknown-linux-gnu.tar.gz", + "sha256": "0e9a3499b0587d449c9ff684c0160da607826e4af1cee220bc87f378702d3e08", + "executable": "uv-aarch64-unknown-linux-gnu/uv", + "binary": "uv" + }, + "linux-x64": { + "archive": "uv-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "f97935763c04be3e692460a7aaeaaab8fc3b78fcf8b389da820b38ae7423a638", + "executable": "uv-x86_64-unknown-linux-gnu/uv", + "binary": "uv" + }, + "win32-arm64": { + "archive": "uv-aarch64-pc-windows-msvc.zip", + "sha256": "a37c8e96cb1260488c8510b64c848533a3a82a2fdf9e905de7c2700ceebf6437", + "executable": "uv.exe", + "binary": "uv.exe" + }, + "win32-x64": { + "archive": "uv-x86_64-pc-windows-msvc.zip", + "sha256": "477bd99a84e34891f2bd4c9152ddeb74e971accccbc59c0f0301f11f08a32d46", + "executable": "uv.exe", + "binary": "uv.exe" + } + } +} diff --git a/src/sea/version.mts b/src/sea/version.mts new file mode 100644 index 0000000000..54f67af1e2 --- /dev/null +++ b/src/sea/version.mts @@ -0,0 +1,158 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export type ReleasePolicy = + | { mode: 'stable-from-hint'; distTag: string } + | { + mode: 'prerelease-only' + versionHint: string + identifier: string + distTag: string + } + +type PrereleaseReleasePolicy = Extract< + ReleasePolicy, + { mode: 'prerelease-only' } +> + +const STABLE_VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u +const DIST_TAG = /^[A-Za-z][A-Za-z0-9._-]*$/u + +const REPOSITORY_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +) + +export function parseReleasePolicy(value: unknown): ReleasePolicy { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Release policy must be an object.') + } + const policy = value as Record + if ( + typeof policy['distTag'] !== 'string' || + !DIST_TAG.test(policy['distTag']) + ) { + throw new Error('Release policy must define a valid distTag.') + } + if (policy['mode'] === 'stable-from-hint') { + return { mode: 'stable-from-hint', distTag: policy['distTag'] } + } + if ( + policy['mode'] === 'prerelease-only' && + typeof policy['versionHint'] === 'string' && + STABLE_VERSION.test(policy['versionHint']) && + typeof policy['identifier'] === 'string' && + /^[A-Za-z0-9-]+$/u.test(policy['identifier']) + ) { + const expectedDistTag = `${policy['identifier']}-${policy['versionHint']}` + if (policy['distTag'] !== expectedDistTag) { + throw new Error( + `Prerelease-only release policy must use the ${expectedDistTag} dist-tag.`, + ) + } + return { + mode: 'prerelease-only', + versionHint: policy['versionHint'], + identifier: policy['identifier'], + distTag: policy['distTag'], + } + } + throw new Error( + 'Release policy mode must be stable-from-hint or prerelease-only with a stable versionHint and identifier.', + ) +} + +export function matchesReleasePolicy( + version: string, + policy: ReleasePolicy, +): boolean { + if (policy.mode === 'stable-from-hint') { + return STABLE_VERSION.test(version) + } + const prefix = `${policy.versionHint}-${policy.identifier}.` + return ( + version.startsWith(prefix) && + /^(?:0|[1-9]\d*)$/u.test(version.slice(prefix.length)) + ) +} + +/** + * Use the release-preparation version when building the configured SEA line. + * Feature builds on the older release line use rc.0 until a bump prepares a + * specific prerelease version in package.json. + */ +export function seaVersionForManifestVersion( + version: string, + policy: PrereleaseReleasePolicy, +): string { + const configuredMajor = policy.versionHint.split('.')[0] + const manifestMajor = version.split('.')[0] + if (manifestMajor === configuredMajor) { + if (!matchesReleasePolicy(version, policy)) { + throw new Error( + `Package version ${version} does not match the configured SEA prerelease policy.`, + ) + } + return version + } + return `${policy.versionHint}-${policy.identifier}.0` +} + +export function releasePolicyPath(root = REPOSITORY_ROOT): string { + const candidates = [ + path.join(root, '.config/release.json'), + path.join(root, 'release.json'), + path.join(root, '.release.json'), + ] + const found = candidates.filter(existsSync) + if (found.length > 1) { + throw new Error( + `Found multiple release configurations: ${found.map(filename => path.relative(root, filename)).join(', ')}. Keep only .config/release.json.`, + ) + } + if (found.length === 0) { + throw new Error( + `Release configuration not found. Looked for ${candidates.map(filename => path.relative(root, filename)).join(', ')}.`, + ) + } + return found[0]! +} + +export function readReleasePolicy(root = REPOSITORY_ROOT): ReleasePolicy { + return parseReleasePolicy( + JSON.parse(readFileSync(releasePolicyPath(root), 'utf8')) as unknown, + ) +} + +/** SEA builds require an explicit prerelease-only release policy. */ +export function readSeaReleaseConfig( + root = REPOSITORY_ROOT, +): PrereleaseReleasePolicy { + const policy = readReleasePolicy(root) + if (policy.mode !== 'prerelease-only') { + throw new Error('Node SEA builds require a prerelease-only release policy.') + } + return policy +} + +/** SEA prereleases use the final release version as their stable hint. */ +export const SEA_RELEASE_POLICY = readSeaReleaseConfig() + +const MANIFEST_VERSION = ( + JSON.parse( + readFileSync(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8'), + ) as { + version?: unknown + } +).version + +if (typeof MANIFEST_VERSION !== 'string') { + throw new Error('Root package.json must define a version for SEA builds.') +} + +/** Version reported by the standalone SEA build and its npm prerelease. */ +export const SEA_VERSION = seaVersionForManifestVersion( + MANIFEST_VERSION, + SEA_RELEASE_POLICY, +) diff --git a/src/utils/agent.mts b/src/utils/agent.mts index 5569b4480e..ca29e38eb4 100644 --- a/src/utils/agent.mts +++ b/src/utils/agent.mts @@ -1,92 +1,66 @@ -/** - * Package manager agent utilities for Socket CLI. - * Manages package installation via different package managers. - * - * Key Functions: - * - runAgentInstall: Execute package installation with detected agent - * - * Supported Agents: - * - npm: Node Package Manager - * - pnpm: Fast, disk space efficient package manager - * - yarn: Alternative package manager - * - * Features: - * - Automatic agent detection - * - Shadow installation for security scanning - * - Spinner support for progress indication - */ +/** Run detected package manager install commands with Socket's safe defaults. */ -import { getOwn } from '@socketsecurity/registry/lib/objects' import { spawn } from '@socketsecurity/registry/lib/spawn' -import { Spinner } from '@socketsecurity/registry/lib/spinner' import constants, { NPM, PNPM } from '../constants.mts' -import { cmdFlagsToString } from './cmd.mts' +import { isStandaloneSea } from '../sea/runtime.mts' import { shadowNpmInstall } from '../shadow/npm/install.mts' import type { EnvDetails } from './package-environment.mts' +import type { + SpawnExtra, + SpawnOptions, + SpawnResult, +} from '@socketsecurity/registry/lib/spawn' -type SpawnOption = Exclude[2], undefined> - -export type AgentInstallOptions = SpawnOption & { - args?: string[] | readonly string[] | undefined - spinner?: Spinner | undefined -} - -export type AgentSpawnResult = ReturnType - +/** Run the detected package manager's install command with Socket's safe defaults. */ export function runAgentInstall( pkgEnvDetails: EnvDetails, - options?: AgentInstallOptions | undefined, -): AgentSpawnResult { + options: SpawnOptions & { args?: string[] | readonly string[] } = {}, +): SpawnResult { const { agent, agentExecPath, pkgPath } = pkgEnvDetails + const { args = [], spinner, ...spawnOptions } = options const isNpm = agent === NPM - const isPnpm = agent === PNPM - // All package managers support the "install" command. - if (isNpm) { + if (isNpm && !isStandaloneSea()) { return shadowNpmInstall({ agentExecPath, cwd: pkgPath, - ...options, + args, + spinner, + ...spawnOptions, }) } - const { - args = [], - spinner, - ...spawnOpts - } = { __proto__: null, ...options } as AgentInstallOptions + const isPnpm = agent === PNPM const skipNodeHardenFlags = isPnpm && pkgEnvDetails.agentVersion.major < 11 - // In CI mode, pnpm uses --frozen-lockfile by default, which prevents lockfile updates. - // We need to explicitly disable it when updating the lockfile with overrides. - // Also add --config.confirmModulesPurge=false to avoid interactive prompts. - const installArgs = isPnpm - ? [ - 'install', - '--config.confirmModulesPurge=false', - '--no-frozen-lockfile', - ...args, - ] - : ['install', ...args] - + const installArgs = isNpm + ? ['install', '--no-audit', '--no-fund', ...args] + : isPnpm + ? [ + 'install', + '--config.confirmModulesPurge=false', + '--no-frozen-lockfile', + ...args, + ] + : ['install', ...args] + const nodeOptions = [ + process.env['NODE_OPTIONS'], + ...(skipNodeHardenFlags ? [] : constants.nodeHardenFlags), + ...constants.nodeNoWarningsFlags, + '--disable-sigusr1', + ] + .filter(Boolean) + .join(' ') return spawn(agentExecPath, installArgs, { cwd: pkgPath, - // On Windows, package managers are often .cmd files that require shell execution. - // The spawn function from @socketsecurity/registry will handle this properly - // when shell is true. shell: constants.WIN32, spinner, stdio: 'inherit', - ...spawnOpts, + ...spawnOptions, env: { ...process.env, - ...constants.processEnv, - // Set CI for pnpm to ensure non-interactive mode and consistent behavior. ...(isPnpm ? { CI: '1' } : {}), - NODE_OPTIONS: cmdFlagsToString([ - ...(skipNodeHardenFlags ? [] : constants.nodeHardenFlags), - ...constants.nodeNoWarningsFlags, - ]), - ...getOwn(spawnOpts, 'env'), + NODE_OPTIONS: nodeOptions, + ...spawnOptions.env, }, }) } diff --git a/src/utils/coana-release.mts b/src/utils/coana-release.mts index ad860710d3..773f16d94d 100644 --- a/src/utils/coana-release.mts +++ b/src/utils/coana-release.mts @@ -37,6 +37,7 @@ import { getDefaultProxyUrl, resolveDefaultApiToken, } from './sdk.mts' +import { isStandaloneSea } from '../sea/runtime.mts' import type { CResult } from '../types.mts' import type { SpawnOptions } from '@socketsecurity/registry/lib/spawn' @@ -409,6 +410,30 @@ export async function spawnCoana( ...options, } as CoanaSpawnOptions + const localPath = process.env['SOCKET_CLI_COANA_LOCAL_PATH'] + const standaloneSea = isStandaloneSea() + if (standaloneSea && !localPath) { + return { + ok: false, + message: 'Coana is unavailable in the standalone Socket build', + cause: + 'Set SOCKET_CLI_COANA_LOCAL_PATH to a native Coana executable to run Coana from a standalone build.', + } + } + if ( + standaloneSea && + localPath && + (localPath.endsWith('.js') || localPath.endsWith('.mjs')) + ) { + return { + ok: false, + message: + 'Cannot launch a JavaScript Coana CLI from the standalone Socket build', + cause: + 'SOCKET_CLI_COANA_LOCAL_PATH must name a native Coana executable; the standalone JavaScript Worker adapter is not configured.', + } + } + const mixinsEnv: Record = { SOCKET_CLI_VERSION: constants.ENV.INLINED_SOCKET_CLI_VERSION, // Forwarded to the Coana CLI so it can append our product token to its @@ -448,7 +473,7 @@ export async function spawnCoana( ...spawnEnv, }) - let scriptPath = process.env['SOCKET_CLI_COANA_LOCAL_PATH'] + let scriptPath = localPath if (!scriptPath) { const coanaDirCResult = await getCoanaCliDir( coanaVersion || COANA_CLI_VERSION, diff --git a/src/utils/dlx-binary.mts b/src/utils/dlx-binary.mts deleted file mode 100644 index 9b55fc9fa2..0000000000 --- a/src/utils/dlx-binary.mts +++ /dev/null @@ -1,409 +0,0 @@ -/** - * DLX binary execution utilities for Socket CLI. - * Downloads and executes arbitrary binaries from URLs with caching. - * - * Key Functions: - * - cleanDlxCache: Clean expired binary cache entries - * - dlxBinary: Download and execute binary from URL with caching - * - getDlxCachePath: Get the cache directory path for binaries - * - getSocketHomePath: Get the base .socket directory path - * - listDlxCache: Get information about cached binaries - * - * Cache Management: - * - Stores binaries in ~/.socket/cache/dlx-bin (POSIX) - * - Stores binaries in %USERPROFILE%\.socket\cache\dlx-bin (Windows) - * - Uses content-addressed storage with SHA256 hashes - * - Supports TTL-based cache expiration - * - Verifies checksums for security - * - * Platform Support: - * - Handles Windows, macOS, and Linux - * - Manages executable permissions automatically - * - Supports architecture-specific binary selection - */ - -import { createHash } from 'node:crypto' -import { existsSync, promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { readJson } from '@socketsecurity/registry/lib/fs' -import { spawn } from '@socketsecurity/registry/lib/spawn' - -import constants from '../constants.mts' -import { apiFetch } from './api.mts' -import { InputError } from './errors.mts' - -import type { - SpawnExtra, - SpawnOptions, -} from '@socketsecurity/registry/lib/spawn' - -export interface DlxBinaryOptions { - /** URL to download the binary from. */ - url: string - /** Optional name for the cached binary (defaults to URL hash). */ - name?: string - /** Expected checksum (sha256) for verification. */ - checksum?: string - /** Cache TTL in milliseconds (default: 7 days). */ - cacheTtl?: number - /** Force re-download even if cached. */ - force?: boolean - /** Platform override (defaults to current platform). */ - platform?: NodeJS.Platform - /** Architecture override (defaults to current arch). */ - arch?: string - /** Additional spawn options. */ - spawnOptions?: SpawnOptions -} - -export interface DlxBinaryResult { - /** Path to the cached binary. */ - binaryPath: string - /** Whether the binary was newly downloaded. */ - downloaded: boolean - /** The spawn promise for the running process. */ - spawnPromise: ReturnType['promise'] -} - -const { DLX_BINARY_CACHE_TTL } = constants - -/** - * Generate a cache directory name from URL, similar to pnpm/npx. - * Uses SHA256 hash to create content-addressed storage. - */ -function generateCacheKey(url: string): string { - return createHash('sha256').update(url).digest('hex') -} - -/** - * Get metadata file path for a cached binary. - */ -function getMetadataPath(cacheEntryPath: string): string { - return path.join(cacheEntryPath, '.dlx-metadata.json') -} - -/** - * Check if a cached binary is still valid. - */ -async function isCacheValid( - cacheEntryPath: string, - cacheTtl: number, -): Promise { - try { - const metaPath = getMetadataPath(cacheEntryPath) - if (!existsSync(metaPath)) { - return false - } - - const metadata = await readJson(metaPath, { throws: false }) - if (!metadata) { - return false - } - const now = Date.now() - const age = now - (metadata['timestamp'] as number) - - return age < cacheTtl - } catch { - return false - } -} - -/** - * Download a file from a URL with integrity checking. - */ -async function downloadBinary( - url: string, - destPath: string, - checksum?: string, -): Promise { - const response = await apiFetch(url) - if (!response.ok) { - throw new InputError( - `Failed to download binary: ${response.status} ${response.statusText}`, - ) - } - - // Create a temporary file first. - const tempPath = `${destPath}.download` - const hasher = createHash('sha256') - - try { - // Ensure directory exists. - await fs.mkdir(path.dirname(destPath), { recursive: true }) - - // Get the response as a buffer and compute hash. - const arrayBuffer = await response.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - - // Compute hash. - hasher.update(buffer) - const actualChecksum = hasher.digest('hex') - - // Verify checksum if provided. - if (checksum && actualChecksum !== checksum) { - throw new InputError( - `Checksum mismatch: expected ${checksum}, got ${actualChecksum}`, - ) - } - - // Write to temp file. - await fs.writeFile(tempPath, buffer) - - // Make executable on POSIX systems. - if (os.platform() !== 'win32') { - await fs.chmod(tempPath, 0o755) - } - - // Move temp file to final location. - await fs.rename(tempPath, destPath) - - return actualChecksum - } catch (error) { - // Clean up temp file on error. - try { - await fs.unlink(tempPath) - } catch { - // Ignore cleanup errors. - } - throw error - } -} - -/** - * Write metadata for a cached binary. - */ -async function writeMetadata( - cacheEntryPath: string, - url: string, - checksum: string, -): Promise { - const metaPath = getMetadataPath(cacheEntryPath) - const metadata = { - url, - checksum, - timestamp: Date.now(), - platform: os.platform(), - arch: os.arch(), - version: '1.0.0', - } - await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2)) -} - -/** - * Clean expired entries from the DLX cache. - */ -export async function cleanDlxCache( - maxAge: number = DLX_BINARY_CACHE_TTL, -): Promise { - const cacheDir = getDlxCachePath() - - if (!existsSync(cacheDir)) { - return 0 - } - - let cleaned = 0 - const now = Date.now() - const entries = await fs.readdir(cacheDir) - - for (const entry of entries) { - const entryPath = path.join(cacheDir, entry) - const metaPath = getMetadataPath(entryPath) - - try { - // eslint-disable-next-line no-await-in-loop - const stats = await fs.stat(entryPath) - if (!stats.isDirectory()) { - continue - } - - // eslint-disable-next-line no-await-in-loop - const metadata = await readJson(metaPath, { throws: false }) - if (!metadata) { - continue - } - const age = now - (metadata['timestamp'] as number) - - if (age > maxAge) { - // Remove entire cache entry directory. - // eslint-disable-next-line no-await-in-loop - await fs.rm(entryPath, { recursive: true, force: true }) - cleaned += 1 - } - } catch { - // If we can't read metadata, check if directory is empty or corrupted. - try { - // eslint-disable-next-line no-await-in-loop - const contents = await fs.readdir(entryPath) - if (!contents.length) { - // Remove empty directory. - // eslint-disable-next-line no-await-in-loop - await fs.rmdir(entryPath) - cleaned += 1 - } - } catch {} - } - } - - return cleaned -} - -/** - * Download and execute a binary from a URL with caching. - */ -export async function dlxBinary( - args: string[] | readonly string[], - options?: DlxBinaryOptions | undefined, - spawnExtra?: SpawnExtra | undefined, -): Promise { - const { - arch = os.arch(), - cacheTtl = DLX_BINARY_CACHE_TTL, - checksum, - force = false, - name, - platform = os.platform(), - spawnOptions, - url, - } = { __proto__: null, ...options } as DlxBinaryOptions - - // Generate cache paths similar to pnpm/npx structure. - const cacheDir = getDlxCachePath() - const cacheKey = generateCacheKey(url) - const cacheEntryDir = path.join(cacheDir, cacheKey) - const platformKey = `${platform}-${arch}` - const binaryName = - name || `binary-${platformKey}${platform === 'win32' ? '.exe' : ''}` - const binaryPath = path.join(cacheEntryDir, binaryName) - - let downloaded = false - let computedChecksum = checksum - - // Check if we need to download. - if ( - !force && - existsSync(cacheEntryDir) && - (await isCacheValid(cacheEntryDir, cacheTtl)) - ) { - // Binary is cached and valid, read the checksum from metadata. - try { - const metaPath = getMetadataPath(cacheEntryDir) - const metadata = await readJson(metaPath, { throws: false }) - if (metadata && typeof metadata['checksum'] === 'string') { - computedChecksum = metadata['checksum'] - } else { - // If metadata is invalid, re-download. - downloaded = true - } - } catch { - // If we can't read metadata, re-download. - downloaded = true - } - } else { - downloaded = true - } - - if (downloaded) { - // Ensure cache directory exists. - await fs.mkdir(cacheEntryDir, { recursive: true }) - - // Download the binary. - computedChecksum = await downloadBinary(url, binaryPath, checksum) - await writeMetadata(cacheEntryDir, url, computedChecksum || '') - } - - // Execute the binary. - const spawnPromise = spawn(binaryPath, args, spawnOptions, spawnExtra) - - return { - binaryPath, - downloaded, - spawnPromise, - } -} - -/** - * Get the DLX binary cache directory path. - */ -export function getDlxCachePath(): string { - return path.join(getSocketHomePath(), 'cache', 'dlx') -} - -/** - * Get the base .socket directory path. - * Uses %USERPROFILE% on Windows, $HOME on POSIX systems. - */ -export function getSocketHomePath(): string { - const homedir = os.homedir() - if (!homedir) { - throw new InputError('Unable to determine home directory') - } - return path.join(homedir, '.socket') -} - -/** - * Get information about cached binaries. - */ -export async function listDlxCache(): Promise< - Array<{ - name: string - url: string - size: number - age: number - platform: string - arch: string - checksum: string - }> -> { - const cacheDir = getDlxCachePath() - - if (!existsSync(cacheDir)) { - return [] - } - - const results = [] - const now = Date.now() - const entries = await fs.readdir(cacheDir) - - for (const entry of entries) { - const entryPath = path.join(cacheDir, entry) - try { - // eslint-disable-next-line no-await-in-loop - const stats = await fs.stat(entryPath) - if (!stats.isDirectory()) { - continue - } - - const metaPath = getMetadataPath(entryPath) - // eslint-disable-next-line no-await-in-loop - const metadata = await readJson(metaPath, { throws: false }) - if (!metadata) { - continue - } - - // Find the binary file in the directory. - // eslint-disable-next-line no-await-in-loop - const files = await fs.readdir(entryPath) - const binaryFile = files.find(f => !f.startsWith('.')) - - if (binaryFile) { - const binaryPath = path.join(entryPath, binaryFile) - // eslint-disable-next-line no-await-in-loop - const binaryStats = await fs.stat(binaryPath) - - results.push({ - name: binaryFile, - url: metadata['url'] as string, - size: binaryStats.size, - age: now - (metadata['timestamp'] as number), - platform: (metadata['platform'] as string) || 'unknown', - arch: (metadata['arch'] as string) || 'unknown', - checksum: (metadata['checksum'] as string) || '', - }) - } - } catch {} - } - - return results -} diff --git a/src/utils/dlx-binary.test.mts b/src/utils/dlx-binary.test.mts deleted file mode 100644 index 37f9a52a46..0000000000 --- a/src/utils/dlx-binary.test.mts +++ /dev/null @@ -1,34 +0,0 @@ -import os from 'node:os' -import path from 'node:path' - -import { describe, expect, it, vi } from 'vitest' - -import { getDlxCachePath, getSocketHomePath } from './dlx-binary.mts' -import { InputError } from './errors.mts' - -describe('dlx-binary simple tests', () => { - describe('getSocketHomePath', () => { - it('should return correct path', () => { - const result = getSocketHomePath() - expect(result).toBe(path.join(os.homedir(), '.socket')) - }) - - it('should throw error when home directory cannot be determined', () => { - const originalHomedir = os.homedir - os.homedir = vi.fn(() => '') - - expect(() => getSocketHomePath()).toThrow( - new InputError('Unable to determine home directory'), - ) - - os.homedir = originalHomedir - }) - }) - - describe('getDlxCachePath', () => { - it('should return correct cache path', () => { - const result = getDlxCachePath() - expect(result).toBe(path.join(os.homedir(), '.socket', 'cache', 'dlx')) - }) - }) -}) diff --git a/src/utils/dlx.mts b/src/utils/dlx.mts index 2c84b34f2f..310fb6324a 100644 --- a/src/utils/dlx.mts +++ b/src/utils/dlx.mts @@ -30,6 +30,7 @@ import constants, { } from '../constants.mts' import { findUp } from './fs.mts' import { isYarnBerry } from './yarn-version.mts' +import { isStandaloneSea } from '../sea/runtime.mts' import type { ShadowBinOptions, ShadowBinResult } from '../shadow/npm-base.mts' import type { SpawnExtra } from '@socketsecurity/registry/lib/spawn' @@ -58,6 +59,15 @@ export async function spawnDlx( options?: DlxOptions | undefined, spawnExtra?: SpawnExtra | undefined, ): Promise { + // Standalone builds must never turn a missing embedded helper into a + // package-manager install. Keep this guard ahead of lockfile detection and + // shadow-bin loading so every generic dlx caller fails closed consistently. + if (isStandaloneSea()) { + throw new Error( + `Cannot run '${packageSpec.name}' from a standalone Socket CLI: this helper is not bundled for this target, and runtime package installation is disabled.`, + ) + } + const { agent, silent = false, ...shadowOptions } = options ?? {} let finalShadowOptions = shadowOptions diff --git a/src/utils/glob.mts b/src/utils/glob.mts index c4be9b93fc..c01dd31c04 100644 --- a/src/utils/glob.mts +++ b/src/utils/glob.mts @@ -104,6 +104,19 @@ function ignoreFileToGlobPatterns( return ignoreFileLinesToGlobPatterns(content.split(/\r?\n/), filepath, cwd) } +function escapeMinimatchGroupStarts(pattern: string): string { + let escaped = '' + let previousWasOddBackslash = false + for (const char of pattern) { + if ((char === '{' || char === '(') && !previousWasOddBackslash) { + escaped += '\\' + } + escaped += char + previousWasOddBackslash = char === '\\' ? !previousWasOddBackslash : false + } + return escaped +} + // Based on `@eslint/compat` convertIgnorePatternToMinimatch. // Apache v2.0 licensed // Copyright Nicholas C. Zakas @@ -135,11 +148,9 @@ function ignorePatternToMinimatch(pattern: string): string { // For example, gitignore pattern `src/{a,b}.js` ignores file `src/{a,b}.js`. // But, the same minimatch pattern `src/{a,b}.js` ignores files `src/a.js` and `src/b.js`. // Minimatch pattern `src/\{a,b}.js` is equivalent to gitignore pattern `src/{a,b}.js`. - const escapedPatternWithoutLeadingSlash = - patternWithoutLeadingSlash.replaceAll( - /(?=((?:\\.|[^{(])*))\1([{(])/guy, - '$1\\$2', - ) + const escapedPatternWithoutLeadingSlash = escapeMinimatchGroupStarts( + patternWithoutLeadingSlash, + ) const matchInsideSuffix = patternToTest.endsWith('/**') ? '/*' : '' return `${negatedPrefix}${matchEverywherePrefix}${escapedPatternWithoutLeadingSlash}${matchInsideSuffix}` } diff --git a/src/utils/glob.test.mts b/src/utils/glob.test.mts index 8967093e5d..e86abceceb 100644 --- a/src/utils/glob.test.mts +++ b/src/utils/glob.test.mts @@ -1,30 +1,23 @@ import { chmodSync, - existsSync, mkdirSync, mkdtempSync, - readdirSync, rmSync, writeFileSync, } from 'node:fs' -import { tmpdir } from 'node:os' +import os from 'node:os' import path from 'node:path' -import { fileURLToPath } from 'node:url' -import mockFs from 'mock-fs' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { normalizePath } from '@socketsecurity/registry/lib/path' -import { NODE_MODULES } from '../constants.mjs' import { createSupportedFilesFilter, globWithGitIgnore, pathsToGlobPatterns, } from './glob.mts' -import type FileSystem from 'mock-fs/lib/filesystem' - // Filter functions defined at module scope to satisfy linting rules. function filterJsonFiles(filepath: string): boolean { return filepath.endsWith('.json') @@ -34,65 +27,34 @@ function filterTsFiles(filepath: string): boolean { return filepath.endsWith('.ts') } -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const rootNmPath = path.join(__dirname, '../..', NODE_MODULES) -const mockFixturePath = normalizePath(path.join(__dirname, 'glob-mock')) -const mockNmPath = normalizePath(rootNmPath) +let mockFixturePath = '' -// Remove broken symlinks in node_modules before loading to prevent mock-fs errors. -function cleanupBrokenSymlinks(dirPath: string): void { - try { - if (!existsSync(dirPath)) { - return - } - const entries = readdirSync(dirPath, { withFileTypes: true }) - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name) - try { - if (entry.isSymbolicLink() && !existsSync(fullPath)) { - // Symlink exists but target does not, remove it. - rmSync(fullPath, { force: true }) - } else if (entry.isDirectory()) { - // Recursively check subdirectories. - cleanupBrokenSymlinks(fullPath) - } - } catch { - // Ignore errors for individual entries. +function mockTestFs(config: Record): void { + const writeEntry = (filename: string, value: unknown): void => { + if (value && typeof value === 'object' && !Buffer.isBuffer(value)) { + mkdirSync(filename, { recursive: true }) + for (const [name, child] of Object.entries(value)) { + writeEntry(path.join(filename, name), child) } + return } - } catch { - // If we cannot read the directory, skip cleanup. + mkdirSync(path.dirname(filename), { recursive: true }) + writeFileSync(filename, value as string | Uint8Array) } -} - -// Clean up broken symlinks before loading node_modules. -cleanupBrokenSymlinks(rootNmPath) - -// Load node_modules with error handling for any remaining issues. -const mockedNmCallback = (() => { - try { - return mockFs.load(rootNmPath) - } catch (e) { - // If loading fails due to broken symlinks or missing files, return empty mock. - console.warn( - `Warning: Failed to load node_modules for mock-fs: ${e instanceof Error ? e.message : String(e)}`, - ) - return {} + for (const [filename, value] of Object.entries(config)) { + writeEntry(filename, value) } -})() - -function mockTestFs(config: FileSystem.DirectoryItems) { - return mockFs({ - ...config, - [mockNmPath]: mockedNmCallback, - }) } describe('glob utilities', () => { afterEach(() => { - mockFs.restore() + rmSync(mockFixturePath, { recursive: true, force: true }) + }) + + beforeEach(() => { + mockFixturePath = normalizePath( + mkdtempSync(path.join(os.tmpdir(), 'glob-test-')), + ) }) describe('globWithGitIgnore()', () => { @@ -129,6 +91,23 @@ describe('glob utilities', () => { ]) }) + it('treats gitignore braces and parentheses literally', async () => { + mockTestFs({ + [`${mockFixturePath}/.gitignore`]: 'literal{a,b}.txt\nliteral(a).txt', + [`${mockFixturePath}/literal{a,b}.txt`]: '', + [`${mockFixturePath}/literal(a).txt`]: '', + [`${mockFixturePath}/literal-a.txt`]: '', + }) + + const results = await globWithGitIgnore(['**/*.txt'], { + cwd: mockFixturePath, + }) + + expect(results.map(normalizePath).sort()).toEqual([ + `${mockFixturePath}/literal-a.txt`, + ]) + }) + it('should handle negated patterns in .gitignore', async () => { mockTestFs({ [`${mockFixturePath}/.gitignore`]: 'ignored/**\n!ignored/keep.json', @@ -150,7 +129,7 @@ describe('glob utilities', () => { it('should apply filter function during streaming to reduce memory', async () => { // Create a mock filesystem with many files. - const files: FileSystem.DirectoryItems = {} + const files: Record = {} const fileCount = 100 for (let i = 0; i < fileCount; i += 1) { files[`${mockFixturePath}/file${i}.txt`] = 'content' @@ -292,7 +271,7 @@ describe('glob utilities', () => { it.skipIf(skipUnreadableDirTest)( 'skips an unreadable directory instead of throwing EACCES', async () => { - const realTmp = mkdtempSync(path.join(tmpdir(), 'socket-glob-perm-')) + const realTmp = mkdtempSync(path.join(os.tmpdir(), 'socket-glob-perm-')) const unreadable = path.join(realTmp, 'data/postgres/pgdata') try { mkdirSync(unreadable, { recursive: true }) diff --git a/src/utils/is-object-object.d.mts b/src/utils/is-object-object.d.mts new file mode 100644 index 0000000000..c87d3086b6 --- /dev/null +++ b/src/utils/is-object-object.d.mts @@ -0,0 +1 @@ +export function isObjectObject(value: unknown): value is object diff --git a/src/utils/is-object-object.mjs b/src/utils/is-object-object.mjs new file mode 100644 index 0000000000..a710d853f9 --- /dev/null +++ b/src/utils/is-object-object.mjs @@ -0,0 +1,4 @@ +/** Return true for non-null object values that are not arrays. */ +export function isObjectObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/src/utils/meow-with-subcommands.mts b/src/utils/meow-with-subcommands.mts index 930ff6258d..71865ab308 100644 --- a/src/utils/meow-with-subcommands.mts +++ b/src/utils/meow-with-subcommands.mts @@ -3,6 +3,7 @@ import terminalLink from 'terminal-link' import colors from 'yoctocolors-cjs' import { joinAnd } from '@socketsecurity/registry/lib/arrays' +import { isDebug } from '@socketsecurity/registry/lib/debug' import { logger } from '@socketsecurity/registry/lib/logger' import { getOwn, @@ -23,8 +24,8 @@ import { overrideCachedConfig, overrideConfigApiToken, } from './config.mts' -import { isDebug } from './debug.mts' import { getFlagListOutput, getHelpListOutput } from './output-formatting.mts' +import { getVisibleTokenPrefix } from './sdk.mts' import { socketPackageLink } from './terminal-link.mts' import constants, { API_V0_URL, @@ -40,7 +41,6 @@ import constants, { // YARN, } from '../constants.mts' import { commonFlags } from '../flags.mts' -import { getVisibleTokenPrefix } from './sdk.mts' import { tildify } from './tildify.mts' import type { MeowFlag, MeowFlags } from '../flags.mts' @@ -701,6 +701,7 @@ export async function meowWithSubcommands( ` ${colors.italic('Aliases:')} GITHUB_TOKEN`, ' SOCKET_CLI_NO_API_TOKEN Make the default API token `undefined`', ' SOCKET_CLI_NPM_PATH The absolute location of the npm directory', + ' SOCKET_CLI_SOCKET_PATCH_PATH Executable socket-patch path for standalone builds', ' SOCKET_CLI_ORG_SLUG Specify the Socket organization slug', '', ' SOCKET_CLI_ACCEPT_RISKS Accept risks of a Socket wrapped npm/npx run', diff --git a/src/utils/package-environment.mts b/src/utils/package-environment.mts index deb078053c..34ec8a7447 100644 --- a/src/utils/package-environment.mts +++ b/src/utils/package-environment.mts @@ -167,7 +167,19 @@ const readLockFileByAgent: Map = (() => { } } - const binaryReader = wrapReader(readFileBinary) + const binaryReader = wrapReader(async (lockPath: string) => { + // registry 1.1.17 declares Buffer but decodes with `binary`, returning a + // Latin-1 string at runtime. Restore the original byte buffer for Bun's + // binary lockfile parser. + const content: unknown = await readFileBinary(lockPath) + if (Buffer.isBuffer(content)) { + return content + } + if (typeof content === 'string') { + return Buffer.from(content, 'latin1') + } + throw new TypeError('Expected binary lockfile content') + }) const defaultReader = wrapReader( async (lockPath: string) => await readFileUtf8(lockPath), diff --git a/src/utils/path-resolve.test.mts b/src/utils/path-resolve.test.mts index 2bbbb6d9a1..b416a44a1a 100644 --- a/src/utils/path-resolve.test.mts +++ b/src/utils/path-resolve.test.mts @@ -1,14 +1,12 @@ -import { existsSync, readdirSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' import path from 'node:path' -import { fileURLToPath } from 'node:url' -import mockFs from 'mock-fs' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { normalizePath } from '@socketsecurity/registry/lib/path' import { - NODE_MODULES, PACKAGE_JSON, PACKAGE_LOCK_JSON, PNPM_LOCK_YAML, @@ -16,62 +14,23 @@ import { } from '../constants.mjs' import { getPackageFilesForScan } from './path-resolve.mts' -import type FileSystem from 'mock-fs/lib/filesystem' +let mockFixturePath = '' -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const rootNmPath = path.join(__dirname, '../..', NODE_MODULES) -const mockFixturePath = normalizePath(path.join(__dirname, 'mock')) -const mockNmPath = normalizePath(rootNmPath) - -// Remove broken symlinks in node_modules before loading to prevent mock-fs errors. -function cleanupBrokenSymlinks(dirPath: string): void { - try { - if (!existsSync(dirPath)) { - return - } - const entries = readdirSync(dirPath, { withFileTypes: true }) - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name) - try { - if (entry.isSymbolicLink() && !existsSync(fullPath)) { - // Symlink exists but target does not, remove it. - rmSync(fullPath, { force: true }) - } else if (entry.isDirectory()) { - // Recursively check subdirectories. - cleanupBrokenSymlinks(fullPath) - } - } catch { - // Ignore errors for individual entries. +function mockTestFs(config: Record): void { + const writeEntry = (filename: string, value: unknown): void => { + if (value && typeof value === 'object' && !Buffer.isBuffer(value)) { + mkdirSync(filename, { recursive: true }) + for (const [name, child] of Object.entries(value)) { + writeEntry(path.join(filename, name), child) } + return } - } catch { - // If we cannot read the directory, skip cleanup. + mkdirSync(path.dirname(filename), { recursive: true }) + writeFileSync(filename, value as string | Uint8Array) } -} - -// Clean up broken symlinks before loading node_modules. -cleanupBrokenSymlinks(rootNmPath) - -// Load node_modules with error handling for any remaining issues. -const mockedNmCallback = (() => { - try { - return mockFs.load(rootNmPath) - } catch (e) { - // If loading fails due to broken symlinks or missing files, return empty mock. - console.warn( - `Warning: Failed to load node_modules for mock-fs: ${e instanceof Error ? e.message : String(e)}`, - ) - return {} + for (const [filename, value] of Object.entries(config)) { + writeEntry(filename, value) } -})() - -function mockTestFs(config: FileSystem.DirectoryItems) { - return mockFs({ - ...config, - [mockNmPath]: mockedNmCallback, - }) } const globPatterns = { @@ -135,7 +94,13 @@ const sortedGetPackageFilesFullScans = sortedPromise(getPackageFilesForScan) describe('Path Resolve', () => { afterEach(() => { - mockFs.restore() + rmSync(mockFixturePath, { recursive: true, force: true }) + }) + + beforeEach(() => { + mockFixturePath = normalizePath( + mkdtempSync(path.join(os.tmpdir(), 'path-resolve-test-')), + ) }) describe('getPackageFilesForScan()', () => { diff --git a/src/utils/self-update.mts b/src/utils/self-update.mts index e3bf91c7b1..87f041645a 100644 --- a/src/utils/self-update.mts +++ b/src/utils/self-update.mts @@ -1,25 +1,41 @@ import updateNotifier from 'tiny-updater' -import colors from 'yoctocolors-cjs' import { logger } from '@socketsecurity/registry/lib/logger' import constants from '../constants.mts' -import { socketPackageLink } from './terminal-link.mts' +import { isStandaloneSea } from '../sea/runtime.mts' + +/** Format a release notice for the current Socket distribution. */ +export function formatUpdateNotice(current: string, latest: string): string { + return isStandaloneSea() + ? `Socket ${current} → ${latest}. Run: socket update` + : `Socket ${current} → ${latest}. Update with your favorite package manager.` +} // Check the public npm registry for a newer socket release. export async function runSelfUpdateCheck(): Promise { + // Match the conventional opt-out used by update notifiers. + if (process.env['NO_UPDATE_NOTIFIER']) { + return + } + + // A standalone executable cannot be updated through the npm package path. + // Keep its startup independent from registry metadata and avoid suggesting + // that an npm CLI update can replace this artifact. + if (isStandaloneSea()) { + return + } + await updateNotifier({ name: constants.SOCKET_CLI_BIN_NAME, registryUrl: 'https://registry.npmjs.org/', - ttl: 86_400_000 /* 24 hours in milliseconds */, + ttl: + process.env['SOCKET_CLI_FORCE_UPDATE_NOTIFIER'] === '1' + ? 0 + : 86_400_000 /* 24 hours in milliseconds */, version: constants.ENV.INLINED_SOCKET_CLI_VERSION, - logCallback: (name: string, version: string, latest: string) => { - logger.log( - `\n\n📦 Update available for ${colors.cyan(name)}: ${colors.gray(version)} → ${colors.green(latest)}`, - ) - logger.log( - `📝 ${socketPackageLink('npm', name, `files/${latest}/CHANGELOG.md`, 'View changelog')}`, - ) + logCallback: (_name: string, version: string, latest: string) => { + logger.log(formatUpdateNotice(version, latest)) }, }) } diff --git a/src/utils/self-update.test.mts b/src/utils/self-update.test.mts deleted file mode 100644 index 6da97fa8ae..0000000000 --- a/src/utils/self-update.test.mts +++ /dev/null @@ -1,19 +0,0 @@ -import updateNotifier from 'tiny-updater' -import { describe, expect, it, vi } from 'vitest' - -import { runSelfUpdateCheck } from './self-update.mts' - -vi.mock('tiny-updater', () => ({ - default: vi.fn(async () => false), -})) - -describe('runSelfUpdateCheck', () => { - it('checks the public npm registry', async () => { - await runSelfUpdateCheck() - - expect(updateNotifier).toHaveBeenCalledTimes(1) - const options = vi.mocked(updateNotifier).mock.calls[0]![0] - expect(options.registryUrl).toBe('https://registry.npmjs.org/') - expect('authInfo' in options).toBe(false) - }) -}) diff --git a/taze.config.mts b/taze.config.mts index 5c8b1b8a43..b19bd062b6 100644 --- a/taze.config.mts +++ b/taze.config.mts @@ -1,16 +1 @@ -import { defineConfig } from 'taze' - -export default defineConfig({ - // Exclude these packages (migrated from .ncurc.json reject list). - exclude: ['eslint-plugin-unicorn', 'terminal-link', 'yargs-parser'], - // Interactive mode disabled for automation. - interactive: false, - // Use minimal logging similar to ncu loglevel. - loglevel: 'warn', - // Only update packages that have been stable for 7 days. - maturityPeriod: 7, - // Update mode: 'latest' is similar to ncu's default behavior. - mode: 'latest', - // Write to package.json automatically. - write: true, -}) +export { default } from './.config/taze.config.mts' diff --git a/test/dist-runtime-requires.test.mts b/test/dist-runtime-requires.test.mts index 2afeffe87f..94695cad17 100644 --- a/test/dist-runtime-requires.test.mts +++ b/test/dist-runtime-requires.test.mts @@ -18,6 +18,8 @@ const builtinNames = new Set( name.startsWith('node:') ? name.slice('node:'.length) : name, ), ) +// Node 27 adds the SEA-only API, which Node 22 cannot report in builtinModules. +builtinNames.add('sea') function isAstNode(value: unknown): value is AstNode { return ( diff --git a/test/lib/native/cli-harness.mts b/test/lib/native/cli-harness.mts new file mode 100644 index 0000000000..8966d030cb --- /dev/null +++ b/test/lib/native/cli-harness.mts @@ -0,0 +1,154 @@ +import { mkdir, realpath, rm } from 'node:fs/promises' +import path from 'node:path' + +import { createTerminal } from '@termless/core' +import { createXtermBackend } from '@termless/xtermjs' +import { expect, onTestFinished } from 'vitest' + +import { + captureCheckpoint, + createRecording, + expectTerminalAnimation, +} from '../terminal/animation.mts' +import { normalizeBackendOutput } from '../terminal/output.mts' + +export interface NativeTestEnvironment { + readonly binary: string + readonly nodeVersion: string + readonly stage: string +} + +export function readNativeTestEnvironment( + env: NodeJS.ProcessEnv = process.env, +): NativeTestEnvironment { + const binary = env['SOCKET_CLI_NATIVE_TEST_BINARY'] + const nodeVersion = env['SOCKET_CLI_NATIVE_TEST_NODE_VERSION'] + const stage = env['SOCKET_CLI_NATIVE_TEST_STAGE'] + if (!binary || !nodeVersion || !stage) { + throw new Error( + 'Native test harness requires binary, Node version, and stage environment values', + ) + } + return { binary, nodeVersion, stage } +} + +export interface NativeTerminalFixture { + readonly cwd: string + readonly terminal: ReturnType + readonly normalizer: ReturnType +} + +export function nativeFixturePathReplacements(cwd: string, actualCwd: string) { + const socketJsonPaths = [cwd, actualCwd].flatMap(directory => { + const trimmed = directory.replace(/[\\/]+$/, '') + return [ + path.join(directory, 'socket.json'), + `${trimmed}/socket.json`, + `${trimmed}\\socket.json`, + ] + }) + return [ + ...new Set(socketJsonPaths), + cwd, + actualCwd, + cwd.replaceAll('\\', '/'), + actualCwd.replaceAll('\\', '/'), + ].map(search => ({ + search, + replacement: search.endsWith('socket.json') + ? '/socket.json' + : '', + })) +} + +export function nativeSpawnEnvironment( + args: readonly string[], + env: NodeJS.ProcessEnv = {}, +): NodeJS.ProcessEnv { + return { + NO_COLOR: '', + CI: '1', + TERM: 'xterm-256color', + ...(args.includes('--config') ? {} : { SOCKET_CLI_CONFIG: '{}' }), + ...env, + } +} + +export async function createNativeTerminalFixture( + rows = 30, + env: NodeJS.ProcessEnv = process.env, + cols = 100, +): Promise { + const { nodeVersion, stage } = readNativeTestEnvironment(env) + const cwd = path.join(stage, 'f') + if (process.platform === 'win32') { + expect( + `ℹ No socket.json found at ${cwd}, will generate a new one`.length, + ).toBeLessThan(cols) + } + await rm(cwd, { recursive: true, force: true }) + await mkdir(cwd, { recursive: true }) + const backend = createXtermBackend() + const actualCwd = await realpath(cwd) + const normalizer = normalizeBackendOutput(backend, [ + ...nativeFixturePathReplacements(cwd, actualCwd), + { search: `v${nodeVersion}`, replacement: 'v' }, + { search: nodeVersion, replacement: '' }, + { search: 'socket-patch.exe', replacement: 'socket-patch' }, + ]) + const terminal = createTerminal({ cols, rows, backend }) + onTestFinished(async () => { + await terminal.close() + await rm(cwd, { recursive: true, force: true }) + }) + return { cwd, normalizer, terminal } +} + +export async function captureFlow(options: { + flow: string + args: string[] + expectedText: string + exitCode: number + rows?: number + cols?: number + checkpointName?: string + env?: NodeJS.ProcessEnv + prepare?: (fixture: NativeTerminalFixture) => Promise +}): Promise { + const { binary } = readNativeTestEnvironment() + const { + args, + cols = 100, + env = {}, + exitCode, + expectedText, + flow, + rows = 12, + } = options + const fixture = await createNativeTerminalFixture(rows, process.env, cols) + await options.prepare?.(fixture) + await fixture.terminal.spawn([binary, ...args], { + cwd: fixture.cwd, + env: nativeSpawnEnvironment(args, env), + }) + await fixture.terminal.waitFor(expectedText, 30_000) + await fixture.terminal.waitForStable(200, 30_000) + await expect + .poll(() => fixture.terminal.exitInfo, { timeout: 30_000 }) + .toBe(`exit=${exitCode}`) + fixture.normalizer.flush() + expect(fixture.terminal.scrollback.getText().trim()).toBe('') + expect(fixture.terminal.screen.getText()).toContain(expectedText) + const checkpoint = await captureCheckpoint( + fixture.terminal, + options.checkpointName ?? 'help', + ) + await expectTerminalAnimation(createRecording([checkpoint]), { + flow, + snapshotsDir: path.join(process.cwd(), 'test/native-snapshots'), + artifactsDir: + process.env['SOCKET_CLI_NATIVE_TEST_ARTIFACTS'] ?? + path.join(process.cwd(), 'build/native-test-artifacts'), + expectedSequence: [options.checkpointName ?? 'help'], + }) +} diff --git a/test/lib/native/command-inventory.mts b/test/lib/native/command-inventory.mts new file mode 100644 index 0000000000..127523d14a --- /dev/null +++ b/test/lib/native/command-inventory.mts @@ -0,0 +1,201 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +import parser from '@typescript-eslint/parser' + +export type AstNode = { + type: string + [key: string]: unknown +} + +export const REPO_ROOT = path.resolve(import.meta.dirname, '../../..') +export const SOURCE_ROOT = path.join(REPO_ROOT, 'src') + +export const childNodes = (node: AstNode): AstNode[] => + Object.values(node) + .flatMap(value => (Array.isArray(value) ? value : [value])) + .filter( + (value): value is AstNode => + !!value && typeof value === 'object' && 'type' in value, + ) + +export const descendants = (node: AstNode): AstNode[] => { + const children = childNodes(node) + return children.flatMap(child => [child, ...descendants(child)]) +} + +export const literal = (node: AstNode): string => { + if (node.type === 'Identifier') { + return String((node as { name: string }).name) + } + if ( + node.type === 'Literal' && + typeof (node as { value: unknown }).value === 'string' + ) { + return String((node as { value: string }).value) + } + throw new Error(`Unsupported command registration key: ${node.type}`) +} + +export const objectProperties = (node: AstNode): AstNode[] => { + if (node.type !== 'ObjectExpression') { + throw new Error(`Unsupported command registration object: ${node.type}`) + } + return (node as { properties: AstNode[] }).properties +} + +export const propertyName = (node: AstNode): string => { + if (node.type !== 'Property' || (node as { computed: boolean }).computed) { + throw new Error(`Unsupported command registration property: ${node.type}`) + } + return literal((node as { key: AstNode }).key) +} + +export const isNamedProperty = (node: AstNode, name: string): boolean => { + if (node.type !== 'Property') { + return false + } + const property = node as { computed: boolean; key: AstNode } + if (property.computed) { + return false + } + return ( + (property.key.type === 'Identifier' && + (property.key as { name: string }).name === name) || + (property.key.type === 'Literal' && + (property.key as { value: unknown }).value === name) + ) +} + +export const parse = async (file: string): Promise => { + const text = await readFile(file, 'utf8') + return parser.parse(text, { + sourceType: 'module', + ecmaVersion: 'latest', + }) as AstNode +} + +export const declarationFor = (ast: AstNode, name: string): AstNode => { + const statements = (ast as { body: AstNode[] }).body + for (const statement of statements) { + if (statement.type !== 'ExportNamedDeclaration') { + continue + } + const decl = (statement as { declaration?: AstNode }).declaration + if (decl?.type === 'VariableDeclaration') { + for (const item of (decl as { declarations: AstNode[] }).declarations) { + if ( + (item as { id: AstNode }).id.type === 'Identifier' && + (item as { id: { name: string } }).id.name === name + ) { + const init = (item as { init?: AstNode }).init + if (init) { + return init + } + } + } + } + } + throw new Error(`Could not resolve registered command ${name}`) +} + +export const importedCommands = ( + ast: AstNode, + file: string, +): Map => { + const imports = new Map() + for (const statement of (ast as { body: AstNode[] }).body) { + if (statement.type !== 'ImportDeclaration') { + continue + } + const source = (statement as { source: AstNode }).source + const sourceName = literal(source) + const resolved = path.resolve(path.dirname(file), sourceName) + for (const specifier of (statement as { specifiers: AstNode[] }) + .specifiers) { + if (specifier.type === 'ImportSpecifier') { + const local = (specifier as { local: { name: string } }).local.name + imports.set(local, resolved) + } + } + } + return imports +} + +export const resolveCommand = async ( + file: string, + name: string, + imports: Map, +): Promise<{ ast: AstNode; file: string; name: string }> => { + const importedFile = imports.get(name) + if (!importedFile) { + throw new Error(`Unsupported command registration reference: ${name}`) + } + const ast = await parse(importedFile) + declarationFor(ast, name) + return { ast, file: importedFile, name } +} + +/** Discovers canonical command paths from source registrations without importing commands. */ +export const discoverNativeCommandPaths = async ( + sourceRoot = SOURCE_ROOT, +): Promise => { + const entry = path.join(sourceRoot, 'commands.mts') + const ast = await parse(entry) + const root = declarationFor(ast, 'rootCommands') + const paths: string[] = [] + + const walk = async ( + file: string, + name: string, + prefix: string, + ): Promise => { + const command = await resolveCommand( + file, + name, + importedCommands(await parse(file), file), + ) + const registrations = descendants(command.ast).filter(node => + isNamedProperty(node, 'subcommands'), + ) + if (registrations.length > 1) { + throw new Error( + `Ambiguous subcommand registrations for ${prefix}: ${registrations.length}`, + ) + } + const subcommands = registrations[0] + if (!subcommands) { + return + } + const nestedObject = (subcommands as { value: AstNode }).value + for (const nested of objectProperties(nestedObject)) { + const nestedName = propertyName(nested) + const nestedValue = (nested as { value: AstNode }).value + if (nestedValue.type !== 'Identifier') { + throw new Error( + `Unsupported nested command registration for ${prefix} ${nestedName}`, + ) + } + const nestedPath = `${prefix} ${nestedName}` + paths.push(nestedPath) + // Command tree traversal is intentionally sequential. + // eslint-disable-next-line no-await-in-loop + await walk(command.file, literal(nestedValue), nestedPath) + } + } + + for (const property of objectProperties(root)) { + const commandPath = propertyName(property) + const value = (property as { value: AstNode }).value + if (value.type !== 'Identifier') { + throw new Error( + `Unsupported root command registration for ${commandPath}`, + ) + } + paths.push(commandPath) + // Root command discovery is intentionally sequential. + // eslint-disable-next-line no-await-in-loop + await walk(entry, literal(value), commandPath) + } + return paths.sort((a, b) => a.localeCompare(b)) +} diff --git a/test/lib/native/tui-api.mts b/test/lib/native/tui-api.mts new file mode 100644 index 0000000000..be049450a2 --- /dev/null +++ b/test/lib/native/tui-api.mts @@ -0,0 +1,148 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { createServer } from 'node:https' +import os from 'node:os' +import path from 'node:path' + +export interface TuiApiFixture { + readonly baseUrl: string + readonly caFile: string + close(): Promise +} + +const analytics = Array.from({ length: 3 }, (_, index) => ({ + id: index + 1, + created_at: new Date(Date.UTC(2020, 0, index + 1)).toISOString(), + repository_id: 'fixture-repo', + organization_id: 'fixture-org', + repository_name: 'fixture-repo', + total_critical_alerts: index + 1, + total_high_alerts: index + 3, + total_medium_alerts: index + 6, + total_low_alerts: index + 9, + total_critical_added: index + 1, + total_high_added: index + 2, + total_medium_added: index + 3, + total_low_added: index + 4, + total_critical_prevented: index + 2, + total_high_prevented: index + 3, + total_medium_prevented: index + 4, + total_low_prevented: index + 5, + top_five_alert_types: { malware: index + 7, vuln: index + 4 }, +})) + +const audit = { + results: [ + { + event_id: 'fixture-event-1', + created_at: '2024-01-02T03:04:05Z', + updated_at: '2024-01-02T03:04:05Z', + country_code: '', + organization_id: 'fixture-org', + ip_address: '192.0.2.1', + payload: { settingKey: 'fixtureSetting' }, + type: 'updateOrganizationSetting', + user_agent: 'native-tui-fixture', + user_email: 'fixture@example.invalid', + }, + ], + nextPage: '', +} + +const threatFeed = { + results: [ + { + createdAt: '1990-01-01T00:00:00Z', + description: 'Local native TUI fixture threat', + id: 1, + locationHtmlUrl: 'https://example.invalid/advisory', + packageHtmlUrl: 'https://example.invalid/package', + purl: 'pkg:npm/tui-fixture-package@1.2.3', + removedAt: null, + threatType: 'mal', + }, + ], + nextPage: '', +} + +export async function createTuiApiFixture(): Promise { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'socket-tui-api-')) + const keyPath = path.join(tempDir, 'key.pem') + const certPath = path.join(tempDir, 'cert.pem') + let server: ReturnType | undefined + try { + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + keyPath, + '-out', + certPath, + '-days', + '1', + '-subj', + '/CN=127.0.0.1', + '-addext', + 'subjectAltName=IP:127.0.0.1', + ], + { stdio: 'ignore', timeout: 30_000 }, + ) + server = createServer( + { key: await readFile(keyPath), cert: await readFile(certPath) }, + (request, response) => { + const route = request.url ?? '' + const body = route.startsWith('/orgs/fixture-org/threat-feed') + ? threatFeed + : route.startsWith('/analytics/org/') + ? analytics + : route.startsWith('/orgs/fixture-org/audit-log') + ? audit + : {} + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify(body)) + }, + ) + await new Promise((resolve, reject) => { + const onListening = () => { + server?.off('error', onError) + resolve() + } + const onError = (error: Error) => { + server?.off('listening', onListening) + reject(error) + } + server!.once('listening', onListening) + server!.once('error', onError) + server!.listen(0, '127.0.0.1') + }) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Could not start the local TUI fixture API') + } + const activeServer = server + return { + baseUrl: `https://127.0.0.1:${address.port}`, + caFile: certPath, + async close() { + try { + await new Promise((resolve, reject) => + activeServer.close(error => (error ? reject(error) : resolve())), + ) + } finally { + await rm(tempDir, { recursive: true, force: true }) + } + }, + } + } catch (error) { + if (server?.listening) { + await new Promise(resolve => server!.close(() => resolve())) + } + await rm(tempDir, { recursive: true, force: true }) + throw error + } +} diff --git a/test/lib/optimize-svg.mts b/test/lib/optimize-svg.mts new file mode 100644 index 0000000000..0d10936276 --- /dev/null +++ b/test/lib/optimize-svg.mts @@ -0,0 +1,18 @@ +import { optimize } from 'svgo' + +/** Preserve terminal layout and paint with a conservative plugin list. */ +export function optimizeSvg(svg: string): string { + return optimize(svg, { + multipass: false, + plugins: [ + 'removeDoctype', + 'removeXMLProcInst', + 'removeComments', + 'removeMetadata', + { + name: 'convertColors', + params: { currentColor: false, rgb2hex: false }, + }, + ], + }).data +} diff --git a/test/lib/terminal/animation.mts b/test/lib/terminal/animation.mts new file mode 100644 index 0000000000..58af1a97c2 --- /dev/null +++ b/test/lib/terminal/animation.mts @@ -0,0 +1,387 @@ +import { createHash } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { + createAnimatedSvg, + screenshotSvg, + selectRasterizer, +} from '@termless/core' +import { expect } from 'vitest' + +import { optimizeSvg } from '../optimize-svg.mts' + +import type { + AnimationFrame, + AnimationOptions, + Cell, + Cursor, + TestTerminal, +} from '@termless/core' + +export interface Checkpoint { + readonly name: string + readonly svg: string + readonly captured?: CapturedCheckpoint +} + +export interface CapturedCheckpoint { + readonly cols: number + readonly height: number + readonly rows: ReadonlyArray + readonly cursor: Cursor +} + +export interface TerminalRecording { + readonly checkpoints: readonly Checkpoint[] +} + +export interface RecordingOptions { + readonly flow: string + readonly snapshotsDir: string + readonly artifactsDir: string + readonly durations?: readonly number[] + readonly expectedSequence: readonly string[] +} + +export async function assertSamePixels( + rasterizer: Awaited>, + expectedSvg: string, + actualSvg: string, +): Promise { + const [expected, actual] = await Promise.all([ + rasterizer.rasterize(expectedSvg, 1), + rasterizer.rasterize(actualSvg, 1), + ]) + if ( + expected.width !== actual.width || + expected.height !== actual.height || + !Buffer.from(expected.pixels).equals(Buffer.from(actual.pixels)) + ) { + const expectedHash = createHash('sha256') + .update(Buffer.from(expected.pixels)) + .digest('hex') + const actualHash = createHash('sha256') + .update(Buffer.from(actual.pixels)) + .digest('hex') + throw new Error( + `Terminal screenshot pixels differ (${expected.width}x${expected.height}, ${expectedHash} vs ${actual.width}x${actual.height}, ${actualHash})`, + ) + } +} + +const safeName = /^[a-z0-9][a-z0-9-]*$/ +const svgOptions = { + fontFamily: "'JetBrains Mono', 'Noto Sans Symbols 2', monospace", + fontSize: 14, + padding: 16, + borderRadius: 8, + windowBar: 'colorful' as const, +} + +export async function captureCheckpoint( + terminal: TestTerminal, + name: string, +): Promise { + if (!safeName.test(name)) { + throw new Error(`Unsafe checkpoint name: ${name}`) + } + const rows = structuredClone(terminal.getRows()) + const cursor = structuredClone(terminal.getCursor()) + const scrollbackRows = Math.max( + 0, + terminal.getScrollback().totalRows - terminal.getScrollback().screenRows, + ) + const cursorRow = cursor.row + scrollbackRows + const lastOccupied = findLastOccupiedRow(rows) + const contentEnd = Math.max( + lastOccupied, + cursor.visible === true ? cursorRow : -1, + ) + const height = Math.max(1, contentEnd + 3) + const capturedRows = rows.slice(0, height).map(row => + Object.freeze( + row.map(cell => + Object.freeze({ + ...cell, + fg: cell.fg ? Object.freeze({ ...cell.fg }) : null, + bg: cell.bg ? Object.freeze({ ...cell.bg }) : null, + underlineColor: cell.underlineColor + ? Object.freeze({ ...cell.underlineColor }) + : null, + }), + ), + ), + ) + const captured = Object.freeze({ + cols: terminal.cols, + height, + rows: Object.freeze(capturedRows), + cursor: Object.freeze({ ...cursor, row: cursorRow, y: cursorRow }), + }) + const svg = renderCaptured(captured, height) + return Object.freeze({ name, svg, captured }) +} + +export function isVisiblyOccupied(cell: Cell): boolean { + return ( + (cell.char !== '' && cell.char !== ' ') || + cell.bg !== null || + cell.inverse || + (cell.underline !== false && cell.underline !== 'none') || + cell.strikethrough === true || + cell.overline === true || + cell.wide || + cell.continuation + ) +} + +export function createBlankCell(cell?: Cell): Cell { + const template = cell ?? emptyCell() + return { + ...template, + char: '', + fg: null, + bg: null, + bold: false, + dim: false, + italic: false, + underline: false, + underlineColor: null, + strikethrough: false, + inverse: false, + blink: false, + overline: false, + hidden: false, + wide: false, + continuation: false, + hyperlink: null, + } +} + +export function findLastOccupiedRow( + rows: ReadonlyArray, +): number { + for (let index = rows.length - 1; index >= 0; index--) { + if (rows[index]!.some(isVisiblyOccupied)) { + return index + } + } + return -1 +} + +export function renderCaptured( + captured: CapturedCheckpoint, + height: number, +): string { + const rows = captured.rows.slice(0, height).map(row => [...row]) + const blank = createBlankCell(rows[0]?.[0]) + while (rows.length < height) { + rows.push(Array.from({ length: captured.cols }, () => ({ ...blank }))) + } + const cursor = { ...captured.cursor } + const virtualTerminal = { + getRows: () => rows, + getCursor: () => cursor, + } + return screenshotSvg(virtualTerminal as TestTerminal, svgOptions) +} + +// Pinned Termless 0.11.0 separates keyframe blocks with invalid semicolons, +// which browsers use to discard later frames; repair only that generated CSS. +export function createTerminalAnimationSvg( + frames: AnimationFrame[], + options?: AnimationOptions, +): string { + const svg = createAnimatedSvg(frames, options) + return optimizeSvg( + svg.replace(/` + }), + ) +} + +export function emptyCell(): Cell { + return { + char: '', + fg: null, + bg: null, + bold: false, + dim: false, + italic: false, + underline: false, + underlineColor: null, + strikethrough: false, + inverse: false, + blink: false, + overline: false, + hidden: false, + wide: false, + continuation: false, + hyperlink: null, + } +} + +export function createRecording( + checkpoints: readonly Checkpoint[], +): TerminalRecording { + if (checkpoints.length === 0) { + throw new Error('Recording needs a checkpoint') + } + const seen = new Set() + const frozen: Checkpoint[] = [] + for (const checkpoint of checkpoints) { + if (!safeName.test(checkpoint.name)) { + throw new Error(`Unsafe checkpoint name: ${checkpoint.name}`) + } + if (seen.has(checkpoint.name)) { + throw new Error(`Duplicate checkpoint: ${checkpoint.name}`) + } + seen.add(checkpoint.name) + if (typeof checkpoint.svg !== 'string') { + throw new Error('Invalid checkpoint data') + } + const captured = checkpoint.captured + if (captured) { + if ( + frozen.some( + item => item.captured && item.captured.cols !== captured.cols, + ) + ) { + throw new Error( + 'Captured checkpoints must have the same terminal width', + ) + } + frozen.push( + Object.freeze({ name: checkpoint.name, svg: checkpoint.svg, captured }), + ) + } else { + frozen.push(Object.freeze({ name: checkpoint.name, svg: checkpoint.svg })) + } + } + const sharedHeight = Math.max( + 0, + ...frozen.map(item => item.captured?.height ?? 0), + ) + const normalized = frozen.map(checkpoint => + checkpoint.captured + ? Object.freeze({ + ...checkpoint, + svg: renderCaptured(checkpoint.captured, sharedHeight), + }) + : checkpoint, + ) + return Object.freeze({ checkpoints: Object.freeze(normalized) }) +} + +export async function expectTerminalAnimation( + recording: TerminalRecording, + options: RecordingOptions, +): Promise { + if (!safeName.test(options.flow)) { + throw new Error(`Unsafe flow name: ${options.flow}`) + } + const names = recording.checkpoints.map(({ name }) => name) + if ( + names.length === 0 || + new Set(names).size !== names.length || + names.some(name => !safeName.test(name)) + ) { + throw new Error( + 'Recording checkpoint names must be safe, unique, and non-empty', + ) + } + const durations = options.durations ?? names.map(() => 900) + if ( + durations.length !== names.length || + durations.some(value => !Number.isFinite(value) || value <= 0) + ) { + throw new Error('Each checkpoint needs a finite positive duration') + } + const flowDir = path.join(options.artifactsDir, options.flow) + await mkdir(flowDir, { recursive: true }) + const checkpoints = recording.checkpoints.map(checkpoint => ({ + ...checkpoint, + svg: optimizeSvg(checkpoint.svg), + })) + const rasterizer = await selectRasterizer('resvg') + const animationFrames: AnimationFrame[] = [] + try { + for (const checkpoint of checkpoints) { + animationFrames.push({ + svg: checkpoint.svg, + duration: durations[names.indexOf(checkpoint.name)]!, + }) + } + await Promise.all( + checkpoints.map(async checkpoint => { + const png = await rasterizer.toPng(checkpoint.svg, 1) + await Promise.all([ + writeFile( + path.join(flowDir, `${checkpoint.name}.svg`), + checkpoint.svg, + ), + writeFile(path.join(flowDir, `${checkpoint.name}.png`), png), + ]) + }), + ) + if (animationFrames.length > 1) { + await writeFile( + path.join(flowDir, 'animation.svg'), + createTerminalAnimationSvg(animationFrames), + ) + } + await writeFile( + path.join(flowDir, 'sequence.json'), + JSON.stringify({ checkpoints: names, durations }, null, 2) + '\n', + ) + } finally { + await rasterizer.dispose?.() + } + if ( + names.length !== options.expectedSequence.length || + names.some((name, index) => name !== options.expectedSequence[index]) + ) { + throw new Error( + `Checkpoint sequence mismatch: expected ${options.expectedSequence.join(', ')}, received ${names.join(', ')}`, + ) + } + const isSnapshotUpdate = + process.env['SOCKET_CLI_NATIVE_TEST_UPDATE_SNAPSHOTS'] === 'true' + const baselineRasterizer = !isSnapshotUpdate + ? await selectRasterizer('resvg') + : undefined + try { + await Promise.all( + checkpoints.map(async checkpoint => { + const svgPath = path.join( + options.snapshotsDir, + `${options.flow}.${checkpoint.name}.svg`, + ) + if (baselineRasterizer) { + const expectedSvg = await readFile(svgPath, 'utf8') + try { + await assertSamePixels( + baselineRasterizer, + expectedSvg, + checkpoint.svg, + ) + } catch (error) { + await writeFile( + path.join(flowDir, `${checkpoint.name}.expected.png`), + await baselineRasterizer.toPng(expectedSvg, 1), + ) + throw error + } + } else { + await expect(checkpoint.svg).toMatchFileSnapshot(svgPath) + } + }), + ) + } finally { + await baselineRasterizer?.dispose?.() + } +} diff --git a/test/lib/terminal/output.mts b/test/lib/terminal/output.mts new file mode 100644 index 0000000000..ed034a6cd6 --- /dev/null +++ b/test/lib/terminal/output.mts @@ -0,0 +1,93 @@ +import type { TerminalBackend } from '@termless/core' + +export interface LiteralReplacement { + readonly search: string + readonly replacement: string +} + +/** Replace known fixture paths before terminal layout, including split PTY chunks. */ +export function normalizeBackendPaths( + backend: TerminalBackend, + paths: readonly string[], + replacement = '', +): { flush(): void; matched(search: string): boolean } { + return normalizeBackendOutput( + backend, + paths.map(search => ({ search, replacement })), + ) +} + +/** Normalize exact known strings before the emulator calculates terminal layout. */ +export function normalizeBackendOutput( + backend: TerminalBackend, + literals: readonly LiteralReplacement[], +): { flush(): void; matched(search: string): boolean } { + const replacements = literals + .filter(({ search }) => search.length > 0) + .map( + ({ replacement, search }) => + [Buffer.from(search), Buffer.from(replacement)] as const, + ) + .sort(([a], [b]) => b.length - a.length) + if (!replacements.length) { + return { flush() {}, matched: () => false } + } + + const feed = backend.feed.bind(backend) + const matches = new Set() + let pending = Buffer.alloc(0) + const write = (data: Buffer, flush: boolean) => { + const combined = Buffer.concat([pending, data]) + const output: Buffer[] = [] + let cursor = 0 + while (cursor < combined.length) { + const exact = replacements.find(([needle]) => + combined.subarray(cursor, cursor + needle.length).equals(needle), + ) + if (exact) { + matches.add(exact[0].toString()) + output.push(exact[1]) + cursor += exact[0].length + continue + } + let partialLength = 0 + if (!flush) { + for (const [needle] of replacements) { + const length = Math.min(needle.length - 1, combined.length - cursor) + if ( + length > partialLength && + combined + .subarray(cursor, cursor + length) + .equals(needle.subarray(0, length)) + ) { + partialLength = length + } + } + } + if (partialLength === combined.length - cursor) { + break + } + if (partialLength > 0) { + output.push(combined.subarray(cursor, cursor + partialLength)) + cursor += partialLength + } else { + output.push(combined.subarray(cursor, cursor + 1)) + cursor += 1 + } + } + pending = Buffer.from(combined.subarray(cursor)) + const normalized = Buffer.concat(output) + if (normalized.length > 0) { + feed(normalized) + } + } + backend.feed = data => write(Buffer.from(data), false) + return { + flush() { + write(Buffer.alloc(0), true) + }, + matched(search) { + return matches.has(search) + }, + } +} diff --git a/test/native-snapshots/behavior-config-get.output.svg b/test/native-snapshots/behavior-config-get.output.svg new file mode 100644 index 0000000000..f9346630b1 --- /dev/null +++ b/test/native-snapshots/behavior-config-get.output.svg @@ -0,0 +1,19 @@ + +CLI: v2.1.0-rc.0 | cmd: socket config get | org: native-fixture | token: (not set) + + + +defaultOrg: native-fixture + + + +Note: the config is in read-only mode, meaning at least one key was temporarily overridden from an e + +nv var or command flag. + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/behavior-config-invalid-key.output.svg b/test/native-snapshots/behavior-config-invalid-key.output.svg new file mode 100644 index 0000000000..9fba7327dd --- /dev/null +++ b/test/native-snapshots/behavior-config-invalid-key.output.svg @@ -0,0 +1,27 @@ + +CLI: v2.1.0-rc.0 | cmd: socket config get | org: (not set) | token: (not set) + + + +✖ + + Input error: + +Please review the input requirements and try again + + + + + +✖ + Config key should be the first arg ( +invalid config key +) + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/behavior-config-list.output.svg b/test/native-snapshots/behavior-config-list.output.svg new file mode 100644 index 0000000000..3cf41b9c21 --- /dev/null +++ b/test/native-snapshots/behavior-config-list.output.svg @@ -0,0 +1,31 @@ + +CLI: v2.1.0-rc.0 | cmd: socket config list | org: native-fixture | token: (not set) + + + +# Local CLI Config + + + +This is the local CLI config (full=false): + + + +- apiToken: ******** + +- defaultOrg: native-fixture + +- org: native-fixture + + + +Note: the config is in read-only mode, meaning at least one key was temporarily + + overridden from an env var or command flag. + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/behavior-json-missing.output.svg b/test/native-snapshots/behavior-json-missing.output.svg new file mode 100644 index 0000000000..1a721bba39 --- /dev/null +++ b/test/native-snapshots/behavior-json-missing.output.svg @@ -0,0 +1,17 @@ + +CLI: v2.1.0-rc.0 | cmd: socket json | org: (not set) | token: (not set) + + + +ℹ + Target cwd: <NATIVE_FIXTURE> + +✖ + Not found: <NATIVE_FIXTURE>/socket.json + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/behavior-json-seeded.output.svg b/test/native-snapshots/behavior-json-seeded.output.svg new file mode 100644 index 0000000000..d4eb332252 --- /dev/null +++ b/test/native-snapshots/behavior-json-seeded.output.svg @@ -0,0 +1,23 @@ + +CLI: v2.1.0-rc.0 | cmd: socket json | org: (not set) | token: (not set) + + + +ℹ + Target cwd: <NATIVE_FIXTURE> + +✔ + This is the contents of <NATIVE_FIXTURE>/socket.json: + + + +{"version":1,"projectIgnorePaths":["fixture/**"]} + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/behavior-manifest-setup.canceled.svg b/test/native-snapshots/behavior-manifest-setup.canceled.svg new file mode 100644 index 0000000000..f417968851 --- /dev/null +++ b/test/native-snapshots/behavior-manifest-setup.canceled.svg @@ -0,0 +1,59 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest setup | org: (not set) | token: (not set) + + + +ℹ + No socket.json found at <NATIVE_FIXTURE>, will generate a new one + + + +Note: This tool will set up flag and argument defaults for certain + + CLI commands. You can still override them by explicitly + + setting the flag. It is meant to be a convenience tool. + + + +This command will generate a socket.json file in the target cwd. + +You can choose to add this file to your repo (handy for collaboration) + +or to add it to the ignored files, or neither. This file is only + +used in CLI workflows. + + + +✔ + +Select ecosystem manifest generator to configure + +None, exit configurator + + + + +ℹ + User canceled + + + +✔ + Setup complete + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/behavior-manifest-setup.exit-selected.svg b/test/native-snapshots/behavior-manifest-setup.exit-selected.svg new file mode 100644 index 0000000000..af202d0422 --- /dev/null +++ b/test/native-snapshots/behavior-manifest-setup.exit-selected.svg @@ -0,0 +1,57 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest setup | org: (not set) | token: (not set) + + + +ℹ + No socket.json found at <NATIVE_FIXTURE>, will generate a new one + + + +Note: This tool will set up flag and argument defaults for certain + + CLI commands. You can still override them by explicitly + + setting the flag. It is meant to be a convenience tool. + + + +This command will generate a socket.json file in the target cwd. + +You can choose to add this file to your repo (handy for collaboration) + +or to add it to the ignored files, or neither. This file is only + +used in CLI workflows. + + + +? + +Select ecosystem manifest generator to configure + + +❯ None, exit configurator + + + Conda + + .NET (dotnet) + + Gradle + + Kotlin (gradle) + + Scala (gradle) + + Maven + +Exit setup + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/behavior-manifest-setup.menu.svg b/test/native-snapshots/behavior-manifest-setup.menu.svg new file mode 100644 index 0000000000..f8b1bae3f9 --- /dev/null +++ b/test/native-snapshots/behavior-manifest-setup.menu.svg @@ -0,0 +1,58 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest setup | org: (not set) | token: (not set) + + + +ℹ + No socket.json found at <NATIVE_FIXTURE>, will generate a new one + + + +Note: This tool will set up flag and argument defaults for certain + + CLI commands. You can still override them by explicitly + + setting the flag. It is meant to be a convenience tool. + + + +This command will generate a socket.json file in the target cwd. + +You can choose to add this file to your repo (handy for collaboration) + +or to add it to the ignored files, or neither. This file is only + +used in CLI workflows. + + + +? + +Select ecosystem manifest generator to configure + + +❯ Conda + + + .NET (dotnet) + + Gradle + + Kotlin (gradle) + + Scala (gradle) + + Maven + + Scala (sbt) + +(Use arrow keys to reveal more choices) + + +Generate requirements.txt from a Conda environment.yml + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/behavior-wrapper-on-dry-run.output.svg b/test/native-snapshots/behavior-wrapper-on-dry-run.output.svg new file mode 100644 index 0000000000..dfa9efefd8 --- /dev/null +++ b/test/native-snapshots/behavior-wrapper-on-dry-run.output.svg @@ -0,0 +1,13 @@ + +CLI: v2.1.0-rc.0 | cmd: socket wrapper | org: (not set) | token: (not set) + + + +[DryRun]: Bailing now + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/native-analytics-tui.charts.svg b/test/native-snapshots/native-analytics-tui.charts.svg new file mode 100644 index 0000000000..ade4186c2b --- /dev/null +++ b/test/native-snapshots/native-analytics-tui.charts.svg @@ -0,0 +1,279 @@ + +┌─ +Total critical alerts +────────────────────────────────────┐┌─ +Total high alerts +────────────────────────────────────────┐ + +│ + +2 + +⡇ +⠒⠒⠒⠒⠒⠒⠒⠒⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠁ + +││ + +3 + +⡇ +⠉⠉⠉⠉⠉⠉⠉⠉ + +│ + +│ + +⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒ + +││ + +⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒ + +│ + +│ + +Jan 1 + +Jan 2 + +Jan 3 + +││ + +Jan 1 + +Jan 2 + +Jan 3 + +│ + +│ + +││ + +│ + +└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘ + +┌─ +Total critical alerts added to the main branch +───────────┐┌─ +Total high alerts added to the main branch +───────────────┐ + +│ + +2 + +⡇ +⠒⠒⠒⠒⠒⠒⠒⠒⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠁ + +││ + +2 + +⡇ +⠉⠉⠉⠉⠉⠉⠉⠉ + +│ + +│ + +⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒ + +││ + +⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒ + +│ + +│ + +Jan 1 + +Jan 2 + +Jan 3 + +││ + +Jan 1 + +Jan 2 + +Jan 3 + +│ + +│ + +││ + +│ + +└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘ + + + +┌─ +Total critical alerts prevented from the main branch +─────┐┌─ +Total high alerts prevented from the main branch +─────────┐ + +│ + +2 + +⡇ +⠉⠉⠉⠉⠉⠉⠉⠉ + +││ + +3 + +⡇ +⠉⠉⠉⠉⠉⠉⠉⠉ + +│ + +│ + +⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒ + +││ + +⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒ + +│ + +│ + +Jan 1 + +Jan 2 + +Jan 3 + +││ + +Jan 1 + +Jan 2 + +Jan 3 + +│ + +│ + +││ + +│ + +└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘ + + + +┌─ +Total medium alerts prevented from the main branch +───────┐┌─ +Total low alerts prevented from the main branch +──────────┐ + +│ + +4 + +⡇ +⠉⠉⠉⠉⠉⠉⠉⠉ + +││ + +5 + +⡇ +⠉⠉⠉⠉⠉⠉⠉⠉ + +│ + +│ + +⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒ + +││ + +⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒ + +│ + +│ + +Jan 1 + +Jan 2 + +Jan 3 + +││ + +Jan 1 + +Jan 2 + +Jan 3 + +│ + +│ + +││ + +│ + +└──────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘ + + + +┌─ +Top 5 alert types +────────────────────────────────────────┐ + + +│ + +│ + + +│ + +│ + + +│ + +24 + +15 + +│ + + +│ + +malware + +vuln + +│ + + +└──────────────────────────────────────────────────────────┘ + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/native-audit-log-tui.table.svg b/test/native-snapshots/native-audit-log-tui.table.svg new file mode 100644 index 0000000000..19b9152e2e --- /dev/null +++ b/test/native-snapshots/native-audit-log-tui.table.svg @@ -0,0 +1,145 @@ + +┌─ +Audit Logs for fixture-org +───────────────────────────────────────────────────────────────────────────────────────────┐ + +│ + Event id Created at Event type User email IP address User agent +│ + +│ + +│ + +│ + +fixture-event-1 2024-01-02 updateOrganizationSetting fixture@example.invalid 192.0.2.1 native-tui-fixt +│ + +│ + + +│ + +│ + + +│ + +│ + + +│ + +│ + + +│ + +│ + + +│ + +│ + + +│ + +│ + + +│ + +│ + + +│ + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + +↑/↓: Move Enter: Select q/ESC: Quit + + +┌─ +Details +──────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ + +│ +{ +│ + +│ + "event_id": "fixture-event-1", +│ + +│ + "created_at": "2024-01-02T03:04:05Z", +│ + +│ + "updated_at": "2024-01-02T03:04:05Z", +│ + +│ + "country_code": "", +│ + +│ + "organization_id": "fixture-org", +│ + +│ + "ip_address": "192.0.2.1", +│ + +│ + "payload": {"settingKey":"fixtureSetting"}, +│ + +│ + "type": "updateOrganizationSetting", +│ + +│ + "user_agent": "native-tui-fixture", +│ + +│ + "user_email": "fixture@example.invalid" +│ + +│ +} +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + + + \ No newline at end of file diff --git a/test/native-snapshots/native-threat-feed-tui.table.svg b/test/native-snapshots/native-threat-feed-tui.table.svg new file mode 100644 index 0000000000..a5d4fa945c --- /dev/null +++ b/test/native-snapshots/native-threat-feed-tui.table.svg @@ -0,0 +1,145 @@ + +┌─ +Threat feed +──────────────────────────────────────────────────────────────────────────────────────────────────────────┐ + +│ + Ecosystem Name Version Threat type Detected at Details +│ + +│ + +│ + +│ + +npm tui-fixture-package 1.2.3 mal 1990-01-01 https://example.invalid/ +│ + +│ + + +│ + +│ + + +│ + +│ + + +│ + +│ + + +│ + +│ + + +│ + +│ + + +│ + +│ + + +│ + +│ + + +│ + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + +↑/↓: Move Enter: Select q/ESC: Quit + + +┌─ +Details +──────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ + +│ +Ecosystem: npm +│ + +│ +Name: tui-fixture-package +│ + +│ +Version: 1.2.3 +│ + +│ +Threat type: mal +│ + +│ +Detected at: 1990-01-01 +│ + +│ +Details: https://example.invalid/advisory +│ + +│ +Description: Local native TUI fixture threat +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +│ + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + + + \ No newline at end of file diff --git a/test/native-snapshots/self-update-notice-standard.standard.svg b/test/native-snapshots/self-update-notice-standard.standard.svg new file mode 100644 index 0000000000..0729eab6a2 --- /dev/null +++ b/test/native-snapshots/self-update-notice-standard.standard.svg @@ -0,0 +1,9 @@ + +Socket 1.0.0 → 1.1.0. Update with your favorite package manager. + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/self-update-notice-wide.wide.svg b/test/native-snapshots/self-update-notice-wide.wide.svg new file mode 100644 index 0000000000..bb283144f1 --- /dev/null +++ b/test/native-snapshots/self-update-notice-wide.wide.svg @@ -0,0 +1,9 @@ + +Socket 1.0.0 → 1.1.0. Update with your favorite package manager. + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/self-update-notice-wrapped.wrapped.svg b/test/native-snapshots/self-update-notice-wrapped.wrapped.svg new file mode 100644 index 0000000000..96f0626748 --- /dev/null +++ b/test/native-snapshots/self-update-notice-wrapped.wrapped.svg @@ -0,0 +1,11 @@ + +Socket 1.0.0 → 1.1.0. Update wit + +h your favorite package manager. + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/self-update-suppressed.suppressed.svg b/test/native-snapshots/self-update-suppressed.suppressed.svg new file mode 100644 index 0000000000..329e94986a --- /dev/null +++ b/test/native-snapshots/self-update-suppressed.suppressed.svg @@ -0,0 +1,9 @@ + +Socket CLI ready + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-cdxgen-unsupported-type.unsupported-type.svg b/test/native-snapshots/socket-cdxgen-unsupported-type.unsupported-type.svg new file mode 100644 index 0000000000..a0d4bceec5 --- /dev/null +++ b/test/native-snapshots/socket-cdxgen-unsupported-type.unsupported-type.svg @@ -0,0 +1,35 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest cdxgen | org: (not set) | token: (not set) + + + +ℹ + Setting cdxgen --lifecycle to "pre-build" to avoid arbitrary code execution on this scan. + + Pass "--lifecycle build" to generate a BOM consisting of information obtained during the build pro + +cess. + + See cdxgen BOM lifecycles documentation (​https://cyclonedx.github.io/cdxgen/#/ADVANCED?id=bom-life + +cycles​) for more details. + + + + + + + +✖ + + Unexpected error: + +The standalone cdxgen build supports only npm projects. + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-analytics.help.svg b/test/native-snapshots/socket-help-analytics.help.svg new file mode 100644 index 0000000000..24a71fc0f0 --- /dev/null +++ b/test/native-snapshots/socket-help-analytics.help.svg @@ -0,0 +1,65 @@ + +CLI: v2.1.0-rc.0 | cmd: socket analytics | org: (not set) | token: (not set) + + + + + + Look up analytics data + + + + Usage + + $ socket analytics [options] [ "org" | "repo" <reponame>] [TIME] + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: report:write + + + + The scope is either org or repo level, defaults to org. + + + + When scope is repo, a repo slug must be given as well. + + + + The TIME argument must be number 7, 30, or 90 and defaults to 30. + + + + Options + + --file Path to store result, only valid with --json/--markdown + + --json Output as JSON + + --markdown Output as Markdown + + + + Examples + + $ socket analytics org 7 + + $ socket analytics repo test-repo 30 + + $ socket analytics 90 + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-audit-log.help.svg b/test/native-snapshots/socket-help-audit-log.help.svg new file mode 100644 index 0000000000..686ed1f825 --- /dev/null +++ b/test/native-snapshots/socket-help-audit-log.help.svg @@ -0,0 +1,89 @@ + +CLI: v2.1.0-rc.0 | cmd: socket audit-log | org: (not set) | token: (not set) + + + + + + Look up the audit log for an organization + + + + Usage + + $ socket audit-log [options] [FILTER] + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: audit-log:list + + + + This feature requires an Enterprise Plan. To learn more about getting access + + to this feature and many more, please visit the Socket pricing page (​https://socket.dev/pricing​). + + + + The type FILTER arg is an enum. Defaults to any. It should be one of these: + + associateLabel, cancelInvitation, changeMemberRole, changePlanSubscriptionSeats, + + createApiToken, createLabel, deleteLabel, deleteLabelSetting, deleteReport, + + deleteRepository, disassociateLabel, joinOrganization, removeMember, + + resetInvitationLink, resetOrganizationSettingToDefault, rotateApiToken, + + sendInvitation, setLabelSettingToDefault, syncOrganization, transferOwnership, + + updateAlertTriage, updateApiTokenCommitter, updateApiTokenMaxQuota, + + updateApiTokenName', updateApiTokenScopes, updateApiTokenVisibility, + + updateLabelSetting, updateOrganizationSetting, upgradeOrganizationPlan + + + + The page arg should be a positive integer, offset 1. Defaults to 1. + + + + Options + + --interactive Allow for interactive elements, asking for input. + + Use --no-interactive to prevent any input questions, defaulting them to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + --page Result page to fetch + + --per-page Results per page - default is 30 + + + + Examples + + $ socket audit-log + + $ socket audit-log deleteReport --page 2 --per-page 10 + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-cdxgen.help.svg b/test/native-snapshots/socket-help-cdxgen.help.svg new file mode 100644 index 0000000000..0bb551dac1 --- /dev/null +++ b/test/native-snapshots/socket-help-cdxgen.help.svg @@ -0,0 +1,35 @@ + +CLI: v2.1.0-rc.0 | cmd: socket cdxgen | org: (not set) | token: (not set) + + + + + +Usage + + socket manifest cdxgen [options] [path] + + + +Options + + -h, --help Show this help + + -o, --output <file> Output file (default: socket-cdx.json) + + -t, --type <type> npm, nodejs, javascript, js, typescript, ts, or tsx + + --lifecycle pre-build Read dependency metadata without running project installs + + + +Generates a CycloneDX SBOM from a root npm package-lock.json v3. + +Only one npm project is supported in this standalone build. Other cdxgen modes are unavailable. + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-ci.help.svg b/test/native-snapshots/socket-help-ci.help.svg new file mode 100644 index 0000000000..08b2cc7bc5 --- /dev/null +++ b/test/native-snapshots/socket-help-ci.help.svg @@ -0,0 +1,59 @@ + +CLI: v2.1.0-rc.0 | cmd: socket ci | org: (not set) | token: (not set) + + + + + + Alias for `socket scan create --report` (creates report and exits with error if unhealthy) + + + + Usage + + $ socket ci [options] + + + + Options + + --auto-manifest Auto generate manifest files where detected? See autoManifest flag in `socket scan create` + + + + This command is intended to use in CI runs to allow automated systems to + + accept or reject a current build. It will use the default org of the + + Socket API token. The exit code will be non-zero when the scan does not pass + + your security policy. + + + + The --auto-manifest flag does the same as the one from `socket scan create` + + but is not enabled by default since the CI is less likely to be set up with + + all the necessary dev tooling. Enable it if you want the scan to include + + locally generated manifests like for gradle and sbt. + + + + Examples + + $ socket ci + + $ socket ci --auto-manifest + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-config-auto.help.svg b/test/native-snapshots/socket-help-config-auto.help.svg new file mode 100644 index 0000000000..6555262e8e --- /dev/null +++ b/test/native-snapshots/socket-help-config-auto.help.svg @@ -0,0 +1,65 @@ + +CLI: v2.1.0-rc.0 | cmd: socket config auto | org: (not set) | token: (not set) + + + + + + Automatically discover and set the correct value config item + + + + Usage + + $ socket config auto [options] KEY + + + + Options + + --json Output as JSON + + --markdown Output as Markdown + + + + Attempt to automatically discover the correct value for a given config KEY. + + + + Examples + + $ socket config auto defaultOrg + + + + Keys: + + - apiBaseUrl -- Base URL of the Socket API endpoint + + - apiProxy -- A proxy through which to access the Socket API + + - apiToken -- The Socket API token required to access most Socket API endpoints + + - defaultOrg -- The default org slug to use; usually the org your Socket API token has access to. When set, all orgSlug arguments are imp + +lied to be this value. + + - enforcedOrgs -- Orgs in this list have their security policies enforced on this machine + + - org -- Alias for defaultOrg + + - skipAskToPersistDefaultOrg -- This flag prevents the Socket CLI from asking you to persist the org slug when you selected one interacti + +vely + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-config-get.help.svg b/test/native-snapshots/socket-help-config-get.help.svg new file mode 100644 index 0000000000..4abb1b01a6 --- /dev/null +++ b/test/native-snapshots/socket-help-config-get.help.svg @@ -0,0 +1,69 @@ + +CLI: v2.1.0-rc.0 | cmd: socket config get | org: (not set) | token: (not set) + + + + + + Get the value of a local CLI config item + + + + Usage + + $ socket config get [options] KEY + + + + Retrieve the value for given KEY at this time. If you have overridden the + + config then the value will come from that override. + + + + Options + + --json Output as JSON + + --markdown Output as Markdown + + + + KEY is an enum. Valid keys: + + + + - apiBaseUrl -- Base URL of the Socket API endpoint + + - apiProxy -- A proxy through which to access the Socket API + + - apiToken -- The Socket API token required to access most Socket API endpoints + + - defaultOrg -- The default org slug to use; usually the org your Socket API token has access to. When set, all orgSlug arguments are imp + +lied to be this value. + + - enforcedOrgs -- Orgs in this list have their security policies enforced on this machine + + - org -- Alias for defaultOrg + + - skipAskToPersistDefaultOrg -- This flag prevents the Socket CLI from asking you to persist the org slug when you selected one interacti + +vely + + + + Examples + + $ socket config get defaultOrg + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-config-list.help.svg b/test/native-snapshots/socket-help-config-list.help.svg new file mode 100644 index 0000000000..1dc7680fa3 --- /dev/null +++ b/test/native-snapshots/socket-help-config-list.help.svg @@ -0,0 +1,41 @@ + +CLI: v2.1.0-rc.0 | cmd: socket config list | org: (not set) | token: (not set) + + + + + + Show all local CLI config items and their values + + + + Usage + + $ socket config list [options] + + + + Options + + --full Show full tokens in plaintext (unsafe) + + --json Output as JSON + + --markdown Output as Markdown + + + + Examples + + $ socket config list + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-config-set.help.svg b/test/native-snapshots/socket-help-config-set.help.svg new file mode 100644 index 0000000000..7458d92016 --- /dev/null +++ b/test/native-snapshots/socket-help-config-set.help.svg @@ -0,0 +1,81 @@ + +CLI: v2.1.0-rc.0 | cmd: socket config set | org: (not set) | token: (not set) + + + + + + Update the value of a local CLI config item + + + + Usage + + $ socket config set [options] <KEY> <VALUE> + + + + Options + + --json Output as JSON + + --markdown Output as Markdown + + + + This is a crude way of updating the local configuration for this CLI tool. + + + + Note that updating a value here is nothing more than updating a key/value + + store entry. No validation is happening. The server may reject your values + + in some cases. Use at your own risk. + + + + Note: use `socket config unset` to restore to defaults. Setting a key + + to `undefined` will not allow default values to be set on it. + + + + Keys: + + + + - apiBaseUrl -- Base URL of the Socket API endpoint + + - apiProxy -- A proxy through which to access the Socket API + + - apiToken -- The Socket API token required to access most Socket API endpoints + + - defaultOrg -- The default org slug to use; usually the org your Socket API token has access to. When set, all orgSlug arguments are imp + +lied to be this value. + + - enforcedOrgs -- Orgs in this list have their security policies enforced on this machine + + - org -- Alias for defaultOrg + + - skipAskToPersistDefaultOrg -- This flag prevents the Socket CLI from asking you to persist the org slug when you selected one interacti + +vely + + + + Examples + + $ socket config set apiProxy https://example.com + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-config-unset.help.svg b/test/native-snapshots/socket-help-config-unset.help.svg new file mode 100644 index 0000000000..72caa92ae0 --- /dev/null +++ b/test/native-snapshots/socket-help-config-unset.help.svg @@ -0,0 +1,69 @@ + +CLI: v2.1.0-rc.0 | cmd: socket config unset | org: (not set) | token: (not set) + + + + + + Clear the value of a local CLI config item + + + + Usage + + $ socket config unset [options] <KEY> <VALUE> + + + + Options + + --json Output as JSON + + --markdown Output as Markdown + + + + Removes a value from a config key, allowing the default value to be used + + for it instead. + + + + Keys: + + + + - apiBaseUrl -- Base URL of the Socket API endpoint + + - apiProxy -- A proxy through which to access the Socket API + + - apiToken -- The Socket API token required to access most Socket API endpoints + + - defaultOrg -- The default org slug to use; usually the org your Socket API token has access to. When set, all orgSlug arguments are imp + +lied to be this value. + + - enforcedOrgs -- Orgs in this list have their security policies enforced on this machine + + - org -- Alias for defaultOrg + + - skipAskToPersistDefaultOrg -- This flag prevents the Socket CLI from asking you to persist the org slug when you selected one interacti + +vely + + + + Examples + + $ socket config unset defaultOrg + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-config.help.svg b/test/native-snapshots/socket-help-config.help.svg new file mode 100644 index 0000000000..4f54203193 --- /dev/null +++ b/test/native-snapshots/socket-help-config.help.svg @@ -0,0 +1,45 @@ + +CLI: v2.1.0-rc.0 | cmd: socket config | org: (not set) | token: (not set) + + + + Manage Socket CLI configuration + + + + Usage + + $ socket config <command> + + + + Commands + + auto Automatically discover and set the correct value config item + + get Get the value of a local CLI config item + + list Show all local CLI config items and their values + + set Update the value of a local CLI config item + + unset Clear the value of a local CLI config item + + + + Options + + + + --no-banner Hide the Socket banner + + --no-spinner Hide the console spinner + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-dependencies.help.svg b/test/native-snapshots/socket-help-dependencies.help.svg new file mode 100644 index 0000000000..eb897a0303 --- /dev/null +++ b/test/native-snapshots/socket-help-dependencies.help.svg @@ -0,0 +1,51 @@ + +CLI: v2.1.0-rc.0 | cmd: socket dependencies | org: (not set) | token: (not set) + + + + + + Search for any dependency that is being used in your organization + + + + Usage + + socket dependencies [options] + + + + API Token Requirements + + (none) + + + + Options + + --json Output as JSON + + --limit Maximum number of dependencies returned + + --markdown Output as Markdown + + --offset Page number + + + + Examples + + socket dependencies + + socket dependencies --limit 20 --offset 10 + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-fix.help.svg b/test/native-snapshots/socket-help-fix.help.svg new file mode 100644 index 0000000000..5cad8355ce --- /dev/null +++ b/test/native-snapshots/socket-help-fix.help.svg @@ -0,0 +1,159 @@ + +CLI: v2.1.0-rc.0 | cmd: socket fix | org: (not set) | token: (not set) + + + + + + Fix CVEs in dependencies + + + + Usage + + $ socket fix [options] [CWD=.] + + + + API Token Requirements + + - Quota: 101 units + + - Permissions: fixes:list, full-scans:create, and packages:list + + + + Options + + --all Process all discovered vulnerabilities in local mode. Cannot be used with --id. + + --allow-overrides When the only fix for a vulnerability is blocked by a parent package's declared version range, write an override or + +resolution that forces the fixed version under that parent. This can install a version outside the range the parent declares, so test the pa + +rent afterwards. Works for npm, pnpm, Yarn and Rush projects. + + --autopilot Enable auto-merge for pull requests that Socket opens. + + See GitHub documentation (​https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository + +/configuring-pull-request-merges/managing-auto-merge-for-pull-requests-in-your-repository​) for managing auto-merge for pull requests in your + + repository. + + --debug Enable debug logging in the Coana-based Socket Fix CLI invocation. + + --disable-external-tool-checks Disable external tool checks during fix analysis. + + --dynamic-sbom-inference For Gradle, sbt, Maven and .NET: generate a Socket facts SBOM (produced directly by each package manager) per + +independent build root, instead of one synthetic root. Fixes are then attributed to the projects/modules that actually resolve each vulnerab + +le dependency. The generated files are removed afterwards. + + --dynamic-sbom-inference-ecosystems With --dynamic-sbom-inference: generate Socket facts only for these ecosystems, as either a comma s + +eparated value or as multiple flags; the others keep their manifest files as they are. Supported: maven (Gradle, Maven, and sbt) and nuget ( + +.NET). Defaults to all of them. + + --ecosystems Limit fix analysis to specific ecosystems. Accepts space- or comma-separated values and is case-insensitive. Default + +s to all ecosystems. + + --exclude-paths Skip matching paths from the scan entirely: manifests under these paths are not uploaded, and fixes are not applied + +to workspaces under them. Patterns are anchored micromatch globs matched relative to the target directory (CWD); `data/postgres/pgdata` matc + +hes that exact path, `**/pgdata` matches at any depth. Use this to skip directories the current user cannot read so they do not abort manife + +st collection. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags. + + --fix-version Override the version of @coana-tech/cli used for fix analysis. Default: 15.15.0. + + --id Provide a list of vulnerability identifiers to compute fixes for: + + - GHSA IDs (​https://docs.github.com/en/code-security/security-advisories/working-with-global-security-advisories + +-from-the-github-advisory-database/about-the-github-advisory-database#about-ghsa-ids​) (e.g., GHSA-xxxx-xxxx-xxxx) + + - CVE IDs (​https://cve.mitre.org/cve/identifiers/​) (e.g., CVE-2026-1234) - automatically converted to GHSA + + - PURLs (​https://github.com/package-url/purl-spec​) (e.g., pkg:npm/package@1.0.0) - automatically converted to GH + +SA + + Can be provided as comma separated values or as multiple flags. Cannot be used with --all. + + --include Include workspaces matching these glob patterns. Can be provided as comma separated values or as multiple flags + + --json Output as JSON + + --markdown Output as Markdown + + --minimum-release-age Set a minimum age requirement for suggested upgrade versions (e.g., 1h, 2d, 3w). A higher age requirement reduces + + the risk of upgrading to malicious versions. For example, setting the value to 1 week (1w) gives ecosystem maintainers one week to remove p + +otentially malicious versions. + + --no-apply-fixes Compute fixes only, do not apply them. Logs what upgrades would be applied. If combined with --output-file, the outp + +ut file will contain the upgrades that would be applied. + + --no-major-updates Do not suggest or apply fixes that require major version updates of direct or transitive dependencies + + --output-file Path to store upgrades as a JSON file at this path. + + --package-managers Limit fix analysis to specific package managers within an ecosystem (e.g. NPM, PNPM, YARN, MAVEN, POETRY). Accepts s + +pace- or comma-separated values and is case-insensitive. When combined with --ecosystems, an artifact must satisfy both filters. + + --pr-limit Maximum number of pull requests to create in CI mode (default 10). Has no effect in local mode. + + --range-style Define how dependency version ranges are updated in package.json (default 'preserve'). + + Available styles: + + * pin - Use the exact version (e.g. 1.2.3) + + * preserve - Retain the existing version range style as-is + + --show-affected-direct-dependencies List the direct dependencies responsible for introducing transitive vulnerabilities and list the up + +dates required to resolve the vulnerabilities + + --silence Silence all output except the final result + + + + Environment Variables (for CI/PR mode) + + CI Set to enable CI mode + + SOCKET_CLI_GITHUB_TOKEN GitHub token for PR creation (or GITHUB_TOKEN) + + SOCKET_CLI_GIT_USER_NAME Git username for commits + + SOCKET_CLI_GIT_USER_EMAIL Git email for commits + + + + Examples + + $ socket fix + + $ socket fix --id CVE-2021-23337 + + $ socket fix ./path/to/project --range-style pin + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-install-completion.help.svg b/test/native-snapshots/socket-help-install-completion.help.svg new file mode 100644 index 0000000000..91191c6d08 --- /dev/null +++ b/test/native-snapshots/socket-help-install-completion.help.svg @@ -0,0 +1,73 @@ + +CLI: v2.1.0-rc.0 | cmd: socket install completion | org: (not set) | token: (not set) + + + + + + Install bash completion for Socket CLI + + + + Usage + + $ socket install completion [options] [NAME=socket] + + + + Installs bash completion for the Socket CLI. This will: + + 1. Source the completion script in your current shell + + 2. Add the source command to your ~/.bashrc if it's not already there + + + + This command will only setup tab completion, nothing else. + + + + Afterwards you should be able to type `socket ` and then press tab to + + have bash auto-complete/suggest the sub/command or flags. + + + + Currently only supports bash. + + + + The optional name argument allows you to enable tab completion on a command + + name other than "socket". Mostly for debugging but also useful if you use a + + different alias for socket on your system. + + + + Options + + (none) + + + + Examples + + + + $ socket install completion + + $ socket install completion sd + + $ socket install completion ./sd + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-install.help.svg b/test/native-snapshots/socket-help-install.help.svg new file mode 100644 index 0000000000..81afeedf78 --- /dev/null +++ b/test/native-snapshots/socket-help-install.help.svg @@ -0,0 +1,37 @@ + +CLI: v2.1.0-rc.0 | cmd: socket install | org: (not set) | token: (not set) + + + + Install Socket CLI tab completion + + + + Usage + + $ socket install <command> + + + + Commands + + completion Install bash completion for Socket CLI + + + + Options + + + + --no-banner Hide the Socket banner + + --no-spinner Hide the console spinner + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-json.help.svg b/test/native-snapshots/socket-help-json.help.svg new file mode 100644 index 0000000000..7f48c6e308 --- /dev/null +++ b/test/native-snapshots/socket-help-json.help.svg @@ -0,0 +1,37 @@ + +CLI: v2.1.0-rc.0 | cmd: socket json | org: (not set) | token: (not set) + + + + + + Display the `socket.json` that would be applied for target folder + + + + Usage + + $ socket json [options] [CWD=.] + + + + Display the `socket.json` file that would apply when running relevant commands + + in the target directory. + + + + Examples + + $ socket json + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-license.help.svg b/test/native-snapshots/socket-help-license.help.svg new file mode 100644 index 0000000000..b4c356dc28 --- /dev/null +++ b/test/native-snapshots/socket-help-license.help.svg @@ -0,0 +1,59 @@ + +CLI: v2.1.0-rc.0 | cmd: socket license | org: (not set) | token: (not set) + + + + + + Retrieve the license policy of an organization + + + + Usage + + $ socket license [options] + + + + API Token Requirements + + (none) + + + + Options + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + + + Your API token will need the `license-policy:read` permission otherwise + + the request will fail with an authentication error. + + + + Examples + + $ socket license + + $ socket license --json + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-login.help.svg b/test/native-snapshots/socket-help-login.help.svg new file mode 100644 index 0000000000..c7d5ace86a --- /dev/null +++ b/test/native-snapshots/socket-help-login.help.svg @@ -0,0 +1,59 @@ + +CLI: v2.1.0-rc.0 | cmd: socket login | org: (not set) | token: (not set) + + + + + + Sign in to Socket and configure the CLI + + + + Usage + + $ socket login [options] + + + + API Token Requirements + + - Quota: 1 unit + + + + By default, opens a browser for read-only package, alert, and threat-feed + + access. Use --api-token for scans and write operations. A sign-in URL is + + printed so you can open it manually if the browser does not launch. + + + + Options + + --api-base-url API server for API-token login + + --api-proxy Proxy to use when making connection to API server + + --api-token Use masked API-token login for scans and write commands + + + + Examples + + $ socket login + + $ socket login --api-token + + $ socket login --api-proxy=http://localhost:1234 + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-logout.help.svg b/test/native-snapshots/socket-help-logout.help.svg new file mode 100644 index 0000000000..090f2c2c38 --- /dev/null +++ b/test/native-snapshots/socket-help-logout.help.svg @@ -0,0 +1,35 @@ + +CLI: v2.1.0-rc.0 | cmd: socket logout | org: (not set) | token: (not set) + + + + + + Socket API logout + + + + Usage + + $ socket logout [options] + + + + Logs out of the Socket API and clears all Socket credentials from disk + + + + Examples + + $ socket logout + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-manifest-auto.help.svg b/test/native-snapshots/socket-help-manifest-auto.help.svg new file mode 100644 index 0000000000..abb95046a4 --- /dev/null +++ b/test/native-snapshots/socket-help-manifest-auto.help.svg @@ -0,0 +1,67 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest auto | org: (not set) | token: (not set) + + + + + + Auto-detect build and attempt to generate manifest file + + + + Usage + + $ socket manifest auto [options] [CWD=.] + + + + Options + + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched rela + +tive to CWD (`--cwd` if set): `tests` matches only `<cwd>/tests`; use `**/tests` to match at any depth. Negation patterns (`!path`) are not + +supported. Accepts a comma-separated value or multiple flags. An excluded JVM build module or .NET project is not analysed as a project of i + +ts own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module or project outs + +ide CWD cannot be excluded. + + --verbose Enable debug output (only for auto itself; sub-steps need to have it pre-configured), may help when running into err + +ors + + + + Tries to figure out what language your target repo uses. If it finds a + + supported case then it will try to generate the manifest file for that + + language with the default or detected settings. + + + + Note: you can exclude languages from being auto-generated if you don't want + + them to. Run `socket manifest setup` in the same dir to disable it. + + + + Examples + + + + $ socket manifest auto + + $ socket manifest auto ./project/foo + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-manifest-bazel.help.svg b/test/native-snapshots/socket-help-manifest-bazel.help.svg new file mode 100644 index 0000000000..a8761ac8df --- /dev/null +++ b/test/native-snapshots/socket-help-manifest-bazel.help.svg @@ -0,0 +1,143 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest bazel | org: (not set) | token: (not set) + + + + + + [beta] Bazel SBOM support — generate manifest files for a Bazel project (Maven, PyPI) + + + + Usage + + $ socket manifest bazel [options] [CWD=.] + + + + Options + + --bazel Path to bazel/bazelisk binary; default: $(which bazelisk) || $(which bazel) + + --bazel-flags Bazel command flags, with quoted values and literal backslashes + + --bazel-output-base Bazel --output_base for read-only-cache CI environments + + --bazel-rc Path to additional .bazelrc fragments forwarded to bazel + + --ecosystem Ecosystem(s) to extract; repeatable. Supported: maven, pypi. Default: maven. + + --out Output directory for generated manifests; default: ./.socket/bazel-manifests/ + + --per-repo-timeout Per-hub bazel cquery timeout in milliseconds; default: 120000 + + --trust-socket-json Run Bazel settings declared in socket.json. Off by default because the scanned repository controls that file. + + --verbose Emit bounded Bazel diagnostics with argv, duration, exit status, and output sizes + + + + [beta] Generates Bazel SBOM manifests for Maven (`maven_install.json`) + + by running `bazel query` against discovered dependency repos. + + PyPI requirements generation is available with `--ecosystem pypi`. + + Output is consumed by + + `socket scan create`'s server-side parser. + + + + --ecosystem may be repeated to select which ecosystems to extract. + + When omitted, Maven is generated by default. PyPI is explicit opt-in. + + + + A socket.json that sets `bazel`/`bin`, `bazelFlags`, `bazelRc`, or + + `bazelOutputBase`, or an `out` outside the project, is refused unless + + you pass --trust-socket-json. Pass the matching command-line flags to + + override these defaults without trusting socket.json. + + + + Note: this command generates dependency manifests for Bazel workspaces. + + It does not run reachability analysis. + + + + Native Windows support currently covers Maven. Use PowerShell or + + Command Prompt with a working Python 3 python.exe and a JDK on PATH. + + PyPI extraction on Windows is not supported yet. + + + + Set SOCKET_BAZEL_OUTPUT_ROOT to a short, writable parent directory, + + such as C:/sb, to shorten connector-owned Bazel output paths. Each + + invocation gets a separate directory. An explicit --bazel-output-base + + takes precedence for Bazel's cache and is kept after the scan. The + + scan only stops Bazel servers it started. + + + + In --bazel-flags, single or double quotes at the start of a value or + + after = group values containing spaces. Backslashes stay literal. Double a matching quote to include + + it within a quoted value. Shell variables are not expanded. + + + + Maven hub discovery: under Bzlmod, hubs are enumerated from + + `bazel mod show_extension` and filtered to the root module's own hubs. + + Under legacy WORKSPACE mode (no `show_extension`), only conventionally + + named hubs are probed (`maven`, `maven_install`, `maven_dev`, …). A hub + + with a non-conventional name that `show_extension` does not enumerate is + + not discovered yet; a flag to name extra hubs is planned. + + + + To generate AND upload in one step, use `socket scan create --auto-manifest` + + instead — it detects Bazel workspaces, generates Maven manifests by + + default, and uploads the result. This subcommand is for generation only. + + + + Examples + + $ socket manifest bazel . + + $ socket manifest bazel --ecosystem pypi . + + $ socket manifest bazel --ecosystem maven --ecosystem pypi . + + $ socket manifest bazel --bazel=/usr/local/bin/bazelisk . + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-manifest-cdxgen.help.svg b/test/native-snapshots/socket-help-manifest-cdxgen.help.svg new file mode 100644 index 0000000000..c2a8be6f24 --- /dev/null +++ b/test/native-snapshots/socket-help-manifest-cdxgen.help.svg @@ -0,0 +1,35 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest cdxgen | org: (not set) | token: (not set) + + + + + +Usage + + socket manifest cdxgen [options] [path] + + + +Options + + -h, --help Show this help + + -o, --output <file> Output file (default: socket-cdx.json) + + -t, --type <type> npm, nodejs, javascript, js, typescript, ts, or tsx + + --lifecycle pre-build Read dependency metadata without running project installs + + + +Generates a CycloneDX SBOM from a root npm package-lock.json v3. + +Only one npm project is supported in this standalone build. Other cdxgen modes are unavailable. + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-manifest-conda.help.svg b/test/native-snapshots/socket-help-manifest-conda.help.svg new file mode 100644 index 0000000000..288f408029 --- /dev/null +++ b/test/native-snapshots/socket-help-manifest-conda.help.svg @@ -0,0 +1,71 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest conda | org: (not set) | token: (not set) + + + + + + [beta] Convert a Conda environment.yml file to a python requirements.txt + + + + Usage + + $ socket manifest conda [options] [CWD=.] + + + + Warning: While we don't support Conda necessarily, this tool extracts the pip + + block from an environment.yml and outputs it as a requirements.txt + + which you can scan as if it were a PyPI package. + + + + USE AT YOUR OWN RISK + + + + Note: FILE can be a dash (-) to indicate stdin. This way you can pipe the + + contents of a file to have it processed. + + + + Options + + --file Input file name (by default for Conda this is "environment.yml"), relative to cwd + + --json Output as JSON + + --markdown Output as Markdown + + --out Output path (relative to cwd) + + --stdin Read the input from stdin (supersedes --file) + + --stdout Print resulting requirements.txt to stdout (supersedes --out) + + --verbose Print debug messages + + + + Examples + + + + $ socket manifest conda + + $ socket manifest conda ./project/foo --file environment.yaml + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-manifest-dotnet.help.svg b/test/native-snapshots/socket-help-manifest-dotnet.help.svg new file mode 100644 index 0000000000..f02cc29ee9 --- /dev/null +++ b/test/native-snapshots/socket-help-manifest-dotnet.help.svg @@ -0,0 +1,97 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest dotnet | org: (not set) | token: (not set) + + + + + + [beta] Generate Socket facts files from .NET solutions and projects + + + + Usage + + $ socket manifest dotnet [options] [CWD=.|FILE] + + + + Options + + --bin Location of the dotnet binary to use, default: dotnet on PATH + + --dotnet-opts Additional options to pass on to `dotnet restore`, e.g. `-p:Configuration=Release --configfile nuget.config`. MSBuil + +d properties among them also apply to project evaluation + + --exclude-configs Comma-separated glob patterns; target frameworks matching any pattern are skipped (applied after --include-configs) + + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched rela + +tive to CWD (`--cwd` if set): `tests` matches only `<cwd>/tests`; use `**/tests` to match at any depth. Negation patterns (`!path`) are not + +supported. Accepts a comma-separated value or multiple flags. An excluded JVM build module or .NET project is not analysed as a project of i + +ts own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module or project outs + +ide CWD cannot be excluded. + + --ignore-unresolved Warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) + + --include-configs Comma-separated glob patterns matched against target frameworks (case-sensitive; `*`, `?`, and `[...]` wildcards). O + +nly target frameworks matching at least one pattern are read, e.g. `net8.0`. A runtime-specific target (`net8.0/win-x64`) also matches its f + +ramework. Default: every target framework + + --verbose Print debug messages + + + + Emits a `<file>.socket.facts.json` per solution or project describing its + + resolved NuGet dependency graph. Given a directory, it builds every + + solution (.sln, .slnx) and project (.csproj, .fsproj, .vbproj) at its top + + level, solutions first, skipping projects a solution already covers; given + + a file, just that one (a .slnf solution filter too). + + + + Dependencies come from your own `dotnet restore` (same SDK, global.json, + + nuget.config and credential providers), so a build that restores for you + + restores here. PackageReference projects (central package management and + + lock files included) and packages.config projects are supported. An + + unresolved dependency is a fatal error unless --ignore-unresolved. + + + + Support is beta. Please report issues or give us feedback on what's missing. + + + + Examples + + + + $ socket manifest dotnet . + + $ socket manifest dotnet ./App.sln + + $ socket manifest dotnet --dotnet-opts="-p:Configuration=Release" . + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-manifest-dynamic-sbom-inference.help.svg b/test/native-snapshots/socket-help-manifest-dynamic-sbom-inference.help.svg new file mode 100644 index 0000000000..4af23bc96b --- /dev/null +++ b/test/native-snapshots/socket-help-manifest-dynamic-sbom-inference.help.svg @@ -0,0 +1,77 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest dynamic-sbom-inference | org: (not set) | token: (not set) + + + + + + Recursively discover .NET, Gradle, Maven, and sbt build roots and generate a Socket facts SBOM for each + + + + Usage + + $ socket manifest dynamic-sbom-inference [options] [CWD=.] + + + + Recursively walks CWD, discovers independent gradle, sbt, and maven build + + roots, and generates a Socket facts SBOM for each + + (pom.xml.socket.facts.json, gradle.socket.facts.json, or + + sbt.socket.facts.json, so builds sharing a directory never overwrite each + + other), + + skipping build roots whose build file a parent build already reports for + + one of its subprojects. Unlike `socket manifest auto`, this looks beyond + + CWD itself. + + + + Options + + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched rela + +tive to CWD (`--cwd` if set): `tests` matches only `<cwd>/tests`; use `**/tests` to match at any depth. Negation patterns (`!path`) are not + +supported. Accepts a comma-separated value or multiple flags. An excluded JVM build module or .NET project is not analysed as a project of i + +ts own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module or project outs + +ide CWD cannot be excluded. + + --ignore-unresolved Warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts files); ov + +errides each build's socket.json setting + + --json Output as JSON + + --markdown Output as Markdown + + --verbose Print debug messages + + + + Examples + + + + $ socket manifest dynamic-sbom-inference + + $ socket manifest dynamic-sbom-inference ./monorepo + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-manifest-gradle.help.svg b/test/native-snapshots/socket-help-manifest-gradle.help.svg new file mode 100644 index 0000000000..ce17f098de --- /dev/null +++ b/test/native-snapshots/socket-help-manifest-gradle.help.svg @@ -0,0 +1,121 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest gradle | org: (not set) | token: (not set) + + + + + + [beta] Generate a Socket facts file (or `pom.xml` with --pom) for a Gradle/Java/Kotlin/etc project + + + + Usage + + $ socket manifest gradle [options] [CWD=.] + + + + Options + + --bin Location of the gradle binary to use, default: ./gradlew if present, else gradle on PATH + + --exclude-configs When generating facts: comma-separated glob patterns; Gradle configurations matching any pattern are skipped (applie + +d after --include-configs) + + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched rela + +tive to CWD (`--cwd` if set): `tests` matches only `<cwd>/tests`; use `**/tests` to match at any depth. Negation patterns (`!path`) are not + +supported. Accepts a comma-separated value or multiple flags. An excluded JVM build module or .NET project is not analysed as a project of i + +ts own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module or project outs + +ide CWD cannot be excluded. + + --facts Emit a Socket facts JSON file (`gradle.socket.facts.json`) describing the resolved dependency graph. This is the def + +ault; pass `--pom` to generate `pom.xml` files instead + + --gradle-opts Additional options to pass on to ./gradlew, see `./gradlew --help` + + --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted + +to the facts file) + + --include-configs When generating facts: comma-separated glob patterns matched against Gradle configuration names (case-sensitive; `*` + +, `?`, and `[...]` wildcards). Only configurations matching at least one pattern are resolved. e.g. `*CompileClasspath,*RuntimeClasspath`. D + +efault: every resolvable configuration + + --pom Generate `pom.xml` manifest file(s) instead of the default Socket facts file (`gradle.socket.facts.json`) + + --verbose Print debug messages + + + + By default, emits a single `gradle.socket.facts.json` describing the + + resolved dependency graph of the whole build, using gradle (preferably your + + local `gradlew`). An unresolved dependency is a fatal error. You can pass + + --include-configs / --exclude-configs (comma-separated glob patterns) to + + control which configurations are resolved (e.g. + + --include-configs=`*CompileClasspath,*RuntimeClasspath`), and + + --ignore-unresolved to warn on unresolved dependencies instead of failing. + + + + Pass --pom to instead generate `pom.xml` manifest files via gradle (one per + + task). The `pom.xml` is a manifest file similar to `package.json` for npm + + (or requirements.txt for PyPi), but specifically for Maven, which is + + Java's dependency repository. Caveats of the `pom.xml` conversion: + + + + - each task generates its own xml file (one per task by default) + + + + - certain features may not translate well into the xml; reach out if + + something you need is missing + + + + - it works with your `gradlew` from your repo and local settings and config + + + + Support is beta. Please report issues or give us feedback on what's missing. + + + + Examples + + + + $ socket manifest gradle . + + $ socket manifest gradle --pom . + + $ socket manifest gradle --bin=../gradlew . + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-manifest-kotlin.help.svg b/test/native-snapshots/socket-help-manifest-kotlin.help.svg new file mode 100644 index 0000000000..ac7269b4b0 --- /dev/null +++ b/test/native-snapshots/socket-help-manifest-kotlin.help.svg @@ -0,0 +1,121 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest kotlin | org: (not set) | token: (not set) + + + + + + [beta] Generate a Socket facts file (or `pom.xml` with --pom) for a Kotlin project + + + + Usage + + $ socket manifest kotlin [options] [CWD=.] + + + + Options + + --bin Location of the gradle binary to use, default: ./gradlew if present, else gradle on PATH + + --exclude-configs When generating facts: comma-separated glob patterns; Gradle configurations matching any pattern are skipped (applie + +d after --include-configs) + + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched rela + +tive to CWD (`--cwd` if set): `tests` matches only `<cwd>/tests`; use `**/tests` to match at any depth. Negation patterns (`!path`) are not + +supported. Accepts a comma-separated value or multiple flags. An excluded JVM build module or .NET project is not analysed as a project of i + +ts own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module or project outs + +ide CWD cannot be excluded. + + --facts Emit a Socket facts JSON file (`gradle.socket.facts.json`) describing the resolved dependency graph. This is the def + +ault; pass `--pom` to generate `pom.xml` files instead + + --gradle-opts Additional options to pass on to ./gradlew, see `./gradlew --help` + + --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted + +to the facts file) + + --include-configs When generating facts: comma-separated glob patterns matched against Gradle configuration names (case-sensitive; `*` + +, `?`, and `[...]` wildcards). Only configurations matching at least one pattern are resolved. e.g. `*CompileClasspath,*RuntimeClasspath`. D + +efault: every resolvable configuration + + --pom Generate `pom.xml` manifest file(s) instead of the default Socket facts file (`gradle.socket.facts.json`) + + --verbose Print debug messages + + + + By default, emits a single `gradle.socket.facts.json` describing the + + resolved dependency graph of the whole build, using gradle (preferably your + + local `gradlew`). An unresolved dependency is a fatal error. You can pass + + --include-configs / --exclude-configs (comma-separated glob patterns) to + + control which configurations are resolved (e.g. + + --include-configs=`*CompileClasspath,*RuntimeClasspath`), and + + --ignore-unresolved to warn on unresolved dependencies instead of failing. + + + + Pass --pom to instead generate `pom.xml` manifest files via gradle (one per + + task). The `pom.xml` is a manifest file similar to `package.json` for npm + + (or requirements.txt for PyPi), but specifically for Maven, which is + + Java's dependency repository. Caveats of the `pom.xml` conversion: + + + + - each task generates its own xml file (one per task by default) + + + + - certain features may not translate well into the xml; reach out if + + something you need is missing + + + + - it works with your `gradlew` from your repo and local settings and config + + + + Support is beta. Please report issues or give us feedback on what's missing. + + + + Examples + + + + $ socket manifest kotlin . + + $ socket manifest kotlin --pom . + + $ socket manifest kotlin --bin=../gradlew . + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-manifest-maven.help.svg b/test/native-snapshots/socket-help-manifest-maven.help.svg new file mode 100644 index 0000000000..3c589db9a6 --- /dev/null +++ b/test/native-snapshots/socket-help-manifest-maven.help.svg @@ -0,0 +1,93 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest maven | org: (not set) | token: (not set) + + + + + + [beta] Generate a Socket facts file from a Maven `pom.xml` project + + + + Usage + + $ socket manifest maven [options] [CWD=.] + + + + Options + + --bin Location of the maven binary to use, default: ./mvnw if present, else mvn on PATH + + --exclude-configs Comma-separated glob patterns; Maven scopes matching any pattern are skipped (applied after --include-configs) + + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched rela + +tive to CWD (`--cwd` if set): `tests` matches only `<cwd>/tests`; use `**/tests` to match at any depth. Negation patterns (`!path`) are not + +supported. Accepts a comma-separated value or multiple flags. An excluded JVM build module or .NET project is not analysed as a project of i + +ts own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module or project outs + +ide CWD cannot be excluded. + + --ignore-unresolved Warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file) + + --include-configs Comma-separated glob patterns matched against Maven dependency scopes (case-sensitive; `*`, `?`, and `[...]` wildcar + +ds). Only scopes matching at least one pattern are resolved. e.g. `compile,runtime`. Default: every scope + + --maven-opts Additional options to pass on to maven, e.g. `-P <profile> -s <settings.xml>` + + --verbose Print debug messages + + + + Emits a single `pom.xml.socket.facts.json` (named after the POM Maven + + runs on) describing the resolved dependency graph of your Maven project, using maven (`mvn` on PATH by default). It + + reads dependency metadata only and never downloads artifacts; an unresolved + + dependency is a fatal error. You can pass --include-configs / + + --exclude-configs (comma-separated glob patterns) to control which Maven + + scopes are resolved (e.g. --include-configs=`compile,runtime`), and + + --ignore-unresolved to warn on unresolved dependencies instead of failing. + + + + You can specify --bin to override the path to the `mvn` binary to invoke + + (e.g. a project `./mvnw` wrapper), and --maven-opts to pass extra options + + through to maven (e.g. `-P <profile> -s <settings.xml>`). + + + + Support is beta. Please report issues or give us feedback on what's missing. + + + + Examples + + + + $ socket manifest maven . + + $ socket manifest maven --bin=./mvnw . + + $ socket manifest maven --maven-opts="-P release" . + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-manifest-scala.help.svg b/test/native-snapshots/socket-help-manifest-scala.help.svg new file mode 100644 index 0000000000..bf83e6fbf0 --- /dev/null +++ b/test/native-snapshots/socket-help-manifest-scala.help.svg @@ -0,0 +1,153 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest scala | org: (not set) | token: (not set) + + + + + + [beta] Generate a Socket facts file (or `pom.xml` with --pom) from a Scala `build.sbt` project + + + + Usage + + $ socket manifest scala [options] [CWD=.] + + + + Options + + --bin Location of sbt binary to use + + --exclude-configs When generating facts: comma-separated glob patterns; sbt configurations matching any pattern are skipped (applied a + +fter --include-configs) + + --exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched rela + +tive to CWD (`--cwd` if set): `tests` matches only `<cwd>/tests`; use `**/tests` to match at any depth. Negation patterns (`!path`) are not + +supported. Accepts a comma-separated value or multiple flags. An excluded JVM build module or .NET project is not analysed as a project of i + +ts own, but where a project that is not excluded depends on it, its code is analysed as part of that project. A build module or project outs + +ide CWD cannot be excluded. + + --facts Emit a Socket facts JSON file (`sbt.socket.facts.json`) describing the resolved dependency graph. This is the defaul + +t; pass `--pom` to generate `pom.xml` files instead + + --ignore-unresolved When generating facts: warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted + +to the facts file) + + --include-configs When generating facts: comma-separated glob patterns matched against sbt configuration names (case-sensitive; `*`, ` + +?`, and `[...]` wildcards). Only configurations matching at least one pattern are resolved. e.g. `compile,test`. Default: compile,optional,p + +rovided,runtime,test + + --out Only with --pom: path of the output `pom.xml`, see also --stdout. Does not apply when generating Socket facts (alway + +s written to the project root as `sbt.socket.facts.json`) + + --pom Generate `pom.xml` manifest file(s) instead of the default Socket facts file (`sbt.socket.facts.json`) + + --sbt-opts Additional options to pass on to sbt, as per `sbt --help` + + --stdout Only with --pom: print the resulting `pom.xml` to stdout (supersedes --out). Does not apply when generating Socket f + +acts + + --verbose Print debug messages + + + + By default, emits a single `sbt.socket.facts.json` describing the + + resolved dependency graph of the whole build. It reads dependency metadata only and + + never downloads artifacts; an unresolved dependency is a fatal error. You + + can pass --include-configs / --exclude-configs (comma-separated glob + + patterns) to control which sbt configurations are resolved (e.g. + + --include-configs=`compile,test`), and --ignore-unresolved to warn on + + unresolved dependencies instead of failing the run. + + + + Pass --pom to instead generate a `pom.xml` via `sbt makePom` from your + + `build.sbt`. The xml is the dependency manifest (like a package.json for + + Node.js or requirements.txt for PyPi), but specifically for Scala. + + Caveats of the `build.sbt` to `pom.xml` conversion: + + + + - the xml is exported as pom.xml at the project root so Socket scan picks + + it up; sbt itself first writes it inside your /target/sbt<version> folder + + (as a different name). Use --out to override if you already have a + + hand-authored pom.xml at the project root. + + + + - the pom.xml format (standard by Scala) does not support certain sbt features + + - `excludeAll()`, `dependencyOverrides`, `force()`, `relativePath` + + - For details: https://www.scala-sbt.org/1.x/docs/Library-Management.html + + + + - it uses your sbt settings and local configuration verbatim + + + + - it can only export one target per run, so if you have multiple targets like + + development and production, you must run them separately. + + + + You can specify --bin to override the path to the `sbt` binary to invoke. + + + + Support is beta. Please report issues or give us feedback on what's missing. + + + + This is only for SBT. If your Scala setup uses gradle, please see the help + + sections for `socket manifest gradle` or `socket cdxgen`. + + + + Examples + + + + $ socket manifest scala + + $ socket manifest scala --pom . + + $ socket manifest scala ./proj --bin=/usr/bin/sbt --file=boot.sbt + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-manifest-setup.help.svg b/test/native-snapshots/socket-help-manifest-setup.help.svg new file mode 100644 index 0000000000..cc8c3a096d --- /dev/null +++ b/test/native-snapshots/socket-help-manifest-setup.help.svg @@ -0,0 +1,91 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest setup | org: (not set) | token: (not set) + + + + + + Start interactive configurator to customize default flag values for `socket manifest` in this dir + + + + Usage + + $ socket manifest setup [CWD=.] + + + + Options + + --default-on-read-error If reading the socket.json fails, just use a default config? Warning: This might override the existing json fil + +e! + + --dynamic-sbom-inference Uses .NET, Gradle, Maven, and sbt tools to generate dynamic SBOMs with more accurate results than static resol + +ution. It scans build roots under CWD, asks only about detected ecosystems, then disables roots matched by --exclude-paths and configures th + +e rest individually. + + --exclude-paths Build roots matching these glob patterns (and everything beneath them) are marked disabled. Patterns are anchored mi + +cromatch globs matched relative to CWD: `legacy` matches only `<cwd>/legacy`; use `**/legacy` to match at any depth. Negation patterns (`!pa + +th`) are not supported. Accepts a comma-separated value or multiple flags. + + + + This command will try to detect all supported ecosystems in given CWD. Then + + it starts a configurator where you can setup default values for certain flags + + when creating manifest files in that dir. These configuration details are + + then stored in a local `socket.json` file (which you may or may not commit + + to the repo). Next time you run `socket manifest ...` it will load this + + json file and any flags which are not explicitly set in the command but which + + have been registered in the json file will get the default value set to that + + value you stored rather than the hardcoded defaults. + + + + This helps with for example when your build binary is in a particular path + + or when your build tool needs specific opts and you don't want to specify + + them when running the command every time. + + + + You can also disable manifest generation for certain ecosystems. + + + + This generated configuration file will only be used locally by the CLI. You + + can commit it to the repo (useful for collaboration) or choose to add it to + + your .gitignore all the same. Only this CLI will use it. + + + + Examples + + $ socket manifest setup + + $ socket manifest setup ./proj + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-manifest.help.svg b/test/native-snapshots/socket-help-manifest.help.svg new file mode 100644 index 0000000000..0d04ba2ba1 --- /dev/null +++ b/test/native-snapshots/socket-help-manifest.help.svg @@ -0,0 +1,57 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest | org: (not set) | token: (not set) + + + + Generate a dependency manifest for certain ecosystems + + + + Usage + + $ socket manifest <command> + + + + Commands + + auto Auto-detect build and attempt to generate manifest file + + bazel [beta] Bazel SBOM support — generate manifest files for a Bazel project (Maven, PyPI) + + cdxgen Run cdxgen for SBOM generation + + conda [beta] Convert a Conda environment.yml file to a python requirements.txt + + dotnet [beta] Generate Socket facts files from .NET solutions and projects + + dynamic-sbom-inference Recursively discover .NET, Gradle, Maven, and sbt build roots and generate a Socket facts SBOM for each + + gradle [beta] Generate a Socket facts file (or `pom.xml` with --pom) for a Gradle/Java/Kotlin/etc project + + kotlin [beta] Generate a Socket facts file (or `pom.xml` with --pom) for a Kotlin project + + maven [beta] Generate a Socket facts file from a Maven `pom.xml` project + + scala [beta] Generate a Socket facts file (or `pom.xml` with --pom) from a Scala `build.sbt` project + + setup Start interactive configurator to customize default flag values for `socket manifest` in this dir + + + + Options + + + + --no-banner Hide the Socket banner + + --no-spinner Hide the console spinner + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-npm.help.svg b/test/native-snapshots/socket-help-npm.help.svg new file mode 100644 index 0000000000..8ae06e612b --- /dev/null +++ b/test/native-snapshots/socket-help-npm.help.svg @@ -0,0 +1,53 @@ + +CLI: v2.1.0-rc.0 | cmd: socket npm | org: (not set) | token: (not set) + + + + + + Wraps npm with Socket security scanning + + + + Usage + + $ socket npm ... + + + + API Token Requirements + + - Quota: 100 units + + - Permissions: packages:list + + + + Note: Everything after "npm" is passed to the npm command. + + Only the `--dry-run` and `--help` flags are caught here. + + + + Use `socket wrapper on` to alias this command as `npm`. + + + + Examples + + $ socket npm + + $ socket npm install -g cowsay + + $ socket npm exec cowsay + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-npx.help.svg b/test/native-snapshots/socket-help-npx.help.svg new file mode 100644 index 0000000000..9f9cff68ec --- /dev/null +++ b/test/native-snapshots/socket-help-npx.help.svg @@ -0,0 +1,51 @@ + +CLI: v2.1.0-rc.0 | cmd: socket npx | org: (not set) | token: (not set) + + + + + + Wraps npx with Socket security scanning + + + + Usage + + $ socket npx ... + + + + API Token Requirements + + - Quota: 100 units + + - Permissions: packages:list + + + + Note: Everything after "npx" is passed to the npx command. + + Only the `--dry-run` and `--help` flags are caught here. + + + + Use `socket wrapper on` to alias this command as `npx`. + + + + Examples + + $ socket npx cowsay + + $ socket npx cowsay@1.6.0 hello + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-oops.help.svg b/test/native-snapshots/socket-help-oops.help.svg new file mode 100644 index 0000000000..95206af845 --- /dev/null +++ b/test/native-snapshots/socket-help-oops.help.svg @@ -0,0 +1,29 @@ + +CLI: v2.1.0-rc.0 | cmd: socket oops | org: (not set) | token: (not set) + + + + + + Trigger an intentional error (for development) + + + + Usage + + $ socket oops oops + + + + Don't run me. + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-optimize.help.svg b/test/native-snapshots/socket-help-optimize.help.svg new file mode 100644 index 0000000000..c12e3390df --- /dev/null +++ b/test/native-snapshots/socket-help-optimize.help.svg @@ -0,0 +1,49 @@ + +CLI: v2.1.0-rc.0 | cmd: socket optimize | org: (not set) | token: (not set) + + + + + + Optimize dependencies with @socketregistry overrides + + + + Usage + + $ socket optimize [options] [CWD=.] + + + + API Token Requirements + + - Quota: 100 units + + - Permissions: packages:list + + + + Options + + --pin Pin overrides to latest version + + --prod Add overrides for production dependencies only + + + + Examples + + $ socket optimize + + $ socket optimize ./path/to/project --pin + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-organization-dependencies.help.svg b/test/native-snapshots/socket-help-organization-dependencies.help.svg new file mode 100644 index 0000000000..457cbad151 --- /dev/null +++ b/test/native-snapshots/socket-help-organization-dependencies.help.svg @@ -0,0 +1,51 @@ + +CLI: v2.1.0-rc.0 | cmd: socket organization dependencies | org: (not set) | token: (not set) + + + + + + Search for any dependency that is being used in your organization + + + + Usage + + socket organization dependencies [options] + + + + API Token Requirements + + - Quota: 1 unit + + + + Options + + --json Output as JSON + + --limit Maximum number of dependencies returned + + --markdown Output as Markdown + + --offset Page number + + + + Examples + + socket organization dependencies + + socket organization dependencies --limit 20 --offset 10 + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-organization-list.help.svg b/test/native-snapshots/socket-help-organization-list.help.svg new file mode 100644 index 0000000000..5d2c988982 --- /dev/null +++ b/test/native-snapshots/socket-help-organization-list.help.svg @@ -0,0 +1,47 @@ + +CLI: v2.1.0-rc.0 | cmd: socket organization list | org: (not set) | token: (not set) + + + + + + List organizations associated with the Socket API token + + + + Usage + + $ socket organization list [options] + + + + API Token Requirements + + - Quota: 1 unit + + + + Options + + --json Output as JSON + + --markdown Output as Markdown + + + + Examples + + $ socket organization list + + $ socket organization list --json + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-organization-policy-license.help.svg b/test/native-snapshots/socket-help-organization-policy-license.help.svg new file mode 100644 index 0000000000..a544d3622b --- /dev/null +++ b/test/native-snapshots/socket-help-organization-policy-license.help.svg @@ -0,0 +1,61 @@ + +CLI: v2.1.0-rc.0 | cmd: socket organization policy license | org: (not set) | token: (not set) + + + + + + Retrieve the license policy of an organization + + + + Usage + + $ socket organization policy license [options] + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: license-policy:read + + + + Options + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + + + Your API token will need the `license-policy:read` permission otherwise + + the request will fail with an authentication error. + + + + Examples + + $ socket organization policy license + + $ socket organization policy license --json + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-organization-policy-security.help.svg b/test/native-snapshots/socket-help-organization-policy-security.help.svg new file mode 100644 index 0000000000..c6a48d8281 --- /dev/null +++ b/test/native-snapshots/socket-help-organization-policy-security.help.svg @@ -0,0 +1,61 @@ + +CLI: v2.1.0-rc.0 | cmd: socket organization policy security | org: (not set) | token: (not set) + + + + + + Retrieve the security policy of an organization + + + + Usage + + $ socket organization policy security [options] + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: security-policy:read + + + + Options + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + + + Your API token will need the `security-policy:read` permission otherwise + + the request will fail with an authentication error. + + + + Examples + + $ socket organization policy security + + $ socket organization policy security --json + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-organization-policy.help.svg b/test/native-snapshots/socket-help-organization-policy.help.svg new file mode 100644 index 0000000000..9469984afd --- /dev/null +++ b/test/native-snapshots/socket-help-organization-policy.help.svg @@ -0,0 +1,37 @@ + +CLI: v2.1.0-rc.0 | cmd: socket organization policy | org: (not set) | token: (not set) + + + + Organization policy details + + + + Usage + + $ socket organization policy <command> + + + + Commands + + license Retrieve the license policy of an organization + + + + Options + + + + --no-banner Hide the Socket banner + + --no-spinner Hide the console spinner + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-organization-quota.help.svg b/test/native-snapshots/socket-help-organization-quota.help.svg new file mode 100644 index 0000000000..f52b521ace --- /dev/null +++ b/test/native-snapshots/socket-help-organization-quota.help.svg @@ -0,0 +1,41 @@ + +CLI: v2.1.0-rc.0 | cmd: socket organization quota | org: (not set) | token: (not set) + + + + + + List organizations associated with the Socket API token + + + + Usage + + $ socket organization quota [options] + + + + Options + + --json Output as JSON + + --markdown Output as Markdown + + + + Examples + + $ socket organization quota + + $ socket organization quota --json + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-organization.help.svg b/test/native-snapshots/socket-help-organization.help.svg new file mode 100644 index 0000000000..9d8d3f1d9a --- /dev/null +++ b/test/native-snapshots/socket-help-organization.help.svg @@ -0,0 +1,41 @@ + +CLI: v2.1.0-rc.0 | cmd: socket organization | org: (not set) | token: (not set) + + + + Manage Socket organization account details + + + + Usage + + $ socket organization <command> + + + + Commands + + dependencies Search for any dependency that is being used in your organization + + list List organizations associated with the Socket API token + + policy Organization policy details + + + + Options + + + + --no-banner Hide the Socket banner + + --no-spinner Hide the console spinner + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-package-score.help.svg b/test/native-snapshots/socket-help-package-score.help.svg new file mode 100644 index 0000000000..7639a20b0d --- /dev/null +++ b/test/native-snapshots/socket-help-package-score.help.svg @@ -0,0 +1,91 @@ + +CLI: v2.1.0-rc.0 | cmd: socket package score | org: (not set) | token: (not set) + + + + + + Look up score for one package which reflects all of its transitive dependencies as well + + + + Usage + + $ socket package score [options] <<ECOSYSTEM> <NAME> | <PURL>> + + + + API Token Requirements + + - Quota: 100 units + + - Permissions: packages:list + + + + Options + + --json Output as JSON + + --markdown Output as Markdown + + + + Show deep scoring details for one package. The score will reflect the package + + itself, any of its dependencies, and any of its transitive dependencies. + + + + When you want to know whether to trust a package, this is the command to run. + + + + See also the `socket package shallow` command, which returns the shallow + + score for any number of packages. That will not reflect the dependency scores. + + + + Only a few ecosystems are supported like npm, pypi, nuget, gem, golang, and maven. + + + + A "purl" is a standard package name formatting: `pkg:eco/name@version` + + This command will automatically prepend "pkg:" when not present. + + + + The version is optional but when given should be a direct match. The `pkg:` + + prefix is optional. + + + + Note: if a package cannot be found it may be too old or perhaps was removed + + before we had the opportunity to process it. + + + + Examples + + $ socket package score npm babel-cli + + $ socket package score npm eslint@1.0.0 --json + + $ socket package score pkg:golang/github.com/steelpoor/tlsproxy@v0.0.0-20250304082521-29051ed19c60 + + $ socket package score nuget/needpluscommonlibrary@1.0.0 --markdown + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-package-shallow.help.svg b/test/native-snapshots/socket-help-package-shallow.help.svg new file mode 100644 index 0000000000..868246d173 --- /dev/null +++ b/test/native-snapshots/socket-help-package-shallow.help.svg @@ -0,0 +1,89 @@ + +CLI: v2.1.0-rc.0 | cmd: socket package shallow | org: (not set) | token: (not set) + + + + + + Look up info regarding one or more packages but not their transitives + + + + Usage + + $ socket package shallow [options] <<ECOSYSTEM> <PKGNAME> [<PKGNAME> ...] | <PURL> [<PURL> ...]> + + + + API Token Requirements + + - Quota: 100 units + + - Permissions: packages:list + + + + Options + + --json Output as JSON + + --markdown Output as Markdown + + + + Show scoring details for one or more packages purely based on their own package. + + This means that any dependency scores are not reflected by the score. You can + + use the `socket package score <pkg>` command to get its full transitive score. + + + + Only a few ecosystems are supported like npm, pypi, nuget, gem, golang, and maven. + + + + A "purl" is a standard package name formatting: `pkg:eco/name@version` + + This command will automatically prepend "pkg:" when not present. + + + + If the first arg is an ecosystem, remaining args that are not a purl are + + assumed to be scoped to that ecosystem. The `pkg:` prefix is optional. + + + + Note: if a package cannot be found, it may be too old or perhaps was removed + + before we had the opportunity to process it. + + + + Examples + + $ socket package shallow npm webtorrent + + $ socket package shallow npm webtorrent@1.9.1 + + $ socket package shallow npm/webtorrent@1.9.1 + + $ socket package shallow pkg:npm/webtorrent@1.9.1 + + $ socket package shallow maven webtorrent babel + + $ socket package shallow npm/webtorrent golang/babel + + $ socket package shallow npm npm/webtorrent@1.0.1 babel + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-package.help.svg b/test/native-snapshots/socket-help-package.help.svg new file mode 100644 index 0000000000..16b8e931c8 --- /dev/null +++ b/test/native-snapshots/socket-help-package.help.svg @@ -0,0 +1,39 @@ + +CLI: v2.1.0-rc.0 | cmd: socket package | org: (not set) | token: (not set) + + + + Look up published package details + + + + Usage + + $ socket package <command> + + + + Commands + + score Look up score for one package which reflects all of its transitive dependencies as well + + shallow Look up info regarding one or more packages but not their transitives + + + + Options + + + + --no-banner Hide the Socket banner + + --no-spinner Hide the console spinner + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-patch.help.svg b/test/native-snapshots/socket-help-patch.help.svg new file mode 100644 index 0000000000..f32a0e1191 --- /dev/null +++ b/test/native-snapshots/socket-help-patch.help.svg @@ -0,0 +1,74 @@ + +CLI tool for applying security patches to dependencies + + + +Usage: + +socket-patch + <COMMAND> + + + +Commands: + + + +apply + Apply security patches to dependencies + + +rollback + Rollback patches to restore original files + + +get + Get security patches from Socket API and apply them [aliases: download] + + +scan + Scan installed packages for available security patches + + +list + List all patches in the local manifest + + +remove + Remove a patch from the manifest by PURL or UUID (rolls back files first) + + +setup + Configure package.json postinstall scripts to apply patches + + +repair + Download missing blobs and clean up unused blobs [aliases: gc] + + +help + Print this message or the help of the given subcommand(s) + + + +Options: + + + +-h +, +--help + Print help + + +-V +, +--version + Print version + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-pnpm.help.svg b/test/native-snapshots/socket-help-pnpm.help.svg new file mode 100644 index 0000000000..4a2341d7b2 --- /dev/null +++ b/test/native-snapshots/socket-help-pnpm.help.svg @@ -0,0 +1,53 @@ + +CLI: v2.1.0-rc.0 | cmd: socket pnpm | org: (not set) | token: (not set) + + + + + + Wraps pnpm with Socket security scanning + + + + Usage + + $ socket pnpm ... + + + + API Token Requirements + + (none) + + + + Note: Everything after "pnpm" is passed to the pnpm command. + + Only the `--dry-run` and `--help` flags are caught here. + + + + Use `socket wrapper on` to alias this command as `pnpm`. + + + + Examples + + $ socket pnpm + + $ socket pnpm install + + $ socket pnpm add package-name + + $ socket pnpm dlx package-name + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-raw-npm.help.svg b/test/native-snapshots/socket-help-raw-npm.help.svg new file mode 100644 index 0000000000..1f9ac5e396 --- /dev/null +++ b/test/native-snapshots/socket-help-raw-npm.help.svg @@ -0,0 +1,45 @@ + +CLI: v2.1.0-rc.0 | cmd: socket raw-npm | org: (not set) | token: (not set) + + + + + + Run npm without the Socket wrapper + + + + Usage + + $ socket raw-npm ... + + + + Execute `npm` without gating installs through the Socket API. + + Useful when `socket wrapper on` is enabled and you want to bypass + + the Socket wrapper. Use at your own risk. + + + + Note: Everything after "raw-npm" is passed to the npm command. + + Only the `--dry-run` and `--help` flags are caught here. + + + + Examples + + $ socket raw-npm install -g cowsay + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-raw-npx.help.svg b/test/native-snapshots/socket-help-raw-npx.help.svg new file mode 100644 index 0000000000..53feedf8e5 --- /dev/null +++ b/test/native-snapshots/socket-help-raw-npx.help.svg @@ -0,0 +1,45 @@ + +CLI: v2.1.0-rc.0 | cmd: socket raw-npx | org: (not set) | token: (not set) + + + + + + Run npx without the Socket wrapper + + + + Usage + + $ socket raw-npx ... + + + + Execute `npx` without gating installs through the Socket API. + + Useful when `socket wrapper on` is enabled and you want to bypass + + the Socket wrapper. Use at your own risk. + + + + Note: Everything after "raw-npx" is passed to the npx command. + + Only the `--dry-run` and `--help` flags are caught here. + + + + Examples + + $ socket raw-npx cowsay + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-repository-create.help.svg b/test/native-snapshots/socket-help-repository-create.help.svg new file mode 100644 index 0000000000..27ecc262e2 --- /dev/null +++ b/test/native-snapshots/socket-help-repository-create.help.svg @@ -0,0 +1,67 @@ + +CLI: v2.1.0-rc.0 | cmd: socket repository create | org: (not set) | token: (not set) + + + + + + Create a repository in an organization + + + + Usage + + $ socket repository create [options] <REPO> + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: repo:create + + + + The REPO name should be a "slug". Follows the same naming convention as GitHub. + + + + Options + + --default-branch Repository default branch. Defaults to "main" + + --homepage Repository url + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + --repo-description Repository description + + --visibility Repository visibility (Default Private) + + + + Examples + + $ socket repository create test-repo + + $ socket repository create our-repo --homepage=socket.dev --default-branch=trunk + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-repository-del.help.svg b/test/native-snapshots/socket-help-repository-del.help.svg new file mode 100644 index 0000000000..f4b8a65b46 --- /dev/null +++ b/test/native-snapshots/socket-help-repository-del.help.svg @@ -0,0 +1,53 @@ + +CLI: v2.1.0-rc.0 | cmd: socket repository del | org: (not set) | token: (not set) + + + + + + Delete a repository in an organization + + + + Usage + + $ socket repository del [options] <REPO> + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: repo:delete + + + + Options + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + + + Examples + + $ socket repository del test-repo + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-repository-list.help.svg b/test/native-snapshots/socket-help-repository-list.help.svg new file mode 100644 index 0000000000..bc8846aa9c --- /dev/null +++ b/test/native-snapshots/socket-help-repository-list.help.svg @@ -0,0 +1,67 @@ + +CLI: v2.1.0-rc.0 | cmd: socket repository list | org: (not set) | token: (not set) + + + + + + List repositories in an organization + + + + Usage + + $ socket repository list [options] + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: repo:list + + + + Options + + --all By default view shows the last n repos. This flag allows you to fetch the entire list. Will ignore --page and --per- + +page. + + --direction Direction option + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + --page Page number + + --per-page Number of results per page + + --sort Sorting option + + + + Examples + + $ socket repository list + + $ socket repository list --json + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-repository-update.help.svg b/test/native-snapshots/socket-help-repository-update.help.svg new file mode 100644 index 0000000000..4d4a0e5208 --- /dev/null +++ b/test/native-snapshots/socket-help-repository-update.help.svg @@ -0,0 +1,63 @@ + +CLI: v2.1.0-rc.0 | cmd: socket repository update | org: (not set) | token: (not set) + + + + + + Update a repository in an organization + + + + Usage + + $ socket repository update [options] <REPO> + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: repo:update + + + + Options + + --default-branch Repository default branch + + --homepage Repository url + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + --repo-description Repository description + + --visibility Repository visibility (Default Private) + + + + Examples + + $ socket repository update test-repo + + $ socket repository update test-repo --homepage https://example.com + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-repository-view.help.svg b/test/native-snapshots/socket-help-repository-view.help.svg new file mode 100644 index 0000000000..3dfc4f71d0 --- /dev/null +++ b/test/native-snapshots/socket-help-repository-view.help.svg @@ -0,0 +1,55 @@ + +CLI: v2.1.0-rc.0 | cmd: socket repository view | org: (not set) | token: (not set) + + + + + + View repositories in an organization + + + + Usage + + $ socket repository view [options] <REPO> + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: repo:list + + + + Options + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + + + Examples + + $ socket repository view test-repo + + $ socket repository view test-repo --json + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-repository.help.svg b/test/native-snapshots/socket-help-repository.help.svg new file mode 100644 index 0000000000..276403cbaf --- /dev/null +++ b/test/native-snapshots/socket-help-repository.help.svg @@ -0,0 +1,45 @@ + +CLI: v2.1.0-rc.0 | cmd: socket repository | org: (not set) | token: (not set) + + + + Manage registered repositories + + + + Usage + + $ socket repository <command> + + + + Commands + + create Create a repository in an organization + + del Delete a repository in an organization + + list List repositories in an organization + + update Update a repository in an organization + + view View repositories in an organization + + + + Options + + + + --no-banner Hide the Socket banner + + --no-spinner Hide the console spinner + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-scan-create.help.svg b/test/native-snapshots/socket-help-scan-create.help.svg new file mode 100644 index 0000000000..3f5ba2171f --- /dev/null +++ b/test/native-snapshots/socket-help-scan-create.help.svg @@ -0,0 +1,265 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan create | org: (not set) | token: (not set) + + + + + + Create a new Socket scan and report + + + + Usage + + $ socket scan create [options] [TARGET...] + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: full-scans:create + + + + Options + + --auto-manifest Run `socket manifest auto` before collecting manifest files, which generates them for ecosystems that need their bui + +ld tool to resolve dependencies, such as Scala, Gradle, and Kotlin. See `socket manifest auto --help`. For Gradle, sbt, Maven and .NET, --dy + +namic-sbom-inference generates a Socket facts SBOM per build root instead. + + --branch Branch name + + --commit-hash Commit hash + + --commit-message Commit message + + --committers Committers + + --cwd working directory, defaults to process.cwd() + + --default-branch Set the default branch of the repository to the branch of this full-scan. Should only need to be done once, for exam + +ple for the "main" or "master" branch. + + --dynamic-sbom-inference For Gradle, sbt, Maven and .NET: generate a Socket facts SBOM (produced directly by each package manager) per + +independent build root, instead of one synthetic root. Combine with --reach to split the reachability analysis per project/module. + + --dynamic-sbom-inference-ecosystems With --dynamic-sbom-inference: generate Socket facts only for these ecosystems, as either a comma s + +eparated value or as multiple flags; the others keep their manifest files as they are. Supported: maven (Gradle, Maven, and sbt) and nuget ( + +.NET). Defaults to all of them. + + --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) + +full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the comman + +d working directory (`--cwd` if set), not the reachability target: `tests` matches only `<cwd>/tests`; use `**/tests` to match at any depth. + + Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags. An excluded JVM build module or .NET proj + +ect is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that pr + +oject. A build module or project outside the scan root cannot be excluded. + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + --pull-request Pull request number + + --reach Run full application reachability analysis + + --read-only Similar to --dry-run except it can read from remote, stops before it would create an actual report + + --repo Repository name + + --report Wait for the scan creation to complete, then basically run `socket scan report` on it + + --report-level Which policy level alerts should be reported (default 'error') + + --set-as-alerts-page When true and if this is the "default branch" then this Scan will be the one reflected on your alerts page. See he + +lp for details. Defaults to true. + + --tmp Set the visibility (true/false) of the scan in your dashboard. + + --uv-members Scan each TARGET directory as a uv project, using the versions pinned in its uv.lock or its workspace root uv.lock. + +Uploads a CycloneDX dependency graph per TARGET in place of manifest discovery, including all extras and dependency groups. Requires uv on P + +ATH; standalone builds use SOCKET_CLI_UV_PATH. + + --workspace The workspace in the Socket Organization that the repository is in to associate with the full scan. + + + + Reachability Options (when --reach is used) + + --reach-advisories List of advisories to run reachability analysis for, as GHSA or CVE IDs, either as a comma separated value or as mul + +tiple flags. CVE IDs are converted to GHSA IDs through the GitHub API. Vulnerabilities from other advisories get unknown reachability. Defau + +lts to all advisories. + + --reach-analysis-memory-limit The maximum memory for the reachability analysis as a whole number optionally followed by MB or GB (e.g. + +512MB, 8GB). The default is 8GB. + + --reach-analysis-timeout Set the timeout for the reachability analysis as a whole number optionally followed by s, m or h (e.g. 90s, 10 + +m, 1h). Defaults to 10m. Split analysis runs may cause the total scan time to exceed this timeout significantly. + + --reach-concurrency Set the maximum number of concurrent reachability analysis runs. It is recommended to choose a concurrency level th + +at ensures each analysis run has at least the --reach-analysis-memory-limit amount of memory available. + + --reach-continue-on-analysis-errors Continue reachability analysis when errors occur (timeouts, OOM, parse errors, etc.), falling back + +to precomputed reachability results. By default, the CLI halts on analysis errors. + + --reach-continue-on-install-errors Continue reachability analysis when package installation fails, falling back to precomputed reachabi + +lity results. By default, the CLI halts on installation errors. + + --reach-continue-on-missing-lock-files Continue reachability analysis when a Gradle or SBT project is missing its lock file (or version + + catalog / pre-generated SBOM). By default, the CLI halts. + + --reach-continue-on-no-source-files Continue reachability analysis when a workspace contains no source files for its ecosystem. By defa + +ult, the CLI halts. + + --reach-debug Enable debug mode for reachability analysis. Provides verbose logging from the reachability CLI. + + --reach-detailed-analysis-log-file A log file with detailed analysis logs is written to root of each analyzed workspace. + + --reach-disable-analytics Disable reachability analytics sharing with Socket. Also disables caching-based optimizations. + + --reach-disable-dependency-downloads Only analyze dependencies that are already installed (e.g. in the project's .venv, vendor director + +y or module cache). Missing dependencies count as installation failures. + + --reach-disable-external-tool-checks Disable external tool checks during reachability analysis. + + --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Supp + +orted: cargo, composer, gem, golang, maven, npm, nuget, pypi. Defaults to all supported ecosystems. + + --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysi + +s runs. May improve accuracy, but not recommended by default. + + --reach-fallback-to-regular-scan If reachability analysis fails, continue with a regular SCA scan (without reachability results) instea + +d of halting. By default, the CLI halts on reachability errors. + + --reach-retain-facts-file Keep the `.socket.facts.json` reachability report that the analysis writes to the scan directory instead of d + +eleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as a + +n SBOM carrying stale reachability results. + + --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous + + runs to speed up the analysis. + + --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your + +project. + + --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: 15.15.0. + + + + Uploads the specified dependency manifest files for Go, Gradle, JavaScript, + + Kotlin, Python, and Scala. Files like "package.json" and "requirements.txt". + + If any folder is specified, the ones found in there recursively are uploaded. + + + + Details on TARGET: + + + + - Defaults to the current dir (cwd) if none given + + - Multiple targets can be specified + + - If a target is a file, only that file is checked + + - If it is a dir, the dir is scanned for any supported manifest files + + - Dirs MUST be within the current dir (cwd), you can use --cwd to change it + + - Supports globbing such as "**/package.json", "**/requirements.txt", etc. + + - Ignores any file specified in your project's ".gitignore" + + - Also a sensible set of default ignores from the "ignore-by-default" module + + + + The --repo and --branch flags tell Socket to associate this Scan with that + + repo/branch. The names will show up on your dashboard on the Socket website. + + + + Note: for a first run you probably want to set --default-branch to indicate + + the default branch name, like "main" or "master". + + + + The "alerts page" (​https://socket.dev/dashboard/org/YOURORG/alerts​) will show + + the results from the last scan designated as the "pending head" on the branch + + configured on Socket to be the "default branch". When creating a scan the + + --set-as-alerts-page flag will default to true to update this. You can prevent + + this by using --no-set-as-alerts-page. This flag is ignored for any branch that + + is not designated as the "default branch". It is disabled when using --tmp. + + + + You can use `socket scan setup` to configure certain repo flag defaults. + + + + Examples + + $ socket scan create + + $ socket scan create ./proj --json + + $ socket scan create --repo=test-repo --branch=main ./package.json + + $ socket scan create --uv-members ./packages/api ./packages/worker + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-scan-del.help.svg b/test/native-snapshots/socket-help-scan-del.help.svg new file mode 100644 index 0000000000..7aaf492532 --- /dev/null +++ b/test/native-snapshots/socket-help-scan-del.help.svg @@ -0,0 +1,55 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan del | org: (not set) | token: (not set) + + + + + + Delete a scan + + + + Usage + + $ socket scan del [options] <SCAN_ID> + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: full-scans:delete + + + + Options + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + + + Examples + + $ socket scan del 000aaaa1-0000-0a0a-00a0-00a0000000a0 + + $ socket scan del 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-scan-diff.help.svg b/test/native-snapshots/socket-help-scan-diff.help.svg new file mode 100644 index 0000000000..0d8821ed6e --- /dev/null +++ b/test/native-snapshots/socket-help-scan-diff.help.svg @@ -0,0 +1,75 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan diff | org: (not set) | token: (not set) + + + + + + See what changed between two Scans + + + + Usage + + $ socket scan diff [options] <SCAN_ID1> <SCAN_ID2> + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: full-scans:list + + + + This command displays the package changes between two scans. The full output + + can be pretty large depending on the size of your repo and time range. It is + + best stored to disk (with --json) to be further analyzed by other tools. + + + + Note: While it will work in any order, the first Scan ID is assumed to be the + + older ID, even if it is a newer Scan. This is only relevant for the + + added/removed list (similar to diffing two files with git). + + + + Options + + --depth Max depth of JSON to display before truncating, use zero for no limit (without --json/--file) + + --file Path to a local file where the output should be saved. Use `-` to force stdout. + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + + + Examples + + $ socket scan diff aaa0aa0a-aaaa-0000-0a0a-0000000a00a0 aaa1aa1a-aaaa-1111-1a1a-1111111a11a1 + + $ socket scan diff aaa0aa0a-aaaa-0000-0a0a-0000000a00a0 aaa1aa1a-aaaa-1111-1a1a-1111111a11a1 --json + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-scan-github.help.svg b/test/native-snapshots/socket-help-scan-github.help.svg new file mode 100644 index 0000000000..3e44f369b9 --- /dev/null +++ b/test/native-snapshots/socket-help-scan-github.help.svg @@ -0,0 +1,93 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan github | org: (not set) | token: (not set) + + + + + + Create a scan for given GitHub repo + + + + Usage + + $ socket scan github [options] [CWD=.] + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: full-scans:create + + + + This is similar to the `socket scan create` command except it pulls the files + + from GitHub. See the help for that command for more details. + + + + A GitHub Personal Access Token (PAT) will at least need read access to the repo + + ("contents", read-only) for this command to work. + + + + Note: This command cannot run the `socket manifest auto` things because that + + requires local access to the repo while this command runs entirely through the + + GitHub for file access. + + + + You can use `socket scan setup` to configure certain repo flag defaults. + + + + Options + + --all Apply for all known repositories reported by the Socket API. Supersedes `repos`. + + --github-api-url Base URL of the GitHub API (default: https://api.github.com) + + --github-token Required GitHub token for authentication. + + May set environment variable GITHUB_TOKEN or SOCKET_CLI_GITHUB_TOKEN instead. + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + --org-github Alternate GitHub Org if the name is different than the Socket Org + + --repos List of repos to target in a comma-separated format (e.g., repo1,repo2). If not specified, the script will pull the + +list from Socket and ask you to pick one. Use --all to use them all. + + + + Examples + + $ socket scan github + + $ socket scan github ./proj + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-scan-list.help.svg b/test/native-snapshots/socket-help-scan-list.help.svg new file mode 100644 index 0000000000..f11a0d61cc --- /dev/null +++ b/test/native-snapshots/socket-help-scan-list.help.svg @@ -0,0 +1,77 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan list | org: (not set) | token: (not set) + + + + + + List the scans for an organization + + + + Usage + + $ socket scan list [options] [REPO [BRANCH]] + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: full-scans:list + + + + Optionally filter by REPO. If you specify a repo, you can also specify a + + branch to filter by. (Note: If you don't specify a repo then you must use + + `--branch` to filter by branch across all repos). + + + + Options + + --branch Filter to show only scans with this branch name + + --direction Direction option (`desc` or `asc`) - Default is `desc` + + --from-time From time - as a unix timestamp + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + --page Page number - Default is 1 + + --per-page Results per page - Default is 30 + + --sort Sorting option (`name` or `created_at`) - default is `created_at` + + --until-time Until time - as a unix timestamp + + + + Examples + + $ socket scan list + + $ socket scan list webtools badbranch --markdown + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-scan-metadata.help.svg b/test/native-snapshots/socket-help-scan-metadata.help.svg new file mode 100644 index 0000000000..49b1a76423 --- /dev/null +++ b/test/native-snapshots/socket-help-scan-metadata.help.svg @@ -0,0 +1,55 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan metadata | org: (not set) | token: (not set) + + + + + + Get a scan's metadata + + + + Usage + + $ socket scan metadata [options] <SCAN_ID> + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: full-scans:list + + + + Options + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + + + Examples + + $ socket scan metadata 000aaaa1-0000-0a0a-00a0-00a0000000a0 + + $ socket scan metadata 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-scan-reach.help.svg b/test/native-snapshots/socket-help-scan-reach.help.svg new file mode 100644 index 0000000000..f211674b83 --- /dev/null +++ b/test/native-snapshots/socket-help-scan-reach.help.svg @@ -0,0 +1,177 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan reach | org: (not set) | token: (not set) + + + + + + Compute full application reachability + + + + Usage + + $ socket scan reach [options] [CWD=.] + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: full-scans:create + + + + Options + + --cwd working directory, defaults to process.cwd() + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + --output Path to write the reachability report to (must end with .json). Defaults to .socket.facts.json in the current workin + +g directory. + + + + Reachability Options + + --dynamic-sbom-inference For Gradle, sbt, Maven and .NET: generate a Socket facts SBOM (produced directly by each package manager) per + +independent build root, instead of one synthetic root. The reachability analysis is split per project/module accordingly. + + --dynamic-sbom-inference-ecosystems With --dynamic-sbom-inference: generate Socket facts only for these ecosystems, as either a comma s + +eparated value or as multiple flags; the others keep their manifest files as they are. Supported: maven (Gradle, Maven, and sbt) and nuget ( + +.NET). Defaults to all of them. + + --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) + +full application reachability analysis. Patterns are anchored micromatch globs matched relative to the Socket scan root, which is the comman + +d working directory (`--cwd` if set), not the reachability target: `tests` matches only `<cwd>/tests`; use `**/tests` to match at any depth. + + Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags. An excluded JVM build module or .NET proj + +ect is not analysed as a project of its own, but where a project that is not excluded depends on it, its code is analysed as part of that pr + +oject. A build module or project outside the scan root cannot be excluded. + + --reach-advisories List of advisories to run reachability analysis for, as GHSA or CVE IDs, either as a comma separated value or as mul + +tiple flags. CVE IDs are converted to GHSA IDs through the GitHub API. Vulnerabilities from other advisories get unknown reachability. Defau + +lts to all advisories. + + --reach-analysis-memory-limit The maximum memory for the reachability analysis as a whole number optionally followed by MB or GB (e.g. + +512MB, 8GB). The default is 8GB. + + --reach-analysis-timeout Set the timeout for the reachability analysis as a whole number optionally followed by s, m or h (e.g. 90s, 10 + +m, 1h). Defaults to 10m. Split analysis runs may cause the total scan time to exceed this timeout significantly. + + --reach-concurrency Set the maximum number of concurrent reachability analysis runs. It is recommended to choose a concurrency level th + +at ensures each analysis run has at least the --reach-analysis-memory-limit amount of memory available. + + --reach-continue-on-analysis-errors Continue reachability analysis when errors occur (timeouts, OOM, parse errors, etc.), falling back + +to precomputed reachability results. By default, the CLI halts on analysis errors. + + --reach-continue-on-install-errors Continue reachability analysis when package installation fails, falling back to precomputed reachabi + +lity results. By default, the CLI halts on installation errors. + + --reach-continue-on-missing-lock-files Continue reachability analysis when a Gradle or SBT project is missing its lock file (or version + + catalog / pre-generated SBOM). By default, the CLI halts. + + --reach-continue-on-no-source-files Continue reachability analysis when a workspace contains no source files for its ecosystem. By defa + +ult, the CLI halts. + + --reach-debug Enable debug mode for reachability analysis. Provides verbose logging from the reachability CLI. + + --reach-detailed-analysis-log-file A log file with detailed analysis logs is written to root of each analyzed workspace. + + --reach-disable-analytics Disable reachability analytics sharing with Socket. Also disables caching-based optimizations. + + --reach-disable-dependency-downloads Only analyze dependencies that are already installed (e.g. in the project's .venv, vendor director + +y or module cache). Missing dependencies count as installation failures. + + --reach-disable-external-tool-checks Disable external tool checks during reachability analysis. + + --reach-ecosystems List of ecosystems to conduct reachability analysis on, as either a comma separated value or as multiple flags. Supp + +orted: cargo, composer, gem, golang, maven, npm, nuget, pypi. Defaults to all supported ecosystems. + + --reach-enable-analysis-splitting Allow the reachability analysis to partition CVEs into buckets that are processed in separate analysi + +s runs. May improve accuracy, but not recommended by default. + + --reach-retain-facts-file Keep the `.socket.facts.json` reachability report that the analysis writes to the scan directory instead of d + +eleting it after a successful scan. Delete it once inspected: later reachability scans ignore it, but a scan without --reach uploads it as a + +n SBOM carrying stale reachability results. + + --reach-skip-cache Skip caching-based optimizations. By default, the reachability analysis will use cached configurations from previous + + runs to speed up the analysis. + + --reach-use-only-pregenerated-sboms When using this option, the scan is created based only on pre-generated CDX and SPDX files in your + +project. + + --reach-version Override the version of @coana-tech/cli used for reachability analysis. Default: 15.15.0. + + + + Runs the Socket reachability analysis without creating a scan in Socket. + + The output is written to .socket.facts.json in the current working directory + + unless the --output flag is specified. + + + + Note: Manifest files are uploaded to Socket's backend services because the + + reachability analysis requires creating a Software Bill of Materials (SBOM) + + from these files before the analysis can run. + + + + Examples + + $ socket scan reach + + $ socket scan reach ./proj + + $ socket scan reach ./proj --reach-ecosystems npm,pypi + + $ socket scan reach ./monorepo --dynamic-sbom-inference + + $ socket scan reach --output custom-report.json + + $ socket scan reach ./proj --output ./reports/analysis.json + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-scan-report.help.svg b/test/native-snapshots/socket-help-scan-report.help.svg new file mode 100644 index 0000000000..4c17428f8f --- /dev/null +++ b/test/native-snapshots/socket-help-scan-report.help.svg @@ -0,0 +1,113 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan report | org: (not set) | token: (not set) + + + + + + Check whether a scan result passes the organizational policies (security, license) + + + + Usage + + $ socket scan report [options] <SCAN_ID> [OUTPUT_PATH] + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: full-scans:list + + + + Options + + --fold Fold reported alerts to some degree (default 'none') + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --license Also report the license policy status. Default: false + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + --report-level Which policy level alerts should be reported (default 'warn') + + --short Report only the healthy status + + + + When no output path is given the contents is sent to stdout. + + + + By default the result is a nested object that looks like this: + + `{ + + [ecosystem]: { + + [pkgName]: { + + [version]: { + + [file]: { + + [line:col]: alert + + }}}}` + + So one alert for each occurrence in every file, version, etc, a huge response. + + + + You can --fold these up to given level: 'pkg', 'version', 'file', and 'none'. + + For example: `socket scan report --fold=version` will dedupe alerts to only + + show one alert of a particular kind, no matter how often it was found in a + + file or in how many files it was found. At most one per version that has it. + + + + By default only the warn and error policy level alerts are reported. You can + + override this and request more ('defer' < 'ignore' < 'monitor' < 'warn' < 'error') + + + + Short responses look like this: + + --json: `{healthy:bool}` + + --markdown: `healthy = bool` + + neither: `OK/ERR` + + + + Examples + + $ socket scan report 000aaaa1-0000-0a0a-00a0-00a0000000a0 --json --fold=version + + $ socket scan report 000aaaa1-0000-0a0a-00a0-00a0000000a0 --license --markdown --short + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-scan-setup.help.svg b/test/native-snapshots/socket-help-scan-setup.help.svg new file mode 100644 index 0000000000..b93c7fd602 --- /dev/null +++ b/test/native-snapshots/socket-help-scan-setup.help.svg @@ -0,0 +1,65 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan setup | org: (not set) | token: (not set) + + + + + + Start interactive configurator to customize default flag values for `socket scan` in this dir + + + + Usage + + $ socket scan setup [options] [CWD=.] + + + + Options + + --default-on-read-error If reading the socket.json fails, just use a default config? Warning: This might override the existing json fil + +e! + + + + Interactive configurator to create a local json file in the target directory + + that helps to set flag defaults for `socket scan create`. + + + + This helps to configure the (Socket reported) repo and branch names, as well + + as which branch name is the "default branch" (main, master, etc). This way + + you don't have to specify these flags when creating a scan in this dir. + + + + This generated configuration file will only be used locally by the CLI. You + + can commit it to the repo (useful for collaboration) or choose to add it to + + your .gitignore all the same. Only this CLI will use it. + + + + Examples + + + + $ socket scan setup + + $ socket scan setup ./proj + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-scan-view.help.svg b/test/native-snapshots/socket-help-scan-view.help.svg new file mode 100644 index 0000000000..bbe0379188 --- /dev/null +++ b/test/native-snapshots/socket-help-scan-view.help.svg @@ -0,0 +1,61 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan view | org: (not set) | token: (not set) + + + + + + View the raw results of a scan + + + + Usage + + $ socket scan view [options] <SCAN_ID> [OUTPUT_FILE] + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: full-scans:list + + + + When no output path is given the contents is sent to stdout. + + + + Options + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + --stream Only valid with --json. Streams the response as "ndjson" (chunks of valid json blobs). + + + + Examples + + $ socket scan view 000aaaa1-0000-0a0a-00a0-00a0000000a0 + + $ socket scan view 000aaaa1-0000-0a0a-00a0-00a0000000a0 ./stream.txt + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-scan.help.svg b/test/native-snapshots/socket-help-scan.help.svg new file mode 100644 index 0000000000..e1e8e4f2ee --- /dev/null +++ b/test/native-snapshots/socket-help-scan.help.svg @@ -0,0 +1,53 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan | org: (not set) | token: (not set) + + + + Manage Socket scans + + + + Usage + + $ socket scan <command> + + + + Commands + + create Create a new Socket scan and report + + del Delete a scan + + diff See what changed between two Scans + + list List the scans for an organization + + metadata Get a scan's metadata + + reach Compute full application reachability + + report Check whether a scan result passes the organizational policies (security, license) + + setup Start interactive configurator to customize default flag values for `socket scan` in this dir + + view View the raw results of a scan + + + + Options + + + + --no-banner Hide the Socket banner + + --no-spinner Hide the console spinner + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-security.help.svg b/test/native-snapshots/socket-help-security.help.svg new file mode 100644 index 0000000000..de0b37d0ef --- /dev/null +++ b/test/native-snapshots/socket-help-security.help.svg @@ -0,0 +1,59 @@ + +CLI: v2.1.0-rc.0 | cmd: socket security | org: (not set) | token: (not set) + + + + + + Retrieve the security policy of an organization + + + + Usage + + $ socket security [options] + + + + API Token Requirements + + (none) + + + + Options + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + + + Your API token will need the `security-policy:read` permission otherwise + + the request will fail with an authentication error. + + + + Examples + + $ socket security + + $ socket security --json + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-threat-feed.help.svg b/test/native-snapshots/socket-help-threat-feed.help.svg new file mode 100644 index 0000000000..d3d9608b11 --- /dev/null +++ b/test/native-snapshots/socket-help-threat-feed.help.svg @@ -0,0 +1,155 @@ + +CLI: v2.1.0-rc.0 | cmd: socket threat-feed | org: (not set) | token: (not set) + + + + + + [Beta] View the threat-feed + + + + Usage + + $ socket threat-feed [options] [ECOSYSTEM] [TYPE_FILTER] + + + + API Token Requirements + + - Quota: 1 unit + + - Permissions: threat-feed:list + + - Special access + + + + This feature requires a Threat Feed license. Please contact + + sales@socket.dev (​mailto:sales@socket.dev​) if you are interested in purchasing this access. + + + + Options + + --direction Order asc or desc by the createdAt attribute + + --eco Only show threats for a particular ecosystem + + --filter Filter what type of threats to return + + --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting th + +em to cancel/no. + + --json Output as JSON + + --markdown Output as Markdown + + --org Force override the organization slug, overrides the default org from config + + --page Page token + + --per-page Number of items per page + + --pkg Filter by this package name + + --version Filter by this package version + + + + Valid ecosystems: + + + + - gem + + - golang + + - maven + + - npm + + - nuget + + - pypi + + + + Valid type filters: + + + + - anom Anomaly + + - c Do not filter + + - fp False Positives + + - joke Joke / Fake + + - mal Malware and Possible Malware [default] + + - secret Secrets + + - spy Telemetry + + - tp False Positives and Unreviewed + + - typo Typo-squat + + - u Unreviewed + + - vuln Vulnerability + + + + Note: if you filter by package name or version, it will do so for anything + + unless you also filter by that ecosystem and/or package name. When in + + doubt, look at the threat-feed and see the names in the name/version + + column. That's what you want to search for. + + + + You can put filters as args instead, we'll try to match the strings with the + + correct filter type but since this would not allow you to search for a package + + called "mal", you can also specify the filters through flags. + + + + First arg that matches a typo, eco, or version enum is used as such. First arg + + that matches none of them becomes the package name filter. Rest is ignored. + + + + Note: The version filter is a prefix search, pkg name is a substring search. + + + + Examples + + $ socket threat-feed + + $ socket threat-feed maven --json + + $ socket threat-feed typo + + $ socket threat-feed npm joke 1.0.0 --per-page=5 --page=2 --direction=asc + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-uninstall-completion.help.svg b/test/native-snapshots/socket-help-uninstall-completion.help.svg new file mode 100644 index 0000000000..1d981a06b3 --- /dev/null +++ b/test/native-snapshots/socket-help-uninstall-completion.help.svg @@ -0,0 +1,57 @@ + +CLI: v2.1.0-rc.0 | cmd: socket uninstall completion | org: (not set) | token: (not set) + + + + + + Uninstall bash completion for Socket CLI + + + + Usage + + $ socket uninstall completion [options] [COMMAND_NAME=socket] + + + + Uninstalls bash tab completion for the Socket CLI. This will: + + 1. Remove tab completion from your current shell for given command + + 2. Remove the setup for given command from your ~/.bashrc + + + + The optional name is required if you installed tab completion for an alias + + other than the default "socket". This will NOT remove the command, only the + + tab completion that is registered for it in bash. + + + + Options + + (none) + + + + Examples + + + + $ socket uninstall completion + + $ socket uninstall completion sd + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-uninstall.help.svg b/test/native-snapshots/socket-help-uninstall.help.svg new file mode 100644 index 0000000000..d3eb02bef3 --- /dev/null +++ b/test/native-snapshots/socket-help-uninstall.help.svg @@ -0,0 +1,37 @@ + +CLI: v2.1.0-rc.0 | cmd: socket uninstall | org: (not set) | token: (not set) + + + + Uninstall Socket CLI tab completion + + + + Usage + + $ socket uninstall <command> + + + + Commands + + completion Uninstall bash completion for Socket CLI + + + + Options + + + + --no-banner Hide the Socket banner + + --no-spinner Hide the console spinner + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-wrapper.help.svg b/test/native-snapshots/socket-help-wrapper.help.svg new file mode 100644 index 0000000000..5ff4e906e2 --- /dev/null +++ b/test/native-snapshots/socket-help-wrapper.help.svg @@ -0,0 +1,47 @@ + +CLI: v2.1.0-rc.0 | cmd: socket wrapper | org: (not set) | token: (not set) + + + + + + Enable or disable the Socket npm/npx wrapper + + + + Usage + + $ socket wrapper <"on" | "off"> + + + + Options + + (none) + + + + While enabled, the wrapper makes it so that when you call npm/npx on your + + machine, it will automatically actually run `socket npm` / `socket npx` + + instead. + + + + Examples + + $ socket wrapper on + + $ socket wrapper off + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help-yarn.help.svg b/test/native-snapshots/socket-help-yarn.help.svg new file mode 100644 index 0000000000..fcaf4ccec9 --- /dev/null +++ b/test/native-snapshots/socket-help-yarn.help.svg @@ -0,0 +1,53 @@ + +CLI: v2.1.0-rc.0 | cmd: socket yarn | org: (not set) | token: (not set) + + + + + + Wraps yarn with Socket security scanning + + + + Usage + + $ socket yarn ... + + + + API Token Requirements + + (none) + + + + Note: Everything after "yarn" is passed to the yarn command. + + Only the `--dry-run` and `--help` flags are caught here. + + + + Use `socket wrapper on` to alias this command as `yarn`. + + + + Examples + + $ socket yarn + + $ socket yarn install + + $ socket yarn add package-name + + $ socket yarn dlx package-name + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-help.help.svg b/test/native-snapshots/socket-help.help.svg new file mode 100644 index 0000000000..53c852012f --- /dev/null +++ b/test/native-snapshots/socket-help.help.svg @@ -0,0 +1,129 @@ + +CLI: v2.1.0-rc.0 | cmd: socket | org: (not set) | token: (not set) + + + + CLI for Socket.dev + + + + Usage + + $ socket <command> + + $ socket scan create --json + + $ socket package score npm lodash --markdown + + + + Note: All commands have their own --help + + + + Main commands + + socket login Sign in to Socket and configure the CLI + + socket scan create Create a new Socket scan and report + + socket npm/lodash@4.17.21 Request the Socket score of a package + + socket fix Fix CVEs in dependencies + + socket optimize Optimize dependencies with @socketregistry overrides + + socket cdxgen Run cdxgen for SBOM generation + + socket ci Alias for `socket scan create --report` (creates report and exits with error if unhealthy) + + + + Socket API + + analytics Look up analytics data + + audit-log Look up the audit log for an organization + + organization Manage Socket organization account details + + package Look up published package details + + repository Manage registered repositories + + scan Manage Socket scans + + threat-feed [Beta] View the threat-feed + + + + Local tools + + manifest Generate a dependency manifest for certain ecosystems + + npm Wraps npm with Socket security scanning + + npx Wraps npx with Socket security scanning + + patch Apply, manage, and rollback Socket security patches for vulnerable dependencies + + raw-npm Run npm without the Socket wrapper + + raw-npx Run npx without the Socket wrapper + + + + CLI configuration + + config Manage Socket CLI configuration + + install Install Socket CLI tab completion + + login Socket API login and CLI setup + + logout Socket API logout + + uninstall Uninstall Socket CLI tab completion + + wrapper Enable or disable the Socket npm/npx wrapper + + + + Options + + Note: All commands have these flags even when not displayed in their help + + + + --compact-header Use compact single-line header format (auto-enabled in CI) + + --config Override the local config with this JSON + + --dry-run Run without uploading + + --help Show help + + --help-full Show full help including environment variables + + --no-banner Hide the Socket banner + + --no-spinner Hide the console spinner + + --version Print the app version + + + + Environment variables [more...] + + Use +--help-full + to view all environment variables + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-manifest-dry-run.dry-run.svg b/test/native-snapshots/socket-manifest-dry-run.dry-run.svg new file mode 100644 index 0000000000..0abee68111 --- /dev/null +++ b/test/native-snapshots/socket-manifest-dry-run.dry-run.svg @@ -0,0 +1,13 @@ + +CLI: v2.1.0-rc.0 | cmd: socket manifest auto | org: (not set) | token: en*** (--config flag) + + + +[DryRun]: Bailing now + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-patch-apply-help.help.svg b/test/native-snapshots/socket-patch-apply-help.help.svg new file mode 100644 index 0000000000..527447816c --- /dev/null +++ b/test/native-snapshots/socket-patch-apply-help.help.svg @@ -0,0 +1,89 @@ + +Apply security patches to dependencies + + + +Usage: + +socket-patch apply + [OPTIONS] + + + +Options: + + + +--cwd + <CWD> Working directory [default: .] + + +-d +, +--dry-run + Verify patches can be applied without modifying files + + +-s +, +--silent + Only output errors + + +-m +, +--manifest-path + <MANIFEST_PATH> Path to patch manifest file [default: .socket/manifest.json] + + +--offline + Do not download missing blobs, fail if any are missing + + +-g +, +--global + Apply patches to globally installed npm packages + + +--global-prefix + <GLOBAL_PREFIX> Custom path to global node_modules + + +--ecosystems + <ECOSYSTEMS> Restrict patching to specific ecosystems + + +-f +, +--force + Skip pre-application hash verification (apply even if package version differs) + + +--json + Output results as JSON + + +-v +, +--verbose + Show detailed per-file verification information + + +-h +, +--help + Print help + + +-V +, +--version + Print version + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-patch-get-help.help.svg b/test/native-snapshots/socket-patch-get-help.help.svg new file mode 100644 index 0000000000..6a877c8dd7 --- /dev/null +++ b/test/native-snapshots/socket-patch-get-help.help.svg @@ -0,0 +1,102 @@ + +Get security patches from Socket API and apply them + + + +Usage: + +socket-patch get + [OPTIONS] <IDENTIFIER> + + + +Arguments: + + + <IDENTIFIER> Patch identifier (UUID, CVE ID, GHSA ID, PURL, or package name) + + + +Options: + + + +--org + <ORG> Organization slug + + +--cwd + <CWD> Working directory [default: .] + + +--id + Force identifier to be treated as a patch UUID + + +--cve + Force identifier to be treated as a CVE ID + + +--ghsa + Force identifier to be treated as a GHSA ID + + +-p +, +--package + Force identifier to be treated as a package name + + +-y +, +--yes + Skip confirmation prompt for multiple patches + + +--api-url + <API_URL> Socket API URL (overrides SOCKET_API_URL env var) + + +--api-token + <API_TOKEN> Socket API token (overrides SOCKET_API_TOKEN env var) + + +--save-only + Download patch without applying it + + +-g +, +--global + Apply patch to globally installed npm packages + + +--global-prefix + <GLOBAL_PREFIX> Custom path to global node_modules + + +--one-off + Apply patch immediately without saving to .socket folder + + +--json + Output results as JSON + + +-h +, +--help + Print help + + +-V +, +--version + Print version + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-patch-list-help.help.svg b/test/native-snapshots/socket-patch-list-help.help.svg new file mode 100644 index 0000000000..10714b34ae --- /dev/null +++ b/test/native-snapshots/socket-patch-list-help.help.svg @@ -0,0 +1,47 @@ + +List all patches in the local manifest + + + +Usage: + +socket-patch list + [OPTIONS] + + + +Options: + + + +--cwd + <CWD> Working directory [default: .] + + +-m +, +--manifest-path + <MANIFEST_PATH> Path to patch manifest file [default: .socket/manifest.json] + + +--json + Output as JSON + + +-h +, +--help + Print help + + +-V +, +--version + Print version + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-patch-remove-help.help.svg b/test/native-snapshots/socket-patch-remove-help.help.svg new file mode 100644 index 0000000000..bb44419cdd --- /dev/null +++ b/test/native-snapshots/socket-patch-remove-help.help.svg @@ -0,0 +1,74 @@ + +Remove a patch from the manifest by PURL or UUID (rolls back files first) + + + +Usage: + +socket-patch remove + [OPTIONS] <IDENTIFIER> + + + +Arguments: + + + <IDENTIFIER> Package PURL or patch UUID + + + +Options: + + + +--cwd + <CWD> Working directory [default: .] + + +-m +, +--manifest-path + <MANIFEST_PATH> Path to patch manifest file [default: .socket/manifest.json] + + +--skip-rollback + Skip rolling back files before removing (only update manifest) + + +-y +, +--yes + Skip confirmation prompts + + +-g +, +--global + Remove patches from globally installed npm packages + + +--global-prefix + <GLOBAL_PREFIX> Custom path to global node_modules + + +--json + Output results as JSON + + +-h +, +--help + Print help + + +-V +, +--version + Print version + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-patch-repair-help.help.svg b/test/native-snapshots/socket-patch-repair-help.help.svg new file mode 100644 index 0000000000..f3f99b9430 --- /dev/null +++ b/test/native-snapshots/socket-patch-repair-help.help.svg @@ -0,0 +1,61 @@ + +Download missing blobs and clean up unused blobs + + + +Usage: + +socket-patch repair + [OPTIONS] + + + +Options: + + + +--cwd + <CWD> Working directory [default: .] + + +-m +, +--manifest-path + <MANIFEST_PATH> Path to patch manifest file [default: .socket/manifest.json] + + +-d +, +--dry-run + Show what would be done without actually doing it + + +--offline + Skip network operations (cleanup only) + + +--download-only + Only download missing blobs, do not clean up + + +--json + Output results as JSON + + +-h +, +--help + Print help + + +-V +, +--version + Print version + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-patch-rollback-help.help.svg b/test/native-snapshots/socket-patch-rollback-help.help.svg new file mode 100644 index 0000000000..43538f39e2 --- /dev/null +++ b/test/native-snapshots/socket-patch-rollback-help.help.svg @@ -0,0 +1,106 @@ + +Rollback patches to restore original files + + + +Usage: + +socket-patch rollback + [OPTIONS] [IDENTIFIER] + + + +Arguments: + + + [IDENTIFIER] Package PURL or patch UUID to rollback. Omit to rollback all patches + + + +Options: + + + +--cwd + <CWD> Working directory [default: .] + + +-d +, +--dry-run + Verify rollback can be performed without modifying files + + +-s +, +--silent + Only output errors + + +-m +, +--manifest-path + <MANIFEST_PATH> Path to patch manifest file [default: .socket/manifest.json] + + +--offline + Do not download missing blobs, fail if any are missing + + +-g +, +--global + Rollback patches from globally installed npm packages + + +--global-prefix + <GLOBAL_PREFIX> Custom path to global node_modules + + +--one-off + Rollback a patch by fetching beforeHash blobs from API (no manifest required) + + +--org + <ORG> Organization slug + + +--api-url + <API_URL> Socket API URL (overrides SOCKET_API_URL env var) + + +--api-token + <API_TOKEN> Socket API token (overrides SOCKET_API_TOKEN env var) + + +--ecosystems + <ECOSYSTEMS> Restrict rollback to specific ecosystems + + +--json + Output results as JSON + + +-v +, +--verbose + Show detailed per-file verification information + + +-h +, +--help + Print help + + +-V +, +--version + Print version + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-patch-scan-help.help.svg b/test/native-snapshots/socket-patch-scan-help.help.svg new file mode 100644 index 0000000000..7afbb32b35 --- /dev/null +++ b/test/native-snapshots/socket-patch-scan-help.help.svg @@ -0,0 +1,77 @@ + +Scan installed packages for available security patches + + + +Usage: + +socket-patch scan + [OPTIONS] + + + +Options: + + + +--cwd + <CWD> Working directory [default: .] + + +--org + <ORG> Organization slug + + +--json + Output results as JSON + + +-y +, +--yes + Skip confirmation prompts + + +-g +, +--global + Scan globally installed npm packages + + +--global-prefix + <GLOBAL_PREFIX> Custom path to global node_modules + + +--batch-size + <BATCH_SIZE> Number of packages to query per API request [default: 100] + + +--api-url + <API_URL> Socket API URL (overrides SOCKET_API_URL env var) + + +--api-token + <API_TOKEN> Socket API token (overrides SOCKET_API_TOKEN env var) + + +--ecosystems + <ECOSYSTEMS> Restrict scanning to specific ecosystems (comma-separated: npm,pypi,cargo,maven) + + +-h +, +--help + Print help + + +-V +, +--version + Print version + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-patch-setup-help.help.svg b/test/native-snapshots/socket-patch-setup-help.help.svg new file mode 100644 index 0000000000..4d2dde0b4a --- /dev/null +++ b/test/native-snapshots/socket-patch-setup-help.help.svg @@ -0,0 +1,53 @@ + +Configure package.json postinstall scripts to apply patches + + + +Usage: + +socket-patch setup + [OPTIONS] + + + +Options: + + + +--cwd + <CWD> Working directory [default: .] + + +-d +, +--dry-run + Preview changes without modifying files + + +-y +, +--yes + Skip confirmation prompt + + +--json + Output results as JSON + + +-h +, +--help + Print help + + +-V +, +--version + Print version + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-scan-setup.canceled.svg b/test/native-snapshots/socket-scan-setup.canceled.svg new file mode 100644 index 0000000000..e29d582686 --- /dev/null +++ b/test/native-snapshots/socket-scan-setup.canceled.svg @@ -0,0 +1,58 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan setup | org: (not set) | token: (not set) + + + +ℹ + No socket.json found at <NATIVE_FIXTURE>, will generate a new one + + + +Note: This tool will set up flag and argument defaults for certain + + CLI commands. You can still override them by explicitly + + setting the flag. It is meant to be a convenience tool. + + + +This command will generate a `socket.json` file in the target cwd. + +You can choose to add this file to your repo (handy for collab) + +or to add it to the ignored files, or neither. This file is only + +used in CLI workflows. + + + +Note: For details on a flag you can run `socket <cmd> --help` + + + +✔ + +Which scan command do you want to configure? + +(cancel) + + + + +ℹ + User canceled + + + + + +Finished + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-scan-setup.github-selected.svg b/test/native-snapshots/socket-scan-setup.github-selected.svg new file mode 100644 index 0000000000..2d6a200658 --- /dev/null +++ b/test/native-snapshots/socket-scan-setup.github-selected.svg @@ -0,0 +1,56 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan setup | org: (not set) | token: (not set) + + + +ℹ + No socket.json found at <NATIVE_FIXTURE>, will generate a new one + + + +Note: This tool will set up flag and argument defaults for certain + + CLI commands. You can still override them by explicitly + + setting the flag. It is meant to be a convenience tool. + + + +This command will generate a `socket.json` file in the target cwd. + +You can choose to add this file to your repo (handy for collab) + +or to add it to the ignored files, or neither. This file is only + +used in CLI workflows. + + + +Note: For details on a flag you can run `socket <cmd> --help` + + + +? + +Which scan command do you want to configure? + + + socket scan create + +❯ socket scan github + + + (cancel) + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-scan-setup.menu.svg b/test/native-snapshots/socket-scan-setup.menu.svg new file mode 100644 index 0000000000..9b249e645b --- /dev/null +++ b/test/native-snapshots/socket-scan-setup.menu.svg @@ -0,0 +1,58 @@ + +CLI: v2.1.0-rc.0 | cmd: socket scan setup | org: (not set) | token: (not set) + + + +ℹ + No socket.json found at <NATIVE_FIXTURE>, will generate a new one + + + +Note: This tool will set up flag and argument defaults for certain + + CLI commands. You can still override them by explicitly + + setting the flag. It is meant to be a convenience tool. + + + +This command will generate a `socket.json` file in the target cwd. + +You can choose to add this file to your repo (handy for collab) + +or to add it to the ignored files, or neither. This file is only + +used in CLI workflows. + + + +Note: For details on a flag you can run `socket <cmd> --help` + + + +? + +Which scan command do you want to configure? + +(Use arrow keys) + + +❯ socket scan create + + + socket scan github + + (cancel) + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/native-snapshots/socket-version.version.svg b/test/native-snapshots/socket-version.version.svg new file mode 100644 index 0000000000..566b197620 --- /dev/null +++ b/test/native-snapshots/socket-version.version.svg @@ -0,0 +1,9 @@ + +2.1.0-rc.0 + + + + + + + \ No newline at end of file diff --git a/test/native/commands/cli-behavior.native.test.mts b/test/native/commands/cli-behavior.native.test.mts new file mode 100644 index 0000000000..807f372481 --- /dev/null +++ b/test/native/commands/cli-behavior.native.test.mts @@ -0,0 +1,120 @@ +import { writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + captureFlow, + createNativeTerminalFixture, +} from '../../lib/native/cli-harness.mts' +import { + captureCheckpoint, + createRecording, + expectTerminalAnimation, +} from '../../lib/terminal/animation.mts' + +const binary = process.env['SOCKET_CLI_NATIVE_TEST_BINARY'] + +describe('native CLI behavior', () => { + it('reads an explicit config value', async () => { + await captureFlow({ + flow: 'behavior-config-get', + checkpointName: 'output', + args: ['config', 'get', 'defaultOrg'], + expectedText: 'native-fixture', + exitCode: 0, + env: { SOCKET_CLI_CONFIG: '{"defaultOrg":"native-fixture"}' }, + }) + }, 30_000) + + it('lists an explicit config without persisting it', async () => { + await captureFlow({ + flow: 'behavior-config-list', + checkpointName: 'output', + args: ['config', 'list'], + expectedText: 'native-fixture', + exitCode: 0, + rows: 30, + env: { SOCKET_CLI_CONFIG: '{"defaultOrg":"native-fixture"}' }, + }) + }, 30_000) + + it('rejects an unknown config key', async () => { + await captureFlow({ + flow: 'behavior-config-invalid-key', + checkpointName: 'output', + args: ['config', 'get', 'invalid-native-key'], + expectedText: 'invalid config key', + exitCode: 2, + }) + }, 30_000) + + it('shows a missing socket.json error in an isolated directory', async () => { + await captureFlow({ + flow: 'behavior-json-missing', + checkpointName: 'output', + args: ['json'], + expectedText: 'Not found:', + exitCode: 1, + }) + }, 30_000) + + it('prints a seeded socket.json fixture', async () => { + await captureFlow({ + flow: 'behavior-json-seeded', + checkpointName: 'output', + args: ['json'], + expectedText: '"projectIgnorePaths"', + exitCode: 0, + prepare: async ({ cwd }) => { + await writeFile( + `${cwd}/socket.json`, + '{"version":1,"projectIgnorePaths":["fixture/**"]}\n', + ) + }, + }) + }, 30_000) + + it('validates wrapper mode before applying the dry-run guard', async () => { + await captureFlow({ + flow: 'behavior-wrapper-on-dry-run', + checkpointName: 'output', + args: ['wrapper', 'on', '--dry-run'], + expectedText: '[DryRun]', + exitCode: 0, + }) + }, 30_000) + + it('captures the manifest setup menu and cancellation', async () => { + const fixture = await createNativeTerminalFixture(24) + await fixture.terminal.spawn([binary!, 'manifest', 'setup'], { + cwd: fixture.cwd, + env: { NO_COLOR: '', CI: '1', SOCKET_CLI_CONFIG: '{}' }, + }) + await fixture.terminal.waitFor( + 'Select ecosystem manifest generator to configure', + 30_000, + ) + await fixture.terminal.waitForStable(150, 10_000) + const menu = await captureCheckpoint(fixture.terminal, 'menu') + fixture.terminal.press('ArrowUp') + await fixture.terminal.waitForStable(150, 10_000) + const selected = await captureCheckpoint(fixture.terminal, 'exit-selected') + fixture.terminal.press('Enter') + await fixture.terminal.waitFor('User canceled', 30_000) + await expect + .poll(() => fixture.terminal.exitInfo, { timeout: 30_000 }) + .toBe('exit=0') + fixture.normalizer.flush() + const canceled = await captureCheckpoint(fixture.terminal, 'canceled') + await expectTerminalAnimation(createRecording([menu, selected, canceled]), { + flow: 'behavior-manifest-setup', + snapshotsDir: path.join(process.cwd(), 'test/native-snapshots'), + artifactsDir: + process.env['SOCKET_CLI_NATIVE_TEST_ARTIFACTS'] ?? + path.join(process.cwd(), 'build/native-test-artifacts'), + expectedSequence: ['menu', 'exit-selected', 'canceled'], + durations: [850, 850, 1100], + }) + }, 60_000) +}) diff --git a/test/native/commands/cli.native.test.mts b/test/native/commands/cli.native.test.mts new file mode 100644 index 0000000000..a26382c4ab --- /dev/null +++ b/test/native/commands/cli.native.test.mts @@ -0,0 +1,239 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + FLAG_CONFIG, + FLAG_DRY_RUN, + FLAG_HELP, + FLAG_VERSION, +} from '../../../src/constants.mts' +import { + captureFlow, + createNativeTerminalFixture, +} from '../../lib/native/cli-harness.mts' +import { discoverNativeCommandPaths } from '../../lib/native/command-inventory.mts' +import { + captureCheckpoint, + createRecording, + expectTerminalAnimation, +} from '../../lib/terminal/animation.mts' + +const binary = process.env['SOCKET_CLI_NATIVE_TEST_BINARY'] +if (!binary) { + throw new Error('Use pnpm test:native to run native CLI tests') +} + +const snapshotDir = path.join(process.cwd(), 'test/native-snapshots') +const artifactDir = + process.env['SOCKET_CLI_NATIVE_TEST_ARTIFACTS'] ?? + path.join(process.cwd(), 'build/native-test-artifacts') +const appVersion = process.env['SOCKET_CLI_NATIVE_TEST_VERSION'] +const nodeVersion = process.env['SOCKET_CLI_NATIVE_TEST_NODE_VERSION'] +if (!appVersion || !nodeVersion) { + throw new Error('Native test harness must provide manifest versions') +} + +const commandPaths = [ + 'analytics', + 'audit-log', + 'ci', + 'cdxgen', + 'config', + 'dependencies', + 'fix', + 'install', + 'json', + 'license', + 'login', + 'logout', + 'manifest', + 'npm', + 'npx', + 'pnpm', + 'oops', + 'optimize', + 'organization', + 'package', + 'patch', + 'raw-npm', + 'raw-npx', + 'repository', + 'scan', + 'security', + 'threat-feed', + 'uninstall', + 'wrapper', + 'yarn', + 'config auto', + 'config get', + 'config list', + 'config set', + 'config unset', + 'install completion', + 'uninstall completion', + 'manifest auto', + 'manifest bazel', + 'manifest cdxgen', + 'manifest conda', + 'manifest dotnet', + 'manifest dynamic-sbom-inference', + 'manifest gradle', + 'manifest kotlin', + 'manifest maven', + 'manifest scala', + 'manifest setup', + 'organization dependencies', + 'organization list', + 'organization quota', + 'organization policy', + 'organization policy security', + 'organization policy license', + 'package score', + 'package shallow', + 'repository create', + 'repository view', + 'repository list', + 'repository del', + 'repository update', + 'scan create', + 'scan del', + 'scan diff', + 'scan github', + 'scan list', + 'scan metadata', + 'scan reach', + 'scan report', + 'scan setup', + 'scan view', +] as const + +it('keeps the native help matrix aligned with registered commands', async () => { + expect([...commandPaths].sort()).toEqual( + (await discoverNativeCommandPaths()).sort(), + ) +}) + +for (const commandPath of commandPaths) { + const args = [...commandPath.split(' '), FLAG_HELP] + const flow = `socket-help-${commandPath.replaceAll(' ', '-')}` + it(`captures native help for ${commandPath}`, async () => { + await captureFlow({ + flow, + args, + expectedText: commandPath, + exitCode: 0, + cols: 140, + rows: 200, + }) + }, 60_000) +} + +for (const patchCommand of [ + 'apply', + 'rollback', + 'get', + 'scan', + 'list', + 'remove', + 'setup', + 'repair', +]) { + it(`captures bundled patch help for ${patchCommand}`, async () => { + await captureFlow({ + flow: `socket-patch-${patchCommand}-help`, + args: ['patch', patchCommand, FLAG_HELP], + expectedText: `Usage: socket-patch ${patchCommand}`, + exitCode: 0, + cols: 140, + rows: 200, + }) + }, 60_000) +} + +describe('native Socket CLI', () => { + it('reports the version embedded in the SEA manifest', async () => { + await captureFlow({ + flow: 'socket-version', + args: [FLAG_VERSION], + expectedText: appVersion, + exitCode: 0, + rows: 12, + checkpointName: 'version', + }) + }, 30_000) + + it('prints root help and starts the CLI from the native executable', async () => { + await captureFlow({ + flow: 'socket-help', + args: [FLAG_HELP], + expectedText: 'CLI for Socket.dev', + exitCode: 0, + rows: 200, + cols: 140, + }) + }, 30_000) + + it('runs a local dry-run command through the native executable', async () => { + await captureFlow({ + flow: 'socket-manifest-dry-run', + args: [ + 'manifest', + 'auto', + FLAG_DRY_RUN, + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + expectedText: '[DryRun]', + exitCode: 0, + rows: 12, + checkpointName: 'dry-run', + env: { SOCKET_CLI_CONFIG: '{"apiToken":"fakeToken"}' }, + }) + }, 30_000) + + it('rejects unsupported cdxgen project types before helper execution', async () => { + await captureFlow({ + flow: 'socket-cdxgen-unsupported-type', + args: ['manifest', 'cdxgen', '--type', 'pnpm', FLAG_CONFIG, '{}'], + expectedText: 'supports only npm projects', + exitCode: 1, + rows: 12, + checkpointName: 'unsupported-type', + env: {}, + }) + }, 30_000) + + it('captures the scan setup selection and cancellation interaction', async () => { + const fixture = await createNativeTerminalFixture(24) + const { cwd, normalizer, terminal } = fixture + await terminal.spawn([binary!, 'scan', 'setup', FLAG_CONFIG, '{}'], { + cwd, + env: { NO_COLOR: '', CI: '1' }, + }) + await terminal.waitFor( + 'Which scan command do you want to configure?', + 30_000, + ) + await terminal.waitForStable(150, 10_000) + const menu = await captureCheckpoint(terminal, 'menu') + terminal.press('ArrowDown') + await terminal.waitForStable(150, 10_000) + const github = await captureCheckpoint(terminal, 'github-selected') + terminal.press('ArrowDown') + await terminal.waitForStable(150, 10_000) + terminal.press('Enter') + await expect + .poll(() => terminal.exitInfo, { timeout: 30_000 }) + .toBe('exit=0') + normalizer.flush() + const canceled = await captureCheckpoint(terminal, 'canceled') + await expectTerminalAnimation(createRecording([menu, github, canceled]), { + flow: 'socket-scan-setup', + snapshotsDir: snapshotDir, + artifactsDir: artifactDir, + expectedSequence: ['menu', 'github-selected', 'canceled'], + durations: [850, 850, 1100], + }) + }, 60_000) +}) diff --git a/test/native/commands/tui.native.test.mts b/test/native/commands/tui.native.test.mts new file mode 100644 index 0000000000..8b87b1a9b8 --- /dev/null +++ b/test/native/commands/tui.native.test.mts @@ -0,0 +1,114 @@ +import path from 'node:path' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { createNativeTerminalFixture } from '../../lib/native/cli-harness.mts' +import { createTuiApiFixture } from '../../lib/native/tui-api.mts' +import { + captureCheckpoint, + createRecording, + expectTerminalAnimation, +} from '../../lib/terminal/animation.mts' + +const binary = process.env['SOCKET_CLI_NATIVE_TEST_BINARY'] +if (!binary) { + throw new Error('Use pnpm test:native to run native CLI tests') +} + +const snapshotDir = path.join(process.cwd(), 'test/native-snapshots') +const artifactDir = + process.env['SOCKET_CLI_NATIVE_TEST_ARTIFACTS'] ?? + path.join(process.cwd(), 'build/native-test-artifacts') +const tuiEnv: NodeJS.ProcessEnv = {} +let api: Awaited> + +beforeAll(async () => { + api = await createTuiApiFixture() + Object.assign(tuiEnv, { + SOCKET_CLI_CONFIG: '{}', + SOCKET_CLI_API_TOKEN: 'sktsec_local-fixture-token', + SOCKET_CLI_API_BASE_URL: api.baseUrl, + NODE_EXTRA_CA_CERTS: api.caFile, + TERM: 'xterm-256color', + TZ: 'UTC', + LC_ALL: 'C', + }) +}, 60_000) // Windows may need longer to generate the fixture certificate. +afterAll(async () => { + await api?.close() +}) + +describe('native blessed terminal interfaces', () => { + it('renders the analytics charts from a local API fixture', async () => { + const fixture = await createNativeTerminalFixture(34, process.env, 120) + const { cwd, normalizer, terminal } = fixture + await terminal.spawn([binary!, 'analytics', 'org', '7'], { + cwd, + env: { NO_COLOR: '', CI: '1', ...tuiEnv }, + }) + await terminal.waitFor('Top 5 alert types', 30_000) + await terminal.waitFor('Total critical alerts', 30_000) + await terminal.waitForStable(250, 10_000) + expect(terminal.screen.getText()).toContain('Top 5 alert types') + const checkpoint = await captureCheckpoint(terminal, 'charts') + terminal.press('q') + await expect + .poll(() => terminal.exitInfo, { timeout: 10_000 }) + .toBe('exit=0') + normalizer.flush() + await expectTerminalAnimation(createRecording([checkpoint]), { + flow: 'native-analytics-tui', + snapshotsDir: snapshotDir, + artifactsDir: artifactDir, + expectedSequence: ['charts'], + }) + }, 60_000) + + it('renders the audit log table from a local API fixture', async () => { + const fixture = await createNativeTerminalFixture(34, process.env, 120) + const { cwd, normalizer, terminal } = fixture + await terminal.spawn([binary!, 'audit-log', '--org', 'fixture-org'], { + cwd, + env: { NO_COLOR: '', CI: '1', ...tuiEnv }, + }) + await terminal.waitFor('Audit Logs for fixture-org', 30_000) + await terminal.waitFor('fixture@example.invalid', 30_000) + await terminal.waitForStable(250, 10_000) + const checkpoint = await captureCheckpoint(terminal, 'table') + terminal.press('q') + await expect + .poll(() => terminal.exitInfo, { timeout: 10_000 }) + .toBe('exit=0') + normalizer.flush() + await expectTerminalAnimation(createRecording([checkpoint]), { + flow: 'native-audit-log-tui', + snapshotsDir: snapshotDir, + artifactsDir: artifactDir, + expectedSequence: ['table'], + }) + }, 60_000) + + it('renders the threat feed table from a local API fixture', async () => { + const fixture = await createNativeTerminalFixture(34, process.env, 120) + const { cwd, normalizer, terminal } = fixture + await terminal.spawn( + [binary!, 'threat-feed', '--org', 'fixture-org', '--filter', 'mal'], + { cwd, env: { NO_COLOR: '', CI: '1', ...tuiEnv } }, + ) + await terminal.waitFor('Threat feed', 30_000) + await terminal.waitFor('tui-fixture-package', 30_000) + await terminal.waitForStable(250, 10_000) + const checkpoint = await captureCheckpoint(terminal, 'table') + terminal.press('q') + await expect + .poll(() => terminal.exitInfo, { timeout: 10_000 }) + .toBe('exit=0') + normalizer.flush() + await expectTerminalAnimation(createRecording([checkpoint]), { + flow: 'native-threat-feed-tui', + snapshotsDir: snapshotDir, + artifactsDir: artifactDir, + expectedSequence: ['table'], + }) + }, 60_000) +}) diff --git a/test/package-matrix.test.mts b/test/package-matrix.test.mts new file mode 100644 index 0000000000..7cd058f091 --- /dev/null +++ b/test/package-matrix.test.mts @@ -0,0 +1,256 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import os from 'node:os' +import path from 'node:path' +import { gzipSync } from 'node:zlib' + +import { describe, expect, it } from 'vitest' + +import { + createPrivatePnpmEnvironment, + currentPrereleaseVersion, + makeVariants, + readPackagedManifest, + run, + updateRootManifest, + validatePackagedManifest, + validatePrivateVirtualStore, + verifyPrivateVirtualStore, +} from '../scripts/build-package-matrix.mts' + +function tarEntry(name: string, contents: string): Buffer { + const data = Buffer.from(contents) + const header = Buffer.alloc(512) + header.write(name, 0, 100) + header.write('0000644\0', 100, 8) + header.write('0000000\0', 108, 8) + header.write('0000000\0', 116, 8) + header.write(data.length.toString(8).padStart(11, '0') + '\0', 124, 12) + header.write('00000000000\0', 136, 12) + header.fill(32, 148, 156) + header.write('0', 156, 1) + header.write('ustar\0', 257, 6) + header.write('00', 263, 2) + const checksum = header.reduce((sum, byte) => sum + byte, 0) + header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148, 8) + const padded = Buffer.alloc(Math.ceil(data.length / 512) * 512) + data.copy(padded) + return gzipSync(Buffer.concat([header, padded, Buffer.alloc(1024)])) +} + +describe('package matrix helpers', () => { + it('accepts only the SEA prerelease version format', () => { + expect(currentPrereleaseVersion('2.1.0-rc.17')).toBe( + '2.1.0-rc.17', + ) + expect(() => currentPrereleaseVersion('2.1.0')).toThrow( + 'Expected SEA version', + ) + expect(() => currentPrereleaseVersion('2.1.0-beta.1')).toThrow( + 'Expected SEA version', + ) + expect(() => currentPrereleaseVersion('2.2.0-rc.1')).toThrow( + 'Expected SEA version', + ) + }) + + it('defines the four stable and prerelease package variants', () => { + expect( + makeVariants('2.1.0-rc.3').map(({ artifact }) => artifact), + ).toEqual([ + 'socket-1.14.1.tgz', + 'socket-2.1.0-rc.3.tgz', + '@socketsecurity-cli-1.14.1.tgz', + '@socketsecurity-cli-with-sentry-1.14.1.tgz', + ]) + }) + + it('changes only the package name and version and reads packed metadata', () => { + const raw = + '{\n "name": "socket",\n "version": "2.1.0-rc.0",\n "private": true\n}\n' + expect(JSON.parse(updateRootManifest(raw, '1.14.1'))).toEqual({ + name: 'socket', + version: '1.14.1', + private: true, + }) + const bytes = tarEntry( + 'package/package.json', + '{"name":"socket","version":"1.14.1","bin":{"socket":"bin/cli.js","socket-npm":"bin/npm-cli.js","socket-npx":"bin/npx-cli.js","socket-pnpm":"bin/pnpm-cli.js","socket-yarn":"bin/yarn-cli.js"}}', + ) + const manifest = readPackagedManifest(bytes) + expect(manifest).toMatchObject({ + name: 'socket', + version: '1.14.1', + }) + expect(() => + validatePackagedManifest( + { ...manifest, bin: { socket: 'wrong.js' } }, + makeVariants('2.1.0-rc.0')[0]!, + ), + ).toThrow('CLI bin map mismatch') + expect(() => + validatePackagedManifest( + manifest, + makeVariants('2.1.0-rc.0')[0]!, + ), + ).not.toThrow() + }) + + it('validates the legacy CLI bin alias on the scoped package', () => { + const variant = makeVariants('2.1.0-rc.0')[2]! + const standardBins = { + socket: 'bin/cli.js', + 'socket-npm': 'bin/npm-cli.js', + 'socket-npx': 'bin/npx-cli.js', + 'socket-pnpm': 'bin/pnpm-cli.js', + 'socket-yarn': 'bin/yarn-cli.js', + } + expect(() => + validatePackagedManifest( + { + name: '@socketsecurity/cli', + version: '1.14.1', + bin: { cli: 'bin/cli.js', ...standardBins }, + }, + variant, + ), + ).not.toThrow() + }) + + it('requires pnpm to keep its virtual store inside the private workspace', () => { + const workspace = '/tmp/private-package-workspace' + expect(() => validatePrivateVirtualStore(workspace, '.pnpm')).not.toThrow() + expect(() => + validatePrivateVirtualStore( + workspace, + '../../../../shared/pnpm/store/links', + ), + ).toThrow('pnpm virtual store escaped the private workspace') + }) + + it('keeps a nested pnpm install local despite user-level global-store settings', async () => { + const workspace = await mkdtemp( + path.join(os.tmpdir(), 'matrix-private-pnpm-test-'), + ) + const userConfig = path.join(workspace, 'user.npmrc') + const fixturePackage = path.join(workspace, 'fixture-package') + await mkdir(fixturePackage) + await writeFile( + path.join(fixturePackage, 'package.json'), + JSON.stringify({ name: 'matrix-local-fixture', version: '1.0.0' }), + ) + await writeFile( + path.join(fixturePackage, 'index.js'), + 'module.exports = 1\n', + ) + await writeFile( + path.join(workspace, 'package.json'), + JSON.stringify({ + name: 'matrix-private-pnpm-test', + version: '1.0.0', + packageManager: 'pnpm@10.33.0', + dependencies: { 'matrix-local-fixture': 'file:./fixture-package' }, + }), + ) + await writeFile(path.join(workspace, '.npmrc'), 'audit=false\n') + await writeFile(userConfig, 'enable-global-virtual-store=true\n') + const env = createPrivatePnpmEnvironment(workspace, { + ...process.env, + npm_config_userconfig: userConfig, + NPM_CONFIG_USERCONFIG: userConfig, + }) + try { + run( + workspace, + 'pnpm', + [ + 'install', + '--offline', + '--ignore-scripts', + '--config.enable-global-virtual-store=false', + '--virtual-store-dir=node_modules/.pnpm', + ], + env, + ) + await verifyPrivateVirtualStore(workspace) + run( + workspace, + 'pnpm', + [ + 'install', + '--offline', + '--frozen-lockfile=false', + '--config.confirmModulesPurge=false', + ], + env, + ) + await verifyPrivateVirtualStore(workspace) + } finally { + await rm(workspace, { recursive: true, force: true }) + } + }, 30_000) + + it.skipIf(process.platform !== 'win32')( + 'launches a pnpm.cmd shim through cross-spawn', + async () => { + const directory = await mkdtemp( + path.join(os.tmpdir(), 'matrix-pnpm-shim-test-'), + ) + const marker = path.join(directory, 'launched.txt') + const shim = path.join(directory, 'pnpm.cmd') + await writeFile( + shim, + '@echo off\r\necho launched> "%MATRIX_PNPM_SHIM_MARKER%"\r\n', + ) + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => key.toLowerCase() !== 'path', + ), + ) as NodeJS.ProcessEnv + env.PATH = `${directory}${path.delimiter}${process.env['PATH'] ?? ''}` + env.MATRIX_PNPM_SHIM_MARKER = marker + try { + run(directory, 'pnpm', ['--version'], env) + await expect(readFile(marker, 'utf8')).resolves.toContain('launched') + } finally { + await rm(directory, { recursive: true, force: true }) + } + }, + 30_000, + ) + + it('stages tracked and intentional untracked source without ignored outputs', () => { + const require = createRequire(import.meta.url) + const { execFileSync } = + require('node:child_process') as typeof import('node:child_process') + const { fileURLToPath } = require('node:url') as typeof import('node:url') + const root = fileURLToPath(new URL('..', import.meta.url)) + const ignored = execFileSync( + 'git', + ['check-ignore', '-v', 'build/sea/check'], + { + cwd: root, + encoding: 'utf8', + }, + ) + expect(ignored).toContain('/build/') + const allowlisted = execFileSync( + 'git', + ['check-ignore', '--no-index', '-v', 'scripts/build-package-matrix.mts'], + { + cwd: root, + encoding: 'utf8', + }, + ) + expect(allowlisted).toContain('!/scripts/build-package-matrix.mts') + const workflowAllowlisted = execFileSync( + 'git', + ['check-ignore', '--no-index', '-v', '.github/workflows/ci.yml'], + { + cwd: root, + encoding: 'utf8', + }, + ) + expect(workflowAllowlisted).toContain('!/.github/workflows/ci.yml') + }) +}) diff --git a/test/release-approve-npm.test.mts b/test/release-approve-npm.test.mts new file mode 100644 index 0000000000..30b31d63e7 --- /dev/null +++ b/test/release-approve-npm.test.mts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { + packageSetForVersion, + pnpmStageInvocation, + stageActionForStatus, + validateStage, +} from '../scripts/release/approve-npm.mts' +import { releasePackagesForVersion } from '../scripts/release/packages.mts' + +const id = '12345678-1234-4123-8123-123456789abc' + +describe('npm stage approval', () => { + it('validates stage identity and supported release package names', () => { + expect( + validateStage( + { id, packageName: 'socket', version: '1.14.2', status: 'staged' }, + id, + ), + ).toEqual({ name: 'socket', version: '1.14.2' }) + expect(() => + validateStage({ id, packageName: 'other', version: '1.14.2' }, id), + ).toThrow('Unexpected npm package') + expect(() => + validateStage({ id, packageName: 'socket', version: 'bad' }, id), + ).toThrow('stage ID, package, or version is invalid') + expect(() => + validateStage( + { id, packageName: '@socketsecurity/cli', version: '2.1.0-rc.2' }, + id, + ), + ).toThrow('Prerelease stages may only contain the socket package') + }) + + it('maps npm states to safe retry, approval, finalization, and burn actions', () => { + expect(stageActionForStatus('validating')).toBe('wait') + expect(stageActionForStatus('staged')).toBe('approve') + expect(stageActionForStatus('published')).toBe('finalize') + expect(stageActionForStatus('blocked')).toBe('burned') + expect(stageActionForStatus('deleted')).toBe('burned') + expect(() => stageActionForStatus('unknown')).toThrow( + 'npm stage is unknown; approval stopped', + ) + }) + + it('waits for every stable package but only socket for prereleases', () => { + expect(releasePackagesForVersion('1.14.2')).toEqual([ + 'socket', + '@socketsecurity/cli', + '@socketsecurity/cli-with-sentry', + ]) + expect(releasePackagesForVersion('2.1.0-rc.2')).toEqual(['socket']) + }) + + it('uses pnpm 11 for stage commands when the workspace pnpm is older', () => { + expect(pnpmStageInvocation('10.33.0', ['stage', 'view', id])).toEqual({ + command: 'corepack', + args: ['pnpm@11.17.0', '--pm-on-fail=ignore', 'stage', 'view', id], + }) + expect(pnpmStageInvocation('11.17.0', ['stage', 'view', id])).toEqual({ + command: 'pnpm', + args: ['stage', 'view', id], + }) + }) +}) diff --git a/test/release-availability.test.mts b/test/release-availability.test.mts new file mode 100644 index 0000000000..30a63f047f --- /dev/null +++ b/test/release-availability.test.mts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + waitForNpmVersion, + waitForReleasePackages, +} from '../scripts/release/availability.mts' +import { releasePackagesForVersion } from '../scripts/release/packages.mts' + +describe('npm release availability', () => { + it('checks the exact encoded package version with HEAD and retries 404', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 404 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })) + const wait = vi.fn(async () => {}) + + await expect( + waitForNpmVersion('@socketsecurity/cli', '1.14.2', { + fetcher, + wait, + }), + ).resolves.toBeUndefined() + expect(fetcher.mock.calls.map(([url, options]) => [url, options?.method])).toEqual([ + [ + 'https://registry.npmjs.org/%40socketsecurity%2Fcli/1.14.2', + 'HEAD', + ], + [ + 'https://registry.npmjs.org/%40socketsecurity%2Fcli/1.14.2', + 'HEAD', + ], + ]) + expect(wait).toHaveBeenCalledOnce() + }) + + it('waits for all stable package versions and only socket for prereleases', async () => { + expect(releasePackagesForVersion('1.14.2')).toEqual([ + 'socket', + '@socketsecurity/cli', + '@socketsecurity/cli-with-sentry', + ]) + expect(releasePackagesForVersion('2.1.0-rc.2')).toEqual(['socket']) + + const fetcher = vi.fn(async () => new Response(null, { status: 200 })) + await expect( + waitForReleasePackages('1.14.2', { fetcher }), + ).resolves.toEqual(releasePackagesForVersion('1.14.2')) + expect(fetcher).toHaveBeenCalledTimes(3) + }) + + it('fails fast on registry errors other than pending or throttled responses', async () => { + const fetcher = vi.fn(async () => new Response(null, { status: 403 })) + await expect( + waitForNpmVersion('socket', '2.1.0-rc.2', { fetcher }), + ).rejects.toThrow('npm availability check returned HTTP 403') + }) + + it('honors Retry-After for throttled requests', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 429, + headers: { 'retry-after': '5' }, + }), + ) + .mockResolvedValueOnce(new Response(null, { status: 200 })) + const wait = vi.fn(async () => {}) + + await waitForNpmVersion('socket', '2.1.0-rc.2', { fetcher, wait }) + + expect(wait).toHaveBeenCalledWith(5_000, undefined) + }) + + it('times out without creating a release when the public version stays absent', async () => { + let currentTime = 0 + const fetcher = vi.fn(async () => new Response(null, { status: 404 })) + const wait = vi.fn(async (milliseconds: number) => { + currentTime += milliseconds + }) + + await expect( + waitForNpmVersion('socket', '2.1.0-rc.2', { + fetcher, + now: () => currentTime, + timeoutMs: 3_000, + wait, + }), + ).rejects.toThrow('no GitHub release was created') + expect(fetcher).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/release-history.test.mts b/test/release-history.test.mts index d91e6fbb56..990c3928a1 100644 --- a/test/release-history.test.mts +++ b/test/release-history.test.mts @@ -95,6 +95,8 @@ describe('release history and version reservations', () => { const history = await readReleaseHistory( repository.cwd, '1.1.11-prerelease', + undefined, + '1.1.10', ) expect(history).toEqual({ anchorRef: 'v1.1.10', @@ -132,6 +134,8 @@ describe('release history and version reservations', () => { const history = await readReleaseHistory( repository.cwd, '1.1.11-prerelease', + undefined, + '1.1.10', ) const commits = parseConventionalCommits( await readReleaseCommits(repository.cwd, history.anchorRef), @@ -172,7 +176,12 @@ describe('release history and version reservations', () => { squash, ) repository.git(['update-ref', 'refs/heads/fixture-release', head]) - const history = await readReleaseHistory(repository.cwd, '1.4.2') + const history = await readReleaseHistory( + repository.cwd, + '1.4.2', + undefined, + '1.4.2', + ) expect(history).toEqual({ anchorRef: squash, reservedVersions: ['v1.4.1', 'v1.4.2'], @@ -210,4 +219,56 @@ describe('release history and version reservations', () => { ).map(commit => commit.hash), ).toEqual([head]) }) + + it('carries burned tagged attempts forward from the last published tag', async () => { + const repository = createReleaseRepository() + const publicRelease = repository.commitManifest( + 'chore(release): 2.1.0-rc.0', + '2.1.0-rc.0', + ) + const firstChange = repository.commitManifest( + 'fix(cli): add first change', + '2.1.0-rc.0', + publicRelease, + ) + const burned = repository.commitManifest( + 'chore(release): 2.1.0-rc.1', + '2.1.0-rc.1', + firstChange, + ) + const secondChange = repository.commitManifest( + 'fix(cli): add second change', + '2.1.0-rc.1', + burned, + ) + repository.git(['update-ref', 'refs/heads/fixture-release', secondChange]) + repository.git(['tag', 'v2.1.0-rc.0', publicRelease]) + repository.git(['tag', 'v2.1.0-rc.1', burned]) + + const history = await readReleaseHistory( + repository.cwd, + '2.1.0-rc.1', + 2, + '2.1.0-rc.0', + ) + expect(history.anchorRef).toBe('v2.1.0-rc.0') + const commits = parseConventionalCommits( + await readReleaseCommits(repository.cwd, history.anchorRef), + ) + expect(commits.map(commit => commit.description)).toContain( + 'add first change', + ) + expect(commits.map(commit => commit.description)).toContain( + 'add second change', + ) + }) + + it('fails closed if a published version has no tag', async () => { + const repository = createReleaseRepository() + const head = repository.commitManifest('fix(cli): a change', '1.2.3') + repository.git(['update-ref', 'refs/heads/fixture-release', head]) + await expect( + readReleaseHistory(repository.cwd, '1.2.3', 1, '1.2.2'), + ).rejects.toThrow('Published release 1.2.2 has no matching git tag') + }) }) diff --git a/test/release-npm-policy.test.mts b/test/release-npm-policy.test.mts new file mode 100644 index 0000000000..cc21821b02 --- /dev/null +++ b/test/release-npm-policy.test.mts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' + +import { + npmDistTagForRelease, + validateNpmPolicy, +} from '../scripts/release/validate-npm-policy.mts' + +const prereleasePolicy = { + mode: 'prerelease-only' as const, + versionHint: '2.1.0', + identifier: 'rc', + distTag: 'rc-2.1.0', +} + +describe('npm release policy', () => { + it('chooses the configured tag for prereleases and latest for v1.x', () => { + expect(npmDistTagForRelease('2.1.0-rc.4', prereleasePolicy)).toBe( + 'rc-2.1.0', + ) + expect(npmDistTagForRelease('1.14.2', prereleasePolicy)).toBe('latest') + }) + + it('allows the configured prerelease and tag', () => { + expect(() => + validateNpmPolicy('2.1.0-rc.4', 'rc-2.1.0', prereleasePolicy), + ).not.toThrow() + }) + + it.each([ + ['2.1.0', 'rc-2.1.0'], + ['2.1.0-rc.4', 'latest'], + ['2.2.0-rc.0', 'rc-2.1.0'], + ])('rejects mismatched prerelease version/tag %s %s', (version, tag) => { + expect(() => validateNpmPolicy(version!, tag!, prereleasePolicy)).toThrow() + }) + + it('keeps the existing v1.x stable release line available', () => { + expect(() => + validateNpmPolicy('1.14.2', 'latest', prereleasePolicy), + ).not.toThrow() + expect(() => validateNpmPolicy('1.14.2', 'next', prereleasePolicy)).toThrow( + 'stable v1.x release on the latest dist-tag', + ) + }) +}) diff --git a/test/release-open-pr.test.mts b/test/release-open-pr.test.mts index e87f8c1893..5891351d11 100644 --- a/test/release-open-pr.test.mts +++ b/test/release-open-pr.test.mts @@ -36,7 +36,7 @@ describe('assertBumpOnly', () => { it.each([ [[]], [['package.json']], - [['CHANGELOG.md', 'package.json', 'scripts/release/bump.mts']], + [['CHANGELOG.md', 'package.json', 'scripts/release/prepare.mts']], [['CHANGELOG.md', 'pnpm-lock.yaml']], ])('refuses %j', files => { expect(() => assertBumpOnly(files)).toThrow(/exactly CHANGELOG.md/) @@ -168,6 +168,6 @@ describe('releasePullRequestBody', () => { expect(body).toContain('1.5.1') expect(body).toContain('squash-merge') expect(body).toContain('`v1.x`') - expect(body).toContain('pnpm stage approve') + expect(body).toContain('pnpm run approve:npm ') }) }) diff --git a/test/release-bump.test.mts b/test/release-prepare.test.mts similarity index 97% rename from test/release-bump.test.mts rename to test/release-prepare.test.mts index 004edd2278..d9081c5348 100644 --- a/test/release-bump.test.mts +++ b/test/release-prepare.test.mts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { writeManifestVersion } from '../scripts/release/bump.mts' +import { writeManifestVersion } from '../scripts/release/prepare.mts' describe('writeManifestVersion', () => { it('rewrites the top-level version field, leaving everything else byte-for-byte', () => { diff --git a/test/release-registry.test.mts b/test/release-registry.test.mts index 4e14aea43c..e6e6053867 100644 --- a/test/release-registry.test.mts +++ b/test/release-registry.test.mts @@ -32,6 +32,24 @@ describe('release registry preflight', () => { } }) + it('checks only socket for the configured prerelease track', async () => { + const fetcher = vi.fn( + async () => new Response('', { status: 404 }), + ) + await expect( + assertReleaseVersionsAvailable('2.1.0-rc.2', fetcher, { + mode: 'prerelease-only', + versionHint: '2.1.0', + identifier: 'rc', + distTag: 'rc-2.1.0', + }), + ).resolves.toEqual({ packages: ['socket'], version: '2.1.0-rc.2' }) + expect(fetcher).toHaveBeenCalledTimes(1) + expect(fetcher.mock.calls[0]?.[0]).toBe( + 'https://registry.npmjs.org/socket/2.1.0-rc.2', + ) + }) + it.each(RELEASE_PACKAGES)( 'refuses a version already published as %s', async publishedPackage => { diff --git a/test/release-version.test.mts b/test/release-version.test.mts index 86692cb741..3c4363fc3d 100644 --- a/test/release-version.test.mts +++ b/test/release-version.test.mts @@ -6,7 +6,9 @@ import { COMMIT_RECORD_SEP, bumpLevelFor, deriveNextVersion, + maxPrereleaseVersion, maxReleaseVersion, + nextPrereleaseVersion, parseConventionalCommit, parseConventionalCommits, resolveBumpBase, @@ -139,6 +141,17 @@ describe('maxReleaseVersion', () => { }) }) +describe('maxPrereleaseVersion', () => { + it('chooses the newest published version on the exact prerelease track', () => { + expect( + maxPrereleaseVersion( + ['2.1.0-rc.2', '2.1.0-rc.10', '2.1.0-beta.99', '2.2.0-rc.1'], + '2.1.0-rc.', + ), + ).toBe('2.1.0-rc.10') + }) +}) + describe('resolveBumpBase', () => { it('prefers the highest consumed version over the manifest', () => { expect( @@ -199,6 +212,18 @@ describe('resolveBumpBase', () => { }) describe('deriveNextVersion', () => { + it('keeps the v1 release bump on v1 when the manifest holds the SEA prerelease', () => { + expect( + deriveNextVersion({ + commits: [], + manifestVersion: '2.1.0-rc.0', + releaseMajor: 1, + publishedVersion: '1.14.1', + tagVersions: ['v1.14.1'], + }), + ).toMatchObject({ base: '1.14.1', version: '1.14.2' }) + }) + it('keeps another release line and prereleases out of the reserved floor', () => { expect( deriveNextVersion({ @@ -283,6 +308,22 @@ describe('deriveNextVersion', () => { }) }) +describe('nextPrereleaseVersion', () => { + it('increments the highest configured prerelease and skips unrelated versions', () => { + expect( + nextPrereleaseVersion( + { + mode: 'prerelease-only', + versionHint: '2.1.0', + identifier: 'rc', + distTag: 'rc-2.1.0', + }, + ['2.1.0-rc.0', '2.1.0-rc.2', '2.1.0-beta.9', '2.2.0-rc.5'], + ), + ).toBe('2.1.0-rc.3') + }) +}) + describe('promoteChangelog', () => { const preamble = [ '# Changelog', diff --git a/test/release-workflow.test.mts b/test/release-workflow.test.mts index 2a0edc9962..4ef9672b58 100644 --- a/test/release-workflow.test.mts +++ b/test/release-workflow.test.mts @@ -1,9 +1,18 @@ -import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' interface ReleaseStep { + if?: string env?: Record name?: string run?: string @@ -20,7 +29,7 @@ interface ReleaseJob { interface ReleaseWorkflow { concurrency: { group: string; 'cancel-in-progress': boolean } jobs: { - derive: ReleaseJob + prepare: ReleaseJob 'release-pr': ReleaseJob verify: { if: string @@ -45,6 +54,12 @@ const workflow = parse( 'utf8', ), ) as ReleaseWorkflow +const githubReleaseWorkflow = parse( + readFileSync( + new URL('../.github/workflows/release-github.yml', import.meta.url), + 'utf8', + ), +) as { jobs: { release: { steps: ReleaseStep[] } } } describe('v1 release workflow contract', () => { it('mints the release App token only in the job that installs nothing', () => { @@ -74,7 +89,7 @@ describe('v1 release workflow contract', () => { .map(step => step.run ?? '') expect(scripts.join('\n')).not.toMatch(/promote\.mts|git push/) expect(Object.keys(workflow.jobs).sort()).toEqual([ - 'derive', + 'prepare', 'publish', 'release-pr', 'verify', @@ -86,11 +101,11 @@ describe('v1 release workflow contract', () => { 'publish', ) expect(workflow.jobs.verify.if).toBe("${{ inputs.mode == 'publish' }}") - expect(workflow.jobs.derive.if).toBe("${{ inputs.mode == 'release-pr' }}") + expect(workflow.jobs.prepare.if).toBe("${{ inputs.mode == 'release-pr' }}") expect(workflow.jobs['release-pr'].if).toBe( "${{ inputs.mode == 'release-pr' && inputs.dry-run == false }}", ) - expect(workflow.jobs.derive.permissions).toEqual({ contents: 'read' }) + expect(workflow.jobs.prepare.permissions).toEqual({ contents: 'read' }) }) it('publishes the merged release commit, not whatever HEAD is', () => { @@ -100,6 +115,160 @@ describe('v1 release workflow contract', () => { ) }) + it('verifies the selected release commit before npm staging', () => { + const steps = workflow.jobs.verify.steps + const checkoutIndex = steps.findIndex( + step => step.name === 'Check out the release commit', + ) + const verifyIndex = steps.findIndex( + step => step.name === 'Verify the release commit before staging', + ) + const verify = steps[verifyIndex] + expect(verify?.if).toBe('${{ inputs.dry-run == false }}') + expect(verify?.run).toContain('.commit.verification.verified') + expect(verifyIndex).toBe(checkoutIndex + 1) + }) + + it('prints npm stage IDs and reserves a release tag only once', () => { + const stage = workflow.jobs.publish.steps.find( + step => step.name === 'Stage release packages', + ) + expect(stage?.run).toContain('--json') + expect(stage?.run).toContain('stageId') + expect(stage?.run).toContain('output.search(/^(?:\\{|\\[)/mu)') + expect(stage?.run).toContain('pnpm run approve:npm ') + const tag = workflow.jobs.publish.steps.find( + step => step.name === 'Reserve release tag once', + ) + expect(tag?.run).toContain('already reserved') + expect(tag?.run).toContain('Recover its stage ID') + expect( + workflow.jobs.publish.steps.some(step => + step.name?.includes('GitHub release'), + ), + ).toBe(false) + }) + + it('parses npm stage JSON after pnpm progress output', () => { + const stage = workflow.jobs.publish.steps.find( + step => step.name === 'Stage release packages', + ) + const parser = stage?.run + ?.split("<<'NODE'\n")[1] + ?.split('\nNODE')[0] + expect(parser).toBeDefined() + + const directory = mkdtempSync(path.join(tmpdir(), 'npm-stage-output-')) + const outputPath = path.join(directory, 'output.json') + const summaryPath = path.join(directory, 'summary.md') + const tarballPath = path.join(directory, 'socket-2.1.0-rc.0.tgz') + const stageId = '12345678-1234-4123-8123-123456789abc' + try { + writeFileSync( + outputPath, + [ + 'GET https://example.invalid/idtoken 200 193ms', + 'Staging socket@2.1.0-rc.0', + JSON.stringify({ + socket: { + name: 'socket', + version: '2.1.0-rc.0', + stageId, + }, + }), + '', + ].join('\n'), + ) + execFileSync( + process.execPath, + ['--input-type=module', '-', outputPath, summaryPath, tarballPath], + { input: parser }, + ) + expect(readFileSync(summaryPath, 'utf8')).toContain(stageId) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('selects the expected stage package count from the cross-job version output', () => { + const stage = workflow.jobs.publish.steps.find( + step => step.name === 'Stage release packages', + ) + expect(stage?.env?.VERSION).toBe('${{ needs.verify.outputs.version }}') + expect(stage?.run).toContain( + 'if [[ "$VERSION" == *-* ]]; then EXPECTED=1; fi', + ) + expect(stage?.run).not.toContain('NPM_PRERELEASE_RELEASE') + }) + + it('checks signed default-branch tags and npm availability before finalization', () => { + const steps = githubReleaseWorkflow.jobs.release.steps + const checkout = steps.find( + step => step.name === 'Check out default branch and reserved tag', + ) + const check = steps.find( + step => + step.name === 'Validate release policy and wait for npm publication', + ) + const finalize = steps.find( + step => step.name === 'Create or retry GitHub release finalization', + ) + expect(checkout?.run).toContain('commit.verification.verified') + expect(checkout?.run).toContain('git merge-base --is-ancestor') + expect(check?.run).toContain('availability.mts') + const checkIndex = steps.findIndex(step => step === check) + const finalizeIndex = steps.findIndex(step => step === finalize) + expect(checkIndex).toBeGreaterThanOrEqual(0) + expect(finalizeIndex).toBeGreaterThan(checkIndex) + expect(finalize?.run).toContain('make_latest=false') + expect(finalize?.run).toContain('gh release view "${TAG}" --json databaseId') + expect(finalize?.run).not.toContain('releases/tags/${TAG}') + }) + + it('publishes only socket for the configured prerelease policy', () => { + const verifySteps = workflow.jobs.verify.steps + const versionGuard = verifySteps.find( + step => step.name === 'Validate release policy and choose npm dist-tag', + ) + const versionGuardIndex = verifySteps.findIndex( + step => step.name === 'Validate release policy and choose npm dist-tag', + ) + const buildIndex = verifySteps.findIndex( + step => step.name === 'Build Maven manifest extension jar', + ) + expect(versionGuard).toBeDefined() + expect(versionGuard?.if).toBe('${{ inputs.dry-run == false }}') + expect(versionGuard?.run).toContain('validate-npm-policy.mts') + expect(versionGuardIndex).toBeGreaterThanOrEqual(0) + expect(versionGuardIndex).toBeLessThan(buildIndex) + + const prepareStep = workflow.jobs.prepare.steps.find( + step => step.name === 'Prepare version and changelog', + ) + expect(prepareStep?.env?.SOCKET_CLI_RELEASE_MAJOR).toBe( + '${{ inputs.release-major }}', + ) + expect( + workflow.jobs.verify.steps.find( + step => step.name === 'Build and pack @socketsecurity/cli (legacy)', + )?.if, + ).toBe("${{ env.NPM_PRERELEASE_RELEASE != 'true' }}") + expect( + workflow.jobs.verify.steps.find( + step => step.name === 'Build and pack @socketsecurity/cli-with-sentry', + )?.if, + ).toBe("${{ env.NPM_PRERELEASE_RELEASE != 'true' }}") + + const manifest = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), + ) as { publishConfig?: { tag?: string }; version?: string } + expect(manifest.version).toMatch(/^2\.1\.0-rc\.\d+$/u) + expect(manifest.publishConfig?.tag).toBe('rc-2.1.0') + expect(workflow.on.workflow_dispatch.inputs['release-major']?.default).toBe( + '1', + ) + }) + it('uses the migrated trusted publisher environment', () => { expect(workflow.jobs.publish.environment).toBe('publish-npm') expect(workflow.jobs.publish.permissions['id-token']).toBe('write') diff --git a/test/script-run-main.test.mts b/test/script-run-main.test.mts index dfbae02f70..aae111e1a0 100644 --- a/test/script-run-main.test.mts +++ b/test/script-run-main.test.mts @@ -200,7 +200,7 @@ describe('entry scripts', () => { 'scripts/check.mts', 'scripts/lint.mts', 'scripts/update.mts', - 'scripts/release/bump.mts', + 'scripts/release/prepare.mts', 'scripts/release/open-release-pr.mts', ] diff --git a/test/unit/constants.test.mts b/test/unit/constants.test.mts new file mode 100644 index 0000000000..38d615b32f --- /dev/null +++ b/test/unit/constants.test.mts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' + +import { resolveBlessedTerm } from '../../src/constants.mts' + +describe('Blessed terminal selection', () => { + it('honors an explicitly configured TERM on every platform', () => { + expect(resolveBlessedTerm('xterm-256color', true)).toBe('xterm-256color') + expect(resolveBlessedTerm('xterm', false)).toBe('xterm') + }) + + it('uses platform fallbacks when TERM is not set', () => { + expect(resolveBlessedTerm(undefined, true)).toBe('windows-ansi') + expect(resolveBlessedTerm(undefined, false)).toBe('xterm') + }) +}) diff --git a/test/unit/github/actions/repo/cache-downloads/index.test.mts b/test/unit/github/actions/repo/cache-downloads/index.test.mts new file mode 100644 index 0000000000..f26f0dcc3e --- /dev/null +++ b/test/unit/github/actions/repo/cache-downloads/index.test.mts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@actions/cache', () => ({ + restoreCache: vi.fn(), + saveCache: vi.fn(), +})) + +import { + resolveBlobPath, + runCacheAction, + seaDownloadBlobPath, +} from '../../../../../../.github/actions/repo/cache-downloads/index.mts' + +const reporter = () => ({ + warning: vi.fn(), + output: vi.fn(async () => undefined), +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('SEA download cache action', () => { + it('limits the cache path to content-addressed blobs', () => { + expect(seaDownloadBlobPath('/home/runner')).toBe( + '/home/runner/.cache/socket-sea/downloads/v1/blobs', + ) + expect( + resolveBlobPath('~/.cache/socket-sea/downloads/v1/blobs', '/home/runner'), + ).toBe('/home/runner/.cache/socket-sea/downloads/v1/blobs') + expect(() => + resolveBlobPath('~/.cache/socket-sea/downloads', '/home/runner'), + ).toThrow('must target only the blob directory') + }) + + it('reports exact restore-key hits and passes only blob paths', async () => { + const restoreCache = vi.fn(async () => 'sea-cache-key') + const saveCache = vi.fn() + const report = reporter() + const result = await runCacheAction( + { + mode: 'restore', + key: 'sea-cache-key', + path: '/runner/cache/blobs', + restoreKeys: ['sea-cache-prefix-'], + }, + { restoreCache, saveCache: async () => undefined }, + report, + ) + expect(result).toEqual({ cacheHit: true }) + expect(restoreCache).toHaveBeenCalledWith( + ['/runner/cache/blobs'], + 'sea-cache-key', + ['sea-cache-prefix-'], + ) + expect(report.output).toHaveBeenCalledWith('cache-hit', 'true') + }) + + it('treats fallback restores as misses so trusted jobs can save the new key', async () => { + const report = reporter() + const result = await runCacheAction( + { mode: 'restore', key: 'new-key', path: '/cache/blobs' }, + { restoreCache: async () => 'old-key', saveCache: async () => undefined }, + report, + ) + expect(result).toEqual({ cacheHit: false }) + expect(report.output).toHaveBeenCalledWith('cache-hit', 'false') + }) + + it('warns and continues when the cache service cannot restore', async () => { + const report = reporter() + const result = await runCacheAction( + { mode: 'restore', key: 'key', path: '/cache/blobs' }, + { + restoreCache: async () => { + throw new Error('cache unavailable') + }, + saveCache: async () => undefined, + }, + report, + ) + expect(result).toEqual({ cacheHit: false }) + expect(report.warning).toHaveBeenCalledWith( + 'SEA download cache restore failed: Error: cache unavailable', + ) + expect(report.output).toHaveBeenCalledWith('cache-hit', 'false') + }) + + it('warns and continues when saving fails', async () => { + const report = reporter() + const saveCache = vi.fn(async () => { + throw new Error('save denied') + }) + await expect( + runCacheAction( + { mode: 'save', key: 'key', path: '/cache/blobs' }, + { restoreCache: async () => undefined, saveCache }, + report, + ), + ).resolves.toEqual({ cacheHit: false }) + expect(saveCache).toHaveBeenCalledWith(['/cache/blobs'], 'key') + expect(report.warning).toHaveBeenCalledWith( + 'SEA download cache save failed: Error: save denied', + ) + }) + + it('never saves when the Actions cache mode is read-only', async () => { + vi.stubEnv('ACTIONS_CACHE_MODE', 'read') + const report = reporter() + const saveCache = vi.fn(async () => undefined) + await expect( + runCacheAction( + { mode: 'save', key: 'key', path: '/cache/blobs' }, + { restoreCache: async () => undefined, saveCache }, + report, + ), + ).resolves.toEqual({ cacheHit: false }) + expect(saveCache).not.toHaveBeenCalled() + expect(report.warning).toHaveBeenCalledWith( + 'SEA download cache save skipped in read-only mode', + ) + }) + + it.each(['write-only', 'none'])( + 'skips restore when the Actions cache mode is %s', + async cacheMode => { + const restoreCache = vi.fn(async () => 'key') + vi.stubEnv('ACTIONS_CACHE_MODE', cacheMode) + const report = reporter() + await expect( + runCacheAction( + { mode: 'restore', key: 'key', path: '/cache/blobs' }, + { restoreCache, saveCache: async () => undefined }, + report, + ), + ).resolves.toEqual({ cacheHit: false }) + expect(report.output).toHaveBeenCalledWith('cache-hit', 'false') + expect(restoreCache).not.toHaveBeenCalled() + }, + ) + + it('allows cache restore in read-write mode', async () => { + vi.stubEnv('ACTIONS_CACHE_MODE', 'write') + const report = reporter() + const restoreCache = vi.fn(async () => 'key') + await expect( + runCacheAction( + { mode: 'restore', key: 'key', path: '/cache/blobs' }, + { restoreCache, saveCache: async () => undefined }, + report, + ), + ).resolves.toEqual({ cacheHit: true }) + expect(restoreCache).toHaveBeenCalledOnce() + }) + + it('skips saving when the Actions cache is disabled', async () => { + vi.stubEnv('ACTIONS_CACHE_MODE', 'none') + const report = reporter() + const saveCache = vi.fn(async () => undefined) + await runCacheAction( + { mode: 'save', key: 'key', path: '/cache/blobs' }, + { restoreCache: async () => undefined, saveCache }, + report, + ) + expect(saveCache).not.toHaveBeenCalled() + }) + + it('fails for missing inputs or unknown modes', async () => { + const report = reporter() + const cache = { + restoreCache: async () => undefined, + saveCache: async () => undefined, + } + await expect( + runCacheAction( + { mode: 'restore', key: '', path: '/cache/blobs' }, + cache, + report, + ), + ).rejects.toThrow('key and path are required') + await expect( + runCacheAction( + { mode: 'other' as 'restore', key: 'key', path: '/cache/blobs' }, + cache, + report, + ), + ).rejects.toThrow('Unknown SEA download cache mode') + }) +}) diff --git a/test/unit/lib/native/cli-harness.test.mts b/test/unit/lib/native/cli-harness.test.mts new file mode 100644 index 0000000000..ea1044cd20 --- /dev/null +++ b/test/unit/lib/native/cli-harness.test.mts @@ -0,0 +1,160 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + createNativeTerminalFixture, + nativeFixturePathReplacements, + nativeSpawnEnvironment, + readNativeTestEnvironment, +} from '../../../lib/native/cli-harness.mts' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all( + temporaryDirectories + .splice(0) + .map(directory => rm(directory, { recursive: true, force: true })), + ) +}) + +const makeStage = async (): Promise => { + const stage = await mkdtemp( + path.join(process.env['RUNNER_TEMP'] ?? os.tmpdir(), 'sn-'), + ) + temporaryDirectories.push(stage) + return stage +} + +describe('native CLI harness utilities', () => { + it('validates native runner environment lazily', async () => { + expect(() => readNativeTestEnvironment({})).toThrow( + /binary, Node version, and stage/, + ) + vi.stubEnv('SOCKET_CLI_NATIVE_TEST_BINARY', '') + vi.stubEnv('SOCKET_CLI_NATIVE_TEST_NODE_VERSION', '') + vi.stubEnv('SOCKET_CLI_NATIVE_TEST_STAGE', '') + await expect( + import('../../../lib/native/cli-harness.mts').then(({ captureFlow }) => + captureFlow({ + flow: 'missing-env', + args: [], + expectedText: '', + exitCode: 0, + }), + ), + ).rejects.toThrow(/binary, Node version, and stage/) + }) + + it('reads runner values and keeps Socket config override precedence explicit', () => { + expect( + readNativeTestEnvironment({ + SOCKET_CLI_NATIVE_TEST_BINARY: '/tmp/socket', + SOCKET_CLI_NATIVE_TEST_NODE_VERSION: '26.13.0', + SOCKET_CLI_NATIVE_TEST_STAGE: '/tmp/stage', + }), + ).toEqual({ + binary: '/tmp/socket', + nodeVersion: '26.13.0', + stage: '/tmp/stage', + }) + expect(nativeSpawnEnvironment(['--help'])).toEqual({ + NO_COLOR: '', + CI: '1', + TERM: 'xterm-256color', + SOCKET_CLI_CONFIG: '{}', + }) + expect(nativeSpawnEnvironment(['--config', '{}'])).toEqual({ + NO_COLOR: '', + CI: '1', + TERM: 'xterm-256color', + }) + expect( + nativeSpawnEnvironment(['--help'], { + CI: '0', + SOCKET_CLI_CONFIG: 'explicit', + TERM: 'xterm', + }), + ).toEqual({ + NO_COLOR: '', + CI: '0', + TERM: 'xterm', + SOCKET_CLI_CONFIG: 'explicit', + }) + }) + + it('normalizes full socket.json paths before shorter fixture prefixes', () => { + const posixPaths = nativeFixturePathReplacements( + '/tmp/stage/f', + '/private/tmp/stage/f', + ) + expect(posixPaths).toContainEqual({ + search: '/tmp/stage/f/socket.json', + replacement: '/socket.json', + }) + expect(posixPaths).toContainEqual({ + search: '/private/tmp/stage/f', + replacement: '', + }) + const windowsPaths = nativeFixturePathReplacements( + 'C:\\stage\\f', + 'C:\\stage\\f', + ) + expect(windowsPaths).toContainEqual({ + search: 'C:\\stage\\f\\socket.json', + replacement: '/socket.json', + }) + }) + + it('creates an isolated 100-column terminal fixture with requested rows', async () => { + const stage = await makeStage() + const env = { + SOCKET_CLI_NATIVE_TEST_BINARY: '/tmp/socket', + SOCKET_CLI_NATIVE_TEST_NODE_VERSION: '26.13.0', + SOCKET_CLI_NATIVE_TEST_STAGE: stage, + } + const fixture = await createNativeTerminalFixture(17, env) + expect(fixture.cwd).toBe(path.join(stage, 'f')) + expect(fixture.terminal.cols).toBe(100) + expect(fixture.terminal.rows).toBe(17) + }) + + it('normalizes the socket-patch Windows executable label before terminal layout', async () => { + const stage = await makeStage() + const fixture = await createNativeTerminalFixture(10, { + SOCKET_CLI_NATIVE_TEST_BINARY: '/tmp/socket', + SOCKET_CLI_NATIVE_TEST_NODE_VERSION: '26.13.0', + SOCKET_CLI_NATIVE_TEST_STAGE: stage, + }) + fixture.terminal.backend.feed(Buffer.from('Usage: socket-patch.e')) + fixture.terminal.backend.feed( + Buffer.from('xe apply \r\nsocket-patch-tool.exe\r\n'), + ) + fixture.normalizer.flush() + + expect(fixture.terminal.screen.getText()).toContain( + 'Usage: socket-patch apply ', + ) + expect(fixture.terminal.screen.getText()).toContain('socket-patch-tool.exe') + expect(fixture.normalizer.matched('socket-patch.exe')).toBe(true) + }) + + it('supports wider captures for native help output', async () => { + const stage = await makeStage() + const fixture = await createNativeTerminalFixture( + 20, + { + SOCKET_CLI_NATIVE_TEST_BINARY: '/tmp/socket', + SOCKET_CLI_NATIVE_TEST_NODE_VERSION: '26.13.0', + SOCKET_CLI_NATIVE_TEST_STAGE: stage, + }, + 140, + ) + expect(fixture.terminal.cols).toBe(140) + expect(fixture.terminal.rows).toBe(20) + }) +}) diff --git a/test/unit/lib/native/command-inventory.test.mts b/test/unit/lib/native/command-inventory.test.mts new file mode 100644 index 0000000000..fa3a6ae4f6 --- /dev/null +++ b/test/unit/lib/native/command-inventory.test.mts @@ -0,0 +1,128 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { + declarationFor, + discoverNativeCommandPaths, + literal, + parse, +} from '../../../lib/native/command-inventory.mts' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map(directory => rm(directory, { recursive: true, force: true })), + ) +}) + +async function fixtureRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'sn-command-inventory-')) + temporaryDirectories.push(root) + await mkdir(path.join(root, 'commands'), { recursive: true }) + return root +} + +async function writeSource( + root: string, + file: string, + source: string, +): Promise { + await writeFile(path.join(root, file), source) +} + +describe('native command inventory utilities', () => { + it('discovers and sorts root and nested registrations from an isolated source tree', async () => { + const root = await fixtureRoot() + await writeSource( + root, + 'commands.mts', + ` + import { cmdZed } from './commands/zed.mts' + import { cmdAlpha } from './commands/alpha.mts' + export const rootCommands = { zed: cmdZed, alpha: cmdAlpha } + `, + ) + await writeSource( + root, + 'commands/zed.mts', + ` + import { cmdChild } from './child.mts' + export const cmdZed = { subcommands: { child: cmdChild } } + `, + ) + await writeSource(root, 'commands/child.mts', 'export const cmdChild = {}') + await writeSource(root, 'commands/alpha.mts', 'export const cmdAlpha = {}') + + expect(await discoverNativeCommandPaths(root)).toEqual([ + 'alpha', + 'zed', + 'zed child', + ]) + const ast = await parse(path.join(root, 'commands.mts')) + expect(literal({ type: 'Identifier', name: 'cmdZed' })).toBe('cmdZed') + expect(declarationFor(ast, 'rootCommands').type).toBe('ObjectExpression') + }) + + it('fails closed for unresolved exports and unsupported registration references', async () => { + const root = await fixtureRoot() + await writeSource( + root, + 'commands.mts', + ` + import { missing } from './commands/item.mts' + export const rootCommands = { item: missing } + `, + ) + await writeSource(root, 'commands/item.mts', 'export const other = {}') + await expect(discoverNativeCommandPaths(root)).rejects.toThrow( + /Could not resolve registered command missing/, + ) + + await writeSource( + root, + 'commands.mts', + ` + export const rootCommands = { item: notImported } + `, + ) + await expect(discoverNativeCommandPaths(root)).rejects.toThrow( + /Unsupported command registration reference: notImported/, + ) + }) + + it('fails closed when a command module has ambiguous subcommand registrations', async () => { + const root = await fixtureRoot() + await writeSource( + root, + 'commands.mts', + ` + import { cmdItem } from './commands/item.mts' + export const rootCommands = { item: cmdItem } + `, + ) + await writeSource( + root, + 'commands/item.mts', + ` + export const cmdItem = {} + const unrelated = { subcommands: {} } + const alsoUnrelated = { nested: { subcommands: {} } } + `, + ) + await expect(discoverNativeCommandPaths(root)).rejects.toThrow( + /Ambiguous subcommand registrations for item: 2/, + ) + }) + + it('discovers the complete sorted production registration set', async () => { + const paths = await discoverNativeCommandPaths() + expect(paths).toHaveLength(71) + expect(paths).toEqual([...paths].sort((a, b) => a.localeCompare(b))) + }) +}) diff --git a/test/unit/lib/optimize-svg.test.mts b/test/unit/lib/optimize-svg.test.mts new file mode 100644 index 0000000000..d0160a7893 --- /dev/null +++ b/test/unit/lib/optimize-svg.test.mts @@ -0,0 +1,57 @@ +import { selectRasterizer } from '@termless/core' +import { describe, expect, it } from 'vitest' + +import { optimizeSvg } from '../../lib/optimize-svg.mts' + +const extractStyle = (svg: string) => + svg.match(/ + frame + ` + const optimized = optimizeSvg(source) + expect(extractStyle(optimized)?.trim()).toBe( + extractStyle(source)?.replaceAll("'", ''').trim(), + ) + expect(optimized).toContain('class="f0"') + expect(optimized).toContain('animation: f0 2s steps(1) infinite') + expect(optimized).toContain('font-family: 'Terminal'') + }) +}) diff --git a/test/unit/lib/terminal/animation.test.mts b/test/unit/lib/terminal/animation.test.mts new file mode 100644 index 0000000000..32c8bb584f --- /dev/null +++ b/test/unit/lib/terminal/animation.test.mts @@ -0,0 +1,317 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { createTerminal, selectRasterizer } from '@termless/core' +import { createXtermBackend } from '@termless/xtermjs' +import { describe, expect, it, onTestFinished } from 'vitest' + +import { + assertSamePixels, + captureCheckpoint, + createBlankCell, + createRecording, + createTerminalAnimationSvg, + emptyCell, + expectTerminalAnimation, + findLastOccupiedRow, + isVisiblyOccupied, + renderCaptured, +} from '../../../lib/terminal/animation.mts' + +import type { AnimationFrame } from '@termless/core' + +describe('terminal animation recorder', () => { + it('repairs keyframe separators while preserving every frame and duration', () => { + const frames: AnimationFrame[] = [ + { + svg: 'frame-one', + duration: 100, + }, + { + svg: 'frame-two', + duration: 250, + }, + { + svg: 'frame-three', + duration: 400, + }, + ] + const svg = createTerminalAnimationSvg(frames) + for (const text of ['frame-one', 'frame-two', 'frame-three']) { + expect(svg).toContain(text) + } + expect(svg).toContain('f0 0.75s') + expect(svg).toContain('f1 0.75s') + expect(svg).toContain('f2 0.75s') + const css = [...svg.matchAll(/