@agentrhq/webcmd 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +317 -24
- package/dist/src/adapter-shadow.d.ts +11 -0
- package/dist/src/adapter-shadow.js +72 -0
- package/dist/src/adapter-shadow.test.d.ts +1 -0
- package/dist/src/adapter-shadow.test.js +49 -0
- package/dist/src/adapter-source.d.ts +11 -0
- package/dist/src/adapter-source.js +24 -0
- package/dist/src/adapter-source.test.d.ts +1 -0
- package/dist/src/adapter-source.test.js +29 -0
- package/dist/src/brand.d.ts +9 -0
- package/dist/src/brand.js +9 -0
- package/dist/src/brand.test.d.ts +1 -0
- package/dist/src/brand.test.js +15 -0
- package/dist/src/browser/analyze.d.ts +118 -0
- package/dist/src/browser/analyze.js +405 -0
- package/dist/src/browser/analyze.test.d.ts +1 -0
- package/dist/src/browser/analyze.test.js +209 -0
- package/dist/src/browser/article-extract.d.ts +57 -0
- package/dist/src/browser/article-extract.e2e.test.d.ts +1 -0
- package/dist/src/browser/article-extract.e2e.test.js +105 -0
- package/dist/src/browser/article-extract.js +169 -0
- package/dist/src/browser/article-extract.test.d.ts +1 -0
- package/dist/src/browser/article-extract.test.js +94 -0
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +151 -0
- package/dist/src/browser/base-page.js +1060 -0
- package/dist/src/browser/base-page.test.d.ts +1 -0
- package/dist/src/browser/base-page.test.js +803 -0
- package/dist/src/browser/bridge-readiness.d.ts +14 -0
- package/dist/src/browser/bridge-readiness.js +24 -0
- package/dist/src/browser/bridge-readiness.test.d.ts +1 -0
- package/dist/src/browser/bridge-readiness.test.js +58 -0
- package/dist/src/browser/bridge.d.ts +26 -0
- package/dist/src/browser/bridge.js +58 -0
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.d.ts +46 -0
- package/dist/src/browser/cdp.js +536 -0
- package/dist/src/browser/cdp.test.d.ts +1 -0
- package/dist/src/browser/cdp.test.js +79 -0
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.d.ts +1 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/config.d.ts +2 -0
- package/dist/src/browser/config.js +14 -0
- package/dist/src/browser/daemon-client.d.ts +32 -0
- package/dist/src/browser/daemon-client.js +135 -0
- package/dist/src/browser/daemon-client.test.d.ts +1 -0
- package/dist/src/browser/daemon-client.test.js +326 -0
- package/dist/src/browser/daemon-lifecycle.d.ts +37 -0
- package/dist/src/browser/daemon-lifecycle.js +147 -0
- package/dist/src/browser/daemon-transport.d.ts +54 -0
- package/dist/src/browser/daemon-transport.js +68 -0
- package/dist/src/browser/daemon-version.d.ts +4 -0
- package/dist/src/browser/daemon-version.js +12 -0
- package/dist/src/browser/dom-helpers.d.ts +41 -0
- package/dist/src/browser/dom-helpers.js +239 -0
- package/dist/src/browser/dom-helpers.test.d.ts +1 -0
- package/dist/src/browser/dom-helpers.test.js +106 -0
- package/dist/src/browser/dom-snapshot.d.ts +93 -0
- package/dist/src/browser/dom-snapshot.js +894 -0
- package/dist/src/browser/dom-snapshot.test.d.ts +11 -0
- package/dist/src/browser/dom-snapshot.test.js +313 -0
- package/dist/src/browser/errors.d.ts +38 -0
- package/dist/src/browser/errors.js +79 -0
- package/dist/src/browser/errors.test.d.ts +1 -0
- package/dist/src/browser/errors.test.js +60 -0
- package/dist/src/browser/extract.d.ts +69 -0
- package/dist/src/browser/extract.js +132 -0
- package/dist/src/browser/extract.test.d.ts +1 -0
- package/dist/src/browser/extract.test.js +129 -0
- package/dist/src/browser/find.d.ts +84 -0
- package/dist/src/browser/find.js +398 -0
- package/dist/src/browser/find.test.d.ts +1 -0
- package/dist/src/browser/find.test.js +180 -0
- package/dist/src/browser/html-tree.d.ts +75 -0
- package/dist/src/browser/html-tree.js +112 -0
- package/dist/src/browser/html-tree.test.d.ts +1 -0
- package/dist/src/browser/html-tree.test.js +181 -0
- package/dist/src/browser/index.d.ts +14 -0
- package/dist/src/browser/index.js +12 -0
- package/dist/src/browser/network-cache.d.ts +49 -0
- package/dist/src/browser/network-cache.js +78 -0
- package/dist/src/browser/network-cache.test.d.ts +1 -0
- package/dist/src/browser/network-cache.test.js +75 -0
- package/dist/src/browser/network-interceptor.d.ts +11 -0
- package/dist/src/browser/network-interceptor.js +11 -0
- package/dist/src/browser/network-key.d.ts +22 -0
- package/dist/src/browser/network-key.js +66 -0
- package/dist/src/browser/network-key.test.d.ts +1 -0
- package/dist/src/browser/network-key.test.js +49 -0
- package/dist/src/browser/page.d.ts +83 -0
- package/dist/src/browser/page.js +453 -0
- package/dist/src/browser/page.test.d.ts +1 -0
- package/dist/src/browser/page.test.js +406 -0
- package/dist/src/browser/profile.d.ts +14 -0
- package/dist/src/browser/profile.js +86 -0
- package/dist/src/browser/protocol.d.ts +63 -0
- package/dist/src/browser/protocol.js +1 -0
- package/dist/src/browser/runtime/local-cloak/actions.d.ts +3 -0
- package/dist/src/browser/runtime/local-cloak/actions.js +240 -0
- package/dist/src/browser/runtime/local-cloak/downloads.d.ts +3 -0
- package/dist/src/browser/runtime/local-cloak/downloads.js +28 -0
- package/dist/src/browser/runtime/local-cloak/network.d.ts +28 -0
- package/dist/src/browser/runtime/local-cloak/network.js +120 -0
- package/dist/src/browser/runtime/local-cloak/network.test.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/network.test.js +124 -0
- package/dist/src/browser/runtime/local-cloak/profiles.d.ts +6 -0
- package/dist/src/browser/runtime/local-cloak/profiles.js +17 -0
- package/dist/src/browser/runtime/local-cloak/profiles.test.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/profiles.test.js +19 -0
- package/dist/src/browser/runtime/local-cloak/provider.d.ts +15 -0
- package/dist/src/browser/runtime/local-cloak/provider.js +27 -0
- package/dist/src/browser/runtime/local-cloak/provider.test.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/provider.test.js +244 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +77 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +280 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.test.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +92 -0
- package/dist/src/browser/runtime/provider.d.ts +9 -0
- package/dist/src/browser/runtime/provider.js +1 -0
- package/dist/src/browser/runtime/provider.test.d.ts +2 -0
- package/dist/src/browser/runtime/provider.test.js +49 -0
- package/dist/src/browser/shape-filter.d.ts +52 -0
- package/dist/src/browser/shape-filter.js +101 -0
- package/dist/src/browser/shape-filter.test.d.ts +1 -0
- package/dist/src/browser/shape-filter.test.js +101 -0
- package/dist/src/browser/shape.d.ts +23 -0
- package/dist/src/browser/shape.js +95 -0
- package/dist/src/browser/shape.test.d.ts +1 -0
- package/dist/src/browser/shape.test.js +82 -0
- package/dist/src/browser/stealth.d.ts +11 -0
- package/dist/src/browser/stealth.js +359 -0
- package/dist/src/browser/stealth.test.d.ts +1 -0
- package/dist/src/browser/stealth.test.js +134 -0
- package/dist/src/browser/tabs.d.ts +13 -0
- package/dist/src/browser/tabs.js +70 -0
- package/dist/src/browser/target-errors.d.ts +39 -0
- package/dist/src/browser/target-errors.js +45 -0
- package/dist/src/browser/target-errors.test.d.ts +1 -0
- package/dist/src/browser/target-errors.test.js +94 -0
- package/dist/src/browser/target-resolver.d.ts +158 -0
- package/dist/src/browser/target-resolver.js +570 -0
- package/dist/src/browser/target-resolver.test.d.ts +1 -0
- package/dist/src/browser/target-resolver.test.js +118 -0
- package/dist/src/browser/utils.d.ts +20 -0
- package/dist/src/browser/utils.js +64 -0
- package/dist/src/browser/utils.test.d.ts +1 -0
- package/dist/src/browser/utils.test.js +29 -0
- package/dist/src/browser/verify-fixture.d.ts +65 -0
- package/dist/src/browser/verify-fixture.js +318 -0
- package/dist/src/browser/verify-fixture.test.d.ts +1 -0
- package/dist/src/browser/verify-fixture.test.js +219 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/browser.test.d.ts +1 -0
- package/dist/src/browser.test.js +297 -0
- package/dist/src/build-manifest.d.ts +103 -0
- package/dist/src/build-manifest.js +363 -0
- package/dist/src/build-manifest.test.d.ts +1 -0
- package/dist/src/build-manifest.test.js +341 -0
- package/dist/src/capabilityRouting.d.ts +19 -0
- package/dist/src/capabilityRouting.js +56 -0
- package/dist/src/capabilityRouting.test.d.ts +1 -0
- package/dist/src/capabilityRouting.test.js +63 -0
- package/dist/src/cli-argv-preprocess.d.ts +63 -0
- package/dist/src/cli-argv-preprocess.js +303 -0
- package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
- package/dist/src/cli-argv-preprocess.test.js +304 -0
- package/dist/src/cli.d.ts +79 -0
- package/dist/src/cli.js +3350 -0
- package/dist/src/cli.test.d.ts +1 -0
- package/dist/src/cli.test.js +3041 -0
- package/dist/src/commanderAdapter.d.ts +21 -0
- package/dist/src/commanderAdapter.js +208 -0
- package/dist/src/commanderAdapter.test.d.ts +1 -0
- package/dist/src/commanderAdapter.test.js +352 -0
- package/dist/src/commands/auth.d.ts +40 -0
- package/dist/src/commands/auth.js +434 -0
- package/dist/src/commands/auth.test.d.ts +1 -0
- package/dist/src/commands/auth.test.js +252 -0
- package/dist/src/commands/daemon.d.ts +9 -0
- package/dist/src/commands/daemon.js +91 -0
- package/dist/src/commands/daemon.test.d.ts +1 -0
- package/dist/src/commands/daemon.test.js +293 -0
- package/dist/src/completion-fast.d.ts +22 -0
- package/dist/src/completion-fast.js +88 -0
- package/dist/src/completion-shared.d.ts +13 -0
- package/dist/src/completion-shared.js +61 -0
- package/dist/src/completion.d.ts +21 -0
- package/dist/src/completion.js +68 -0
- package/dist/src/completion.test.d.ts +1 -0
- package/dist/src/completion.test.js +24 -0
- package/dist/src/constants.d.ts +18 -0
- package/dist/src/constants.js +41 -0
- package/dist/src/convention-audit.d.ts +50 -0
- package/dist/src/convention-audit.js +553 -0
- package/dist/src/convention-audit.test.d.ts +1 -0
- package/dist/src/convention-audit.test.js +255 -0
- package/dist/src/daemon/server.d.ts +13 -0
- package/dist/src/daemon/server.js +185 -0
- package/dist/src/daemon/server.test.d.ts +1 -0
- package/dist/src/daemon/server.test.js +132 -0
- package/dist/src/daemon-utils.d.ts +18 -0
- package/dist/src/daemon-utils.js +37 -0
- package/dist/src/daemon.d.ts +1 -0
- package/dist/src/daemon.js +27 -0
- package/dist/src/daemon.test.d.ts +1 -0
- package/dist/src/daemon.test.js +60 -0
- package/dist/src/discovery.d.ts +46 -0
- package/dist/src/discovery.js +341 -0
- package/dist/src/doctor.d.ts +39 -0
- package/dist/src/doctor.js +168 -0
- package/dist/src/doctor.test.d.ts +1 -0
- package/dist/src/doctor.test.js +302 -0
- package/dist/src/download/article-download.d.ts +72 -0
- package/dist/src/download/article-download.js +305 -0
- package/dist/src/download/article-download.test.d.ts +1 -0
- package/dist/src/download/article-download.test.js +235 -0
- package/dist/src/download/index.d.ts +71 -0
- package/dist/src/download/index.js +363 -0
- package/dist/src/download/index.test.d.ts +1 -0
- package/dist/src/download/index.test.js +174 -0
- package/dist/src/download/media-download.d.ts +49 -0
- package/dist/src/download/media-download.js +127 -0
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +112 -0
- package/dist/src/download/progress.d.ts +36 -0
- package/dist/src/download/progress.js +120 -0
- package/dist/src/download/progress.test.d.ts +1 -0
- package/dist/src/download/progress.test.js +36 -0
- package/dist/src/electron-apps.d.ts +31 -0
- package/dist/src/electron-apps.js +91 -0
- package/dist/src/electron-apps.test.d.ts +1 -0
- package/dist/src/electron-apps.test.js +76 -0
- package/dist/src/engine.test.d.ts +1 -0
- package/dist/src/engine.test.js +312 -0
- package/dist/src/errors.d.ts +114 -0
- package/dist/src/errors.js +174 -0
- package/dist/src/errors.test.d.ts +1 -0
- package/dist/src/errors.test.js +109 -0
- package/dist/src/execution.d.ts +24 -0
- package/dist/src/execution.js +509 -0
- package/dist/src/execution.test.d.ts +1 -0
- package/dist/src/execution.test.js +645 -0
- package/dist/src/external.d.ts +49 -0
- package/dist/src/external.js +211 -0
- package/dist/src/external.test.d.ts +1 -0
- package/dist/src/external.test.js +110 -0
- package/dist/src/help.d.ts +88 -0
- package/dist/src/help.js +556 -0
- package/dist/src/help.test.d.ts +1 -0
- package/dist/src/help.test.js +58 -0
- package/dist/src/hooks.d.ts +46 -0
- package/dist/src/hooks.js +58 -0
- package/dist/src/hooks.test.d.ts +4 -0
- package/dist/src/hooks.test.js +92 -0
- package/dist/src/interceptor.d.ts +44 -0
- package/dist/src/interceptor.js +183 -0
- package/dist/src/interceptor.test.d.ts +4 -0
- package/dist/src/interceptor.test.js +81 -0
- package/dist/src/launcher.d.ts +41 -0
- package/dist/src/launcher.js +226 -0
- package/dist/src/launcher.test.d.ts +1 -0
- package/dist/src/launcher.test.js +153 -0
- package/dist/src/logger.d.ts +26 -0
- package/dist/src/logger.js +49 -0
- package/dist/src/main.d.ts +5 -0
- package/dist/src/main.js +177 -0
- package/dist/src/manifest-types.d.ts +43 -0
- package/dist/src/manifest-types.js +9 -0
- package/dist/src/node-network.d.ts +10 -0
- package/dist/src/node-network.js +174 -0
- package/dist/src/node-network.test.d.ts +1 -0
- package/dist/src/node-network.test.js +55 -0
- package/dist/src/observation/artifact.d.ts +16 -0
- package/dist/src/observation/artifact.js +260 -0
- package/dist/src/observation/artifact.test.d.ts +1 -0
- package/dist/src/observation/artifact.test.js +121 -0
- package/dist/src/observation/events.d.ts +89 -0
- package/dist/src/observation/events.js +1 -0
- package/dist/src/observation/index.d.ts +7 -0
- package/dist/src/observation/index.js +7 -0
- package/dist/src/observation/manager.d.ts +9 -0
- package/dist/src/observation/manager.js +27 -0
- package/dist/src/observation/manager.test.d.ts +1 -0
- package/dist/src/observation/manager.test.js +13 -0
- package/dist/src/observation/redaction.d.ts +11 -0
- package/dist/src/observation/redaction.js +81 -0
- package/dist/src/observation/redaction.test.d.ts +1 -0
- package/dist/src/observation/redaction.test.js +32 -0
- package/dist/src/observation/retention.d.ts +32 -0
- package/dist/src/observation/retention.js +160 -0
- package/dist/src/observation/retention.test.d.ts +1 -0
- package/dist/src/observation/retention.test.js +118 -0
- package/dist/src/observation/ring-buffer.d.ts +22 -0
- package/dist/src/observation/ring-buffer.js +45 -0
- package/dist/src/observation/ring-buffer.test.d.ts +1 -0
- package/dist/src/observation/ring-buffer.test.js +22 -0
- package/dist/src/observation/session.d.ts +25 -0
- package/dist/src/observation/session.js +50 -0
- package/dist/src/output.d.ts +14 -0
- package/dist/src/output.js +152 -0
- package/dist/src/output.test.d.ts +1 -0
- package/dist/src/output.test.js +53 -0
- package/dist/src/package-exports.test.d.ts +1 -0
- package/dist/src/package-exports.test.js +112 -0
- package/dist/src/package-paths.d.ts +8 -0
- package/dist/src/package-paths.js +41 -0
- package/dist/src/pipeline/executor.d.ts +11 -0
- package/dist/src/pipeline/executor.js +93 -0
- package/dist/src/pipeline/executor.test.d.ts +4 -0
- package/dist/src/pipeline/executor.test.js +182 -0
- package/dist/src/pipeline/index.d.ts +5 -0
- package/dist/src/pipeline/index.js +5 -0
- package/dist/src/pipeline/registry.d.ts +28 -0
- package/dist/src/pipeline/registry.js +55 -0
- package/dist/src/pipeline/steps/browser.d.ts +13 -0
- package/dist/src/pipeline/steps/browser.js +86 -0
- package/dist/src/pipeline/steps/download.d.ts +18 -0
- package/dist/src/pipeline/steps/download.js +252 -0
- package/dist/src/pipeline/steps/download.test.d.ts +1 -0
- package/dist/src/pipeline/steps/download.test.js +102 -0
- package/dist/src/pipeline/steps/fetch.d.ts +5 -0
- package/dist/src/pipeline/steps/fetch.js +122 -0
- package/dist/src/pipeline/steps/fetch.test.d.ts +1 -0
- package/dist/src/pipeline/steps/fetch.test.js +117 -0
- package/dist/src/pipeline/steps/intercept.d.ts +5 -0
- package/dist/src/pipeline/steps/intercept.js +50 -0
- package/dist/src/pipeline/steps/tap.d.ts +12 -0
- package/dist/src/pipeline/steps/tap.js +93 -0
- package/dist/src/pipeline/steps/transform.d.ts +9 -0
- package/dist/src/pipeline/steps/transform.js +70 -0
- package/dist/src/pipeline/template.d.ts +17 -0
- package/dist/src/pipeline/template.js +344 -0
- package/dist/src/pipeline/template.test.d.ts +4 -0
- package/dist/src/pipeline/template.test.js +197 -0
- package/dist/src/pipeline/transform.test.d.ts +4 -0
- package/dist/src/pipeline/transform.test.js +133 -0
- package/dist/src/plugin-manifest.d.ts +70 -0
- package/dist/src/plugin-manifest.js +160 -0
- package/dist/src/plugin-manifest.test.d.ts +4 -0
- package/dist/src/plugin-manifest.test.js +179 -0
- package/dist/src/plugin-scaffold.d.ts +28 -0
- package/dist/src/plugin-scaffold.js +143 -0
- package/dist/src/plugin-scaffold.test.d.ts +4 -0
- package/dist/src/plugin-scaffold.test.js +83 -0
- package/dist/src/plugin.d.ts +146 -0
- package/dist/src/plugin.js +1303 -0
- package/dist/src/plugin.test.d.ts +4 -0
- package/dist/src/plugin.test.js +1363 -0
- package/dist/src/registry-api.d.ts +13 -0
- package/dist/src/registry-api.js +10 -0
- package/dist/src/registry.d.ts +123 -0
- package/dist/src/registry.js +125 -0
- package/dist/src/registry.test.d.ts +4 -0
- package/dist/src/registry.test.js +209 -0
- package/dist/src/runtime-copy.test.d.ts +1 -0
- package/dist/src/runtime-copy.test.js +29 -0
- package/dist/src/runtime-detect.d.ts +31 -0
- package/dist/src/runtime-detect.js +47 -0
- package/dist/src/runtime-detect.test.d.ts +1 -0
- package/dist/src/runtime-detect.test.js +39 -0
- package/dist/src/runtime-identity.test.d.ts +1 -0
- package/dist/src/runtime-identity.test.js +18 -0
- package/dist/src/runtime.d.ts +47 -0
- package/dist/src/runtime.js +54 -0
- package/dist/src/scripts/framework.d.ts +7 -0
- package/dist/src/scripts/framework.js +25 -0
- package/dist/src/scripts/interact.d.ts +4 -0
- package/dist/src/scripts/interact.js +20 -0
- package/dist/src/scripts/store.d.ts +13 -0
- package/dist/src/scripts/store.js +48 -0
- package/dist/src/serialization.d.ts +42 -0
- package/dist/src/serialization.js +103 -0
- package/dist/src/serialization.test.d.ts +1 -0
- package/dist/src/serialization.test.js +78 -0
- package/dist/src/skills.d.ts +14 -0
- package/dist/src/skills.js +127 -0
- package/dist/src/skills.test.d.ts +1 -0
- package/dist/src/skills.test.js +71 -0
- package/dist/src/snapshotFormatter.d.ts +11 -0
- package/dist/src/snapshotFormatter.js +338 -0
- package/dist/src/snapshotFormatter.test.d.ts +7 -0
- package/dist/src/snapshotFormatter.test.js +521 -0
- package/dist/src/tui.d.ts +28 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/tui.test.d.ts +1 -0
- package/dist/src/tui.test.js +19 -0
- package/dist/src/types.d.ts +236 -0
- package/dist/src/types.js +7 -0
- package/dist/src/update-check.d.ts +66 -0
- package/dist/src/update-check.js +206 -0
- package/dist/src/update-check.test.d.ts +1 -0
- package/dist/src/update-check.test.js +117 -0
- package/dist/src/utils.d.ts +57 -0
- package/dist/src/utils.js +152 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/dist/src/validate.d.ts +21 -0
- package/dist/src/validate.js +130 -0
- package/dist/src/validate.test.d.ts +9 -0
- package/dist/src/validate.test.js +90 -0
- package/dist/src/verify.d.ts +26 -0
- package/dist/src/verify.js +67 -0
- package/dist/src/version.d.ts +4 -0
- package/dist/src/version.js +22 -0
- package/dist/src/weixin-download.test.d.ts +1 -0
- package/dist/src/weixin-download.test.js +45 -0
- package/package.json +4 -3
- package/scripts/check-package-bin.mjs +95 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as http from 'node:http';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { detectContentType, exportCookiesToNetscape, formatCookieHeader, httpDownload, requiresYtdlp, resolveRedirectUrl } from './index.js';
|
|
7
|
+
const servers = [];
|
|
8
|
+
const tempDirs = [];
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
vi.unstubAllEnvs();
|
|
11
|
+
await Promise.all(servers.map((server) => new Promise((resolve, reject) => {
|
|
12
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
13
|
+
})));
|
|
14
|
+
servers.length = 0;
|
|
15
|
+
for (const dir of tempDirs) {
|
|
16
|
+
try {
|
|
17
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
catch { /* ignore */ }
|
|
20
|
+
}
|
|
21
|
+
tempDirs.length = 0;
|
|
22
|
+
});
|
|
23
|
+
async function startServer(handler, hostname = '127.0.0.1') {
|
|
24
|
+
const server = http.createServer(handler);
|
|
25
|
+
servers.push(server);
|
|
26
|
+
await new Promise((resolve) => server.listen(0, hostname, resolve));
|
|
27
|
+
const address = server.address();
|
|
28
|
+
if (!address || typeof address === 'string') {
|
|
29
|
+
throw new Error('Failed to start test server');
|
|
30
|
+
}
|
|
31
|
+
return `http://${hostname}:${address.port}`;
|
|
32
|
+
}
|
|
33
|
+
// Windows Defender can briefly lock newly-written .tmp files, causing EPERM.
|
|
34
|
+
// Retry once to handle this flakiness.
|
|
35
|
+
describe('download helpers', { retry: process.platform === 'win32' ? 2 : 0 }, () => {
|
|
36
|
+
it('resolves relative redirects against the original URL', () => {
|
|
37
|
+
expect(resolveRedirectUrl('https://example.com/a/file', '/cdn/file.bin')).toBe('https://example.com/cdn/file.bin');
|
|
38
|
+
expect(resolveRedirectUrl('https://example.com/a/file', '../next')).toBe('https://example.com/next');
|
|
39
|
+
});
|
|
40
|
+
it('formats browser cookies into a Cookie header', () => {
|
|
41
|
+
expect(formatCookieHeader([
|
|
42
|
+
{ name: 'sid', value: 'abc', domain: 'example.com' },
|
|
43
|
+
{ name: 'ct0', value: 'def', domain: 'example.com' },
|
|
44
|
+
])).toBe('sid=abc; ct0=def');
|
|
45
|
+
});
|
|
46
|
+
it('fails after exceeding the redirect limit', async () => {
|
|
47
|
+
const baseUrl = await startServer((_req, res) => {
|
|
48
|
+
res.statusCode = 302;
|
|
49
|
+
res.setHeader('Location', '/loop');
|
|
50
|
+
res.end();
|
|
51
|
+
});
|
|
52
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-dl-'));
|
|
53
|
+
tempDirs.push(tempDir);
|
|
54
|
+
const destPath = path.join(tempDir, 'file.txt');
|
|
55
|
+
const result = await httpDownload(`${baseUrl}/loop`, destPath, { maxRedirects: 2 });
|
|
56
|
+
expect(result).toEqual({
|
|
57
|
+
success: false,
|
|
58
|
+
size: 0,
|
|
59
|
+
error: 'Too many redirects (> 2)',
|
|
60
|
+
});
|
|
61
|
+
expect(fs.existsSync(destPath)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
it('does not forward cookies across cross-domain redirects', async () => {
|
|
64
|
+
let forwardedCookie;
|
|
65
|
+
const targetUrl = await startServer((req, res) => {
|
|
66
|
+
forwardedCookie = req.headers.cookie;
|
|
67
|
+
res.statusCode = 200;
|
|
68
|
+
res.end('ok');
|
|
69
|
+
}, 'localhost');
|
|
70
|
+
const redirectUrl = await startServer((_req, res) => {
|
|
71
|
+
res.statusCode = 302;
|
|
72
|
+
res.setHeader('Location', targetUrl);
|
|
73
|
+
res.end();
|
|
74
|
+
});
|
|
75
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-dl-'));
|
|
76
|
+
tempDirs.push(tempDir);
|
|
77
|
+
const destPath = path.join(tempDir, 'redirect.txt');
|
|
78
|
+
const result = await httpDownload(`${redirectUrl}/start`, destPath, { cookies: 'sid=abc' });
|
|
79
|
+
expect(result).toEqual({ success: true, size: 2 });
|
|
80
|
+
expect(forwardedCookie).toBeUndefined();
|
|
81
|
+
expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
|
|
82
|
+
});
|
|
83
|
+
it('does not forward cookie headers across cross-domain redirects', async () => {
|
|
84
|
+
let forwardedCookie;
|
|
85
|
+
const targetUrl = await startServer((req, res) => {
|
|
86
|
+
forwardedCookie = req.headers.cookie;
|
|
87
|
+
res.statusCode = 200;
|
|
88
|
+
res.end('ok');
|
|
89
|
+
}, 'localhost');
|
|
90
|
+
const redirectUrl = await startServer((_req, res) => {
|
|
91
|
+
res.statusCode = 302;
|
|
92
|
+
res.setHeader('Location', targetUrl);
|
|
93
|
+
res.end();
|
|
94
|
+
});
|
|
95
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-dl-'));
|
|
96
|
+
tempDirs.push(tempDir);
|
|
97
|
+
const destPath = path.join(tempDir, 'redirect-header.txt');
|
|
98
|
+
const result = await httpDownload(`${redirectUrl}/start`, destPath, {
|
|
99
|
+
headers: { Cookie: 'sid=header-cookie' },
|
|
100
|
+
});
|
|
101
|
+
expect(result).toEqual({ success: true, size: 2 });
|
|
102
|
+
expect(forwardedCookie).toBeUndefined();
|
|
103
|
+
expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
|
|
104
|
+
});
|
|
105
|
+
it('bypasses proxy settings for loopback downloads', async () => {
|
|
106
|
+
vi.stubEnv('HTTP_PROXY', 'http://127.0.0.1:9');
|
|
107
|
+
const baseUrl = await startServer((_req, res) => {
|
|
108
|
+
res.statusCode = 200;
|
|
109
|
+
res.end('ok');
|
|
110
|
+
});
|
|
111
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-dl-'));
|
|
112
|
+
tempDirs.push(tempDir);
|
|
113
|
+
const destPath = path.join(tempDir, 'loopback.txt');
|
|
114
|
+
const result = await httpDownload(`${baseUrl}/ok`, destPath);
|
|
115
|
+
expect(result).toEqual({ success: true, size: 2 });
|
|
116
|
+
expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
|
|
117
|
+
});
|
|
118
|
+
it.skipIf(process.platform === 'win32')('writes the Netscape cookie file with 0o600 owner-only permissions', async () => {
|
|
119
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-dl-'));
|
|
120
|
+
tempDirs.push(tempDir);
|
|
121
|
+
const cookiesPath = path.join(tempDir, 'cookies.txt');
|
|
122
|
+
exportCookiesToNetscape([
|
|
123
|
+
{ name: 'sid', value: 'secret', domain: 'example.com' },
|
|
124
|
+
], cookiesPath);
|
|
125
|
+
const mode = fs.statSync(cookiesPath).mode & 0o777;
|
|
126
|
+
expect(mode).toBe(0o600);
|
|
127
|
+
});
|
|
128
|
+
it.skipIf(process.platform === 'win32')('tightens an existing Netscape cookie file before rewriting it', async () => {
|
|
129
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-dl-'));
|
|
130
|
+
tempDirs.push(tempDir);
|
|
131
|
+
const cookiesPath = path.join(tempDir, 'cookies.txt');
|
|
132
|
+
fs.writeFileSync(cookiesPath, 'stale-cookie', { mode: 0o644 });
|
|
133
|
+
exportCookiesToNetscape([
|
|
134
|
+
{ name: 'sid', value: 'secret', domain: 'example.com' },
|
|
135
|
+
], cookiesPath);
|
|
136
|
+
const mode = fs.statSync(cookiesPath).mode & 0o777;
|
|
137
|
+
expect(mode).toBe(0o600);
|
|
138
|
+
expect(fs.readFileSync(cookiesPath, 'utf8')).toContain('sid\tsecret');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe('video-platform host detection', () => {
|
|
142
|
+
it('matches real video-platform hosts (host or subdomain)', () => {
|
|
143
|
+
for (const url of [
|
|
144
|
+
'https://www.youtube.com/watch?v=abc',
|
|
145
|
+
'https://youtu.be/abc',
|
|
146
|
+
'https://m.bilibili.com/video/BV1xx',
|
|
147
|
+
'https://twitter.com/u/status/1',
|
|
148
|
+
'https://x.com/u/status/1',
|
|
149
|
+
'https://www.tiktok.com/@u/video/1',
|
|
150
|
+
'https://vimeo.com/12345',
|
|
151
|
+
'https://www.twitch.tv/u',
|
|
152
|
+
]) {
|
|
153
|
+
expect(requiresYtdlp(url)).toBe(true);
|
|
154
|
+
expect(detectContentType(url)).toBe('video');
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
it('does not match hosts that merely end with a token or carry it in the path', () => {
|
|
158
|
+
// netflix.com / max.com / phoenix.com all *contain* the substring "x.com";
|
|
159
|
+
// the path-only token "youtu.be-guide" must not match either.
|
|
160
|
+
for (const url of [
|
|
161
|
+
'https://netflix.com/watch/123',
|
|
162
|
+
'https://www.phoenix.com/a.zip',
|
|
163
|
+
'https://max.com/movie',
|
|
164
|
+
'https://cdn.site.com/youtu.be-guide.bin',
|
|
165
|
+
'https://notx.com/a',
|
|
166
|
+
]) {
|
|
167
|
+
expect(requiresYtdlp(url)).toBe(false);
|
|
168
|
+
expect(detectContentType(url)).not.toBe('video');
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
it('returns false for an unparseable URL instead of throwing', () => {
|
|
172
|
+
expect(requiresYtdlp('not a url')).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media download helper — shared logic for batch downloading images/videos.
|
|
3
|
+
*
|
|
4
|
+
* Used by: xiaohongshu/download, twitter/download, bilibili/download,
|
|
5
|
+
* and future media adapters.
|
|
6
|
+
*
|
|
7
|
+
* Flow: MediaItem[] → DownloadProgressTracker → httpDownload/ytdlpDownload → results
|
|
8
|
+
*/
|
|
9
|
+
import type { BrowserCookie } from '../types.js';
|
|
10
|
+
export interface MediaItem {
|
|
11
|
+
type: 'image' | 'video' | 'video-tweet' | 'video-ytdlp';
|
|
12
|
+
url: string;
|
|
13
|
+
/** Optional custom filename (without directory) */
|
|
14
|
+
filename?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface MediaDownloadOptions {
|
|
17
|
+
output: string;
|
|
18
|
+
/** Subdirectory inside output */
|
|
19
|
+
subdir?: string;
|
|
20
|
+
/** Cookie string for HTTP downloads */
|
|
21
|
+
cookies?: string;
|
|
22
|
+
/** Raw browser cookies — auto-exported to Netscape for yt-dlp, auto-cleaned up */
|
|
23
|
+
browserCookies?: BrowserCookie[];
|
|
24
|
+
/** Timeout in ms (default: 30000 for images, 60000 for videos) */
|
|
25
|
+
timeout?: number;
|
|
26
|
+
/** File name prefix (default: 'download') */
|
|
27
|
+
filenamePrefix?: string;
|
|
28
|
+
/** Extra yt-dlp args */
|
|
29
|
+
ytdlpExtraArgs?: string[];
|
|
30
|
+
/** Whether to show progress (default: true) */
|
|
31
|
+
verbose?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface MediaDownloadResult {
|
|
34
|
+
index: number;
|
|
35
|
+
type: string;
|
|
36
|
+
status: string;
|
|
37
|
+
size: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Batch download media files with progress tracking.
|
|
41
|
+
*
|
|
42
|
+
* Handles:
|
|
43
|
+
* - DownloadProgressTracker for terminal UX
|
|
44
|
+
* - Automatic httpDownload vs ytdlpDownload routing via MediaItem.type
|
|
45
|
+
* - Cookie export to Netscape format for yt-dlp (auto-cleanup)
|
|
46
|
+
* - Directory creation
|
|
47
|
+
* - Error handling with per-file results
|
|
48
|
+
*/
|
|
49
|
+
export declare function downloadMedia(items: MediaItem[], options: MediaDownloadOptions): Promise<MediaDownloadResult[]>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media download helper — shared logic for batch downloading images/videos.
|
|
3
|
+
*
|
|
4
|
+
* Used by: xiaohongshu/download, twitter/download, bilibili/download,
|
|
5
|
+
* and future media adapters.
|
|
6
|
+
*
|
|
7
|
+
* Flow: MediaItem[] → DownloadProgressTracker → httpDownload/ytdlpDownload → results
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import { getErrorMessage } from '../errors.js';
|
|
12
|
+
import { httpDownload, ytdlpDownload, checkYtdlp, getTempDir, exportCookiesToNetscape, sanitizeFilename, } from './index.js';
|
|
13
|
+
import { DownloadProgressTracker, formatBytes } from './progress.js';
|
|
14
|
+
// ============================================================
|
|
15
|
+
// Main API
|
|
16
|
+
// ============================================================
|
|
17
|
+
/**
|
|
18
|
+
* Batch download media files with progress tracking.
|
|
19
|
+
*
|
|
20
|
+
* Handles:
|
|
21
|
+
* - DownloadProgressTracker for terminal UX
|
|
22
|
+
* - Automatic httpDownload vs ytdlpDownload routing via MediaItem.type
|
|
23
|
+
* - Cookie export to Netscape format for yt-dlp (auto-cleanup)
|
|
24
|
+
* - Directory creation
|
|
25
|
+
* - Error handling with per-file results
|
|
26
|
+
*/
|
|
27
|
+
export async function downloadMedia(items, options) {
|
|
28
|
+
const { output, subdir, cookies, browserCookies, timeout, filenamePrefix = 'download', ytdlpExtraArgs = [], verbose = true, } = options;
|
|
29
|
+
if (!items || items.length === 0) {
|
|
30
|
+
return [{ index: 0, type: '-', status: 'failed', size: 'No media found' }];
|
|
31
|
+
}
|
|
32
|
+
// Create output directory
|
|
33
|
+
const outputDir = subdir ? path.join(output, subdir) : output;
|
|
34
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
35
|
+
// Pre-check yt-dlp availability (once, not per-item)
|
|
36
|
+
const hasYtdlp = checkYtdlp();
|
|
37
|
+
// Auto-export browser cookies to Netscape format for yt-dlp
|
|
38
|
+
let cookiesFile;
|
|
39
|
+
const needsYtdlp = items.some(m => m.type === 'video-tweet' || m.type === 'video-ytdlp');
|
|
40
|
+
if (needsYtdlp && browserCookies && browserCookies.length > 0) {
|
|
41
|
+
const tempDir = getTempDir();
|
|
42
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
43
|
+
cookiesFile = path.join(tempDir, `media_cookies_${Date.now()}.txt`);
|
|
44
|
+
exportCookiesToNetscape(browserCookies, cookiesFile);
|
|
45
|
+
}
|
|
46
|
+
const tracker = new DownloadProgressTracker(items.length, verbose);
|
|
47
|
+
const results = [];
|
|
48
|
+
try {
|
|
49
|
+
for (let i = 0; i < items.length; i++) {
|
|
50
|
+
const media = items[i];
|
|
51
|
+
const isVideo = media.type !== 'image';
|
|
52
|
+
const ext = isVideo ? 'mp4' : 'jpg';
|
|
53
|
+
const filename = resolveMediaFilename(media.filename, filenamePrefix, i + 1, ext);
|
|
54
|
+
const destPath = path.join(outputDir, filename);
|
|
55
|
+
const progressBar = tracker.onFileStart(filename, i);
|
|
56
|
+
try {
|
|
57
|
+
let result;
|
|
58
|
+
const useYtdlp = (media.type === 'video-tweet' || media.type === 'video-ytdlp') && hasYtdlp;
|
|
59
|
+
if (useYtdlp) {
|
|
60
|
+
result = await ytdlpDownload(media.url, destPath, {
|
|
61
|
+
cookiesFile,
|
|
62
|
+
extraArgs: ytdlpExtraArgs,
|
|
63
|
+
onProgress: (percent) => {
|
|
64
|
+
if (progressBar)
|
|
65
|
+
progressBar.update(percent, 100);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Direct HTTP download for images and direct video URLs
|
|
71
|
+
const dlTimeout = timeout || (isVideo ? 60000 : 30000);
|
|
72
|
+
result = await httpDownload(media.url, destPath, {
|
|
73
|
+
cookies,
|
|
74
|
+
timeout: dlTimeout,
|
|
75
|
+
onProgress: (received, total) => {
|
|
76
|
+
if (progressBar)
|
|
77
|
+
progressBar.update(received, total);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (progressBar) {
|
|
82
|
+
progressBar.complete(result.success, result.success ? formatBytes(result.size) : undefined);
|
|
83
|
+
}
|
|
84
|
+
tracker.onFileComplete(result.success);
|
|
85
|
+
results.push({
|
|
86
|
+
index: i + 1,
|
|
87
|
+
type: media.type === 'video-tweet' || media.type === 'video-ytdlp' ? 'video' : media.type,
|
|
88
|
+
status: result.success ? 'success' : 'failed',
|
|
89
|
+
size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
const msg = getErrorMessage(err);
|
|
94
|
+
if (progressBar)
|
|
95
|
+
progressBar.fail(msg);
|
|
96
|
+
tracker.onFileComplete(false);
|
|
97
|
+
results.push({
|
|
98
|
+
index: i + 1,
|
|
99
|
+
type: media.type,
|
|
100
|
+
status: 'failed',
|
|
101
|
+
size: msg,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
tracker.finish();
|
|
108
|
+
// Auto-cleanup exported cookies file
|
|
109
|
+
if (cookiesFile && fs.existsSync(cookiesFile)) {
|
|
110
|
+
fs.unlinkSync(cookiesFile);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return results;
|
|
114
|
+
}
|
|
115
|
+
function resolveMediaFilename(filename, prefix, index, ext) {
|
|
116
|
+
const safePrefix = sanitizePathSegment(path.basename(path.win32.basename(prefix))) || 'download';
|
|
117
|
+
const fallback = `${safePrefix}_${index}.${ext}`;
|
|
118
|
+
if (!filename)
|
|
119
|
+
return fallback;
|
|
120
|
+
const basename = path.basename(path.win32.basename(filename));
|
|
121
|
+
const safeName = sanitizePathSegment(basename);
|
|
122
|
+
return safeName || fallback;
|
|
123
|
+
}
|
|
124
|
+
function sanitizePathSegment(value) {
|
|
125
|
+
const sanitized = sanitizeFilename(value);
|
|
126
|
+
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : '';
|
|
127
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as http from 'node:http';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { downloadMedia } from './media-download.js';
|
|
7
|
+
const servers = [];
|
|
8
|
+
const tempDirs = [];
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
await Promise.all(servers.map((server) => new Promise((resolve, reject) => {
|
|
11
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
12
|
+
})));
|
|
13
|
+
servers.length = 0;
|
|
14
|
+
for (const dir of tempDirs) {
|
|
15
|
+
try {
|
|
16
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
catch { /* ignore */ }
|
|
19
|
+
}
|
|
20
|
+
tempDirs.length = 0;
|
|
21
|
+
});
|
|
22
|
+
async function startServer(handler) {
|
|
23
|
+
const server = http.createServer(handler);
|
|
24
|
+
servers.push(server);
|
|
25
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
26
|
+
const address = server.address();
|
|
27
|
+
if (!address || typeof address === 'string') {
|
|
28
|
+
throw new Error('Failed to start test server');
|
|
29
|
+
}
|
|
30
|
+
return `http://127.0.0.1:${address.port}`;
|
|
31
|
+
}
|
|
32
|
+
// Windows runners occasionally exceed the default 5s timeout on the first
|
|
33
|
+
// http.createServer + downloadMedia roundtrip (cold-start cost on a loaded VM).
|
|
34
|
+
describe('media downloads', { retry: process.platform === 'win32' ? 2 : 0 }, () => {
|
|
35
|
+
it('keeps custom filenames inside the output directory', async () => {
|
|
36
|
+
const baseUrl = await startServer((_req, res) => {
|
|
37
|
+
res.statusCode = 200;
|
|
38
|
+
res.end('image');
|
|
39
|
+
});
|
|
40
|
+
const parentDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-media-parent-'));
|
|
41
|
+
tempDirs.push(parentDir);
|
|
42
|
+
const outputDir = path.join(parentDir, 'downloads');
|
|
43
|
+
const results = await downloadMedia([
|
|
44
|
+
{ type: 'image', url: `${baseUrl}/image.jpg`, filename: '../escape.jpg' },
|
|
45
|
+
], {
|
|
46
|
+
output: outputDir,
|
|
47
|
+
verbose: false,
|
|
48
|
+
});
|
|
49
|
+
expect(results).toEqual([
|
|
50
|
+
{ index: 1, type: 'image', status: 'success', size: '5.0 B' },
|
|
51
|
+
]);
|
|
52
|
+
expect(fs.readFileSync(path.join(outputDir, 'escape.jpg'), 'utf8')).toBe('image');
|
|
53
|
+
expect(fs.existsSync(path.join(parentDir, 'escape.jpg'))).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
it('strips nested and absolute path components from custom filenames', async () => {
|
|
56
|
+
const baseUrl = await startServer((_req, res) => {
|
|
57
|
+
res.statusCode = 200;
|
|
58
|
+
res.end('image');
|
|
59
|
+
});
|
|
60
|
+
const parentDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-media-parent-'));
|
|
61
|
+
tempDirs.push(parentDir);
|
|
62
|
+
const outputDir = path.join(parentDir, 'downloads');
|
|
63
|
+
const results = await downloadMedia([
|
|
64
|
+
{ type: 'image', url: `${baseUrl}/one.jpg`, filename: '../../etc/passwd' },
|
|
65
|
+
{ type: 'image', url: `${baseUrl}/two.jpg`, filename: '/tmp/evil.jpg' },
|
|
66
|
+
{ type: 'image', url: `${baseUrl}/three.jpg`, filename: 'nested/path/photo.png' },
|
|
67
|
+
{ type: 'image', url: `${baseUrl}/four.jpg`, filename: '..\\windows\\escape.jpg' },
|
|
68
|
+
], {
|
|
69
|
+
output: outputDir,
|
|
70
|
+
verbose: false,
|
|
71
|
+
});
|
|
72
|
+
expect(results).toEqual([
|
|
73
|
+
{ index: 1, type: 'image', status: 'success', size: '5.0 B' },
|
|
74
|
+
{ index: 2, type: 'image', status: 'success', size: '5.0 B' },
|
|
75
|
+
{ index: 3, type: 'image', status: 'success', size: '5.0 B' },
|
|
76
|
+
{ index: 4, type: 'image', status: 'success', size: '5.0 B' },
|
|
77
|
+
]);
|
|
78
|
+
expect(fs.readFileSync(path.join(outputDir, 'passwd'), 'utf8')).toBe('image');
|
|
79
|
+
expect(fs.readFileSync(path.join(outputDir, 'evil.jpg'), 'utf8')).toBe('image');
|
|
80
|
+
expect(fs.readFileSync(path.join(outputDir, 'photo.png'), 'utf8')).toBe('image');
|
|
81
|
+
expect(fs.readFileSync(path.join(outputDir, 'escape.jpg'), 'utf8')).toBe('image');
|
|
82
|
+
expect(fs.existsSync(path.join(parentDir, 'etc', 'passwd'))).toBe(false);
|
|
83
|
+
expect(fs.existsSync(path.join(parentDir, 'tmp', 'evil.jpg'))).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
it('falls back to generated names for empty dot-directory filenames', async () => {
|
|
86
|
+
const baseUrl = await startServer((_req, res) => {
|
|
87
|
+
res.statusCode = 200;
|
|
88
|
+
res.end('image');
|
|
89
|
+
});
|
|
90
|
+
const parentDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-media-parent-'));
|
|
91
|
+
tempDirs.push(parentDir);
|
|
92
|
+
const outputDir = path.join(parentDir, 'downloads');
|
|
93
|
+
const results = await downloadMedia([
|
|
94
|
+
{ type: 'image', url: `${baseUrl}/dot.jpg`, filename: '.' },
|
|
95
|
+
{ type: 'image', url: `${baseUrl}/dotdot.jpg`, filename: '..' },
|
|
96
|
+
{ type: 'image', url: `${baseUrl}/blank.jpg`, filename: ' ' },
|
|
97
|
+
], {
|
|
98
|
+
output: outputDir,
|
|
99
|
+
filenamePrefix: '../unsafe prefix',
|
|
100
|
+
verbose: false,
|
|
101
|
+
});
|
|
102
|
+
expect(results).toEqual([
|
|
103
|
+
{ index: 1, type: 'image', status: 'success', size: '5.0 B' },
|
|
104
|
+
{ index: 2, type: 'image', status: 'success', size: '5.0 B' },
|
|
105
|
+
{ index: 3, type: 'image', status: 'success', size: '5.0 B' },
|
|
106
|
+
]);
|
|
107
|
+
expect(fs.readFileSync(path.join(outputDir, 'unsafe_prefix_1.jpg'), 'utf8')).toBe('image');
|
|
108
|
+
expect(fs.readFileSync(path.join(outputDir, 'unsafe_prefix_2.jpg'), 'utf8')).toBe('image');
|
|
109
|
+
expect(fs.readFileSync(path.join(outputDir, 'unsafe_prefix_3.jpg'), 'utf8')).toBe('image');
|
|
110
|
+
expect(fs.existsSync(path.join(parentDir, 'unsafe prefix_1.jpg'))).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download progress display: terminal progress bars, status updates.
|
|
3
|
+
*/
|
|
4
|
+
export interface ProgressBar {
|
|
5
|
+
update(current: number, total: number, label?: string): void;
|
|
6
|
+
complete(success: boolean, message?: string): void;
|
|
7
|
+
fail(error: string): void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Format bytes as human-readable string (KB, MB, GB).
|
|
11
|
+
*/
|
|
12
|
+
export declare function formatBytes(bytes: number): string;
|
|
13
|
+
/**
|
|
14
|
+
* Format milliseconds as human-readable duration.
|
|
15
|
+
*/
|
|
16
|
+
export declare function formatDuration(ms: number): string;
|
|
17
|
+
/**
|
|
18
|
+
* Create a simple progress bar for terminal display.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createProgressBar(filename: string, index: number, total: number): ProgressBar;
|
|
21
|
+
/**
|
|
22
|
+
* Multi-file download progress tracker.
|
|
23
|
+
*/
|
|
24
|
+
export declare class DownloadProgressTracker {
|
|
25
|
+
private completed;
|
|
26
|
+
private failed;
|
|
27
|
+
private skipped;
|
|
28
|
+
private total;
|
|
29
|
+
private startTime;
|
|
30
|
+
private verbose;
|
|
31
|
+
constructor(total: number, verbose?: boolean);
|
|
32
|
+
onFileStart(filename: string, index: number): ProgressBar | null;
|
|
33
|
+
onFileComplete(success: boolean, skipped?: boolean): void;
|
|
34
|
+
getSummary(): string;
|
|
35
|
+
finish(): void;
|
|
36
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download progress display: terminal progress bars, status updates.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Format bytes as human-readable string (KB, MB, GB).
|
|
6
|
+
*/
|
|
7
|
+
export function formatBytes(bytes) {
|
|
8
|
+
if (!Number.isFinite(bytes) || bytes < 1)
|
|
9
|
+
return '0 B';
|
|
10
|
+
const k = 1024;
|
|
11
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
12
|
+
const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
|
|
13
|
+
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Format milliseconds as human-readable duration.
|
|
17
|
+
*/
|
|
18
|
+
export function formatDuration(ms) {
|
|
19
|
+
if (ms < 1000)
|
|
20
|
+
return `${ms}ms`;
|
|
21
|
+
const seconds = Math.floor(ms / 1000);
|
|
22
|
+
if (seconds < 60)
|
|
23
|
+
return `${seconds}s`;
|
|
24
|
+
const minutes = Math.floor(seconds / 60);
|
|
25
|
+
if (minutes < 60) {
|
|
26
|
+
const remainingSeconds = seconds % 60;
|
|
27
|
+
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
|
28
|
+
}
|
|
29
|
+
const hours = Math.floor(minutes / 60);
|
|
30
|
+
const remainingMinutes = minutes % 60;
|
|
31
|
+
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Create a simple progress bar for terminal display.
|
|
35
|
+
*/
|
|
36
|
+
export function createProgressBar(filename, index, total) {
|
|
37
|
+
const prefix = `[${index + 1}/${total}]`;
|
|
38
|
+
const truncatedName = filename.length > 40 ? filename.slice(0, 37) + '...' : filename;
|
|
39
|
+
return {
|
|
40
|
+
update(current, totalBytes, label) {
|
|
41
|
+
const percent = clampPercent(totalBytes > 0 ? Math.round((current / totalBytes) * 100) : 0);
|
|
42
|
+
const bar = createBar(percent);
|
|
43
|
+
const size = totalBytes > 0 ? formatBytes(totalBytes) : '';
|
|
44
|
+
const extra = label ? ` ${label}` : '';
|
|
45
|
+
process.stderr.write(`\r${prefix} ${truncatedName} ${bar} ${percent}% ${size}${extra}`);
|
|
46
|
+
},
|
|
47
|
+
complete(success, message) {
|
|
48
|
+
const icon = success ? '✓' : '✗';
|
|
49
|
+
const msg = message ? ` ${message}` : '';
|
|
50
|
+
process.stderr.write(`\r${prefix} ${icon} ${truncatedName}${msg}\n`);
|
|
51
|
+
},
|
|
52
|
+
fail(error) {
|
|
53
|
+
process.stderr.write(`\r${prefix} ✗ ${truncatedName} ${error}\n`);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function clampPercent(percent) {
|
|
58
|
+
if (!Number.isFinite(percent))
|
|
59
|
+
return 0;
|
|
60
|
+
return Math.max(0, Math.min(100, percent));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Create a progress bar string.
|
|
64
|
+
*/
|
|
65
|
+
function createBar(percent, width = 20) {
|
|
66
|
+
const filled = Math.round((percent / 100) * width);
|
|
67
|
+
const empty = width - filled;
|
|
68
|
+
return '█'.repeat(filled) + '░'.repeat(empty);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Multi-file download progress tracker.
|
|
72
|
+
*/
|
|
73
|
+
export class DownloadProgressTracker {
|
|
74
|
+
completed = 0;
|
|
75
|
+
failed = 0;
|
|
76
|
+
skipped = 0;
|
|
77
|
+
total;
|
|
78
|
+
startTime;
|
|
79
|
+
verbose;
|
|
80
|
+
constructor(total, verbose = true) {
|
|
81
|
+
this.total = total;
|
|
82
|
+
this.startTime = Date.now();
|
|
83
|
+
this.verbose = verbose;
|
|
84
|
+
}
|
|
85
|
+
onFileStart(filename, index) {
|
|
86
|
+
if (!this.verbose)
|
|
87
|
+
return null;
|
|
88
|
+
return createProgressBar(filename, index, this.total);
|
|
89
|
+
}
|
|
90
|
+
onFileComplete(success, skipped = false) {
|
|
91
|
+
if (skipped) {
|
|
92
|
+
this.skipped++;
|
|
93
|
+
}
|
|
94
|
+
else if (success) {
|
|
95
|
+
this.completed++;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
this.failed++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
getSummary() {
|
|
102
|
+
const elapsed = formatDuration(Date.now() - this.startTime);
|
|
103
|
+
const parts = [];
|
|
104
|
+
if (this.completed > 0) {
|
|
105
|
+
parts.push(`${this.completed} downloaded`);
|
|
106
|
+
}
|
|
107
|
+
if (this.skipped > 0) {
|
|
108
|
+
parts.push(`${this.skipped} skipped`);
|
|
109
|
+
}
|
|
110
|
+
if (this.failed > 0) {
|
|
111
|
+
parts.push(`${this.failed} failed`);
|
|
112
|
+
}
|
|
113
|
+
return `${parts.join(', ')} in ${elapsed}`;
|
|
114
|
+
}
|
|
115
|
+
finish() {
|
|
116
|
+
if (this.verbose) {
|
|
117
|
+
process.stderr.write(`\nDownload complete: ${this.getSummary()}\n`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createProgressBar, formatBytes } from './progress.js';
|
|
3
|
+
describe('formatBytes', () => {
|
|
4
|
+
it('returns a stable zero-byte label for invalid or sub-byte values', () => {
|
|
5
|
+
expect(formatBytes(-1)).toBe('0 B');
|
|
6
|
+
expect(formatBytes(0.5)).toBe('0 B');
|
|
7
|
+
expect(formatBytes(Number.NaN)).toBe('0 B');
|
|
8
|
+
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe('0 B');
|
|
9
|
+
});
|
|
10
|
+
it('caps very large values at the largest supported unit', () => {
|
|
11
|
+
expect(formatBytes(1024 ** 5)).toBe('1024.0 TB');
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
describe('download progress display', () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
});
|
|
18
|
+
it('clamps percentages above 100 to keep the progress bar renderable', () => {
|
|
19
|
+
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
20
|
+
const progress = createProgressBar('file.bin', 0, 1);
|
|
21
|
+
expect(() => progress.update(150, 100)).not.toThrow();
|
|
22
|
+
expect(write).toHaveBeenCalledWith(expect.stringContaining('100%'));
|
|
23
|
+
});
|
|
24
|
+
it('clamps negative percentages to zero', () => {
|
|
25
|
+
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
26
|
+
const progress = createProgressBar('file.bin', 0, 1);
|
|
27
|
+
expect(() => progress.update(-10, 100)).not.toThrow();
|
|
28
|
+
expect(write).toHaveBeenCalledWith(expect.stringContaining('0%'));
|
|
29
|
+
});
|
|
30
|
+
it('renders zero percent when the total size is unknown', () => {
|
|
31
|
+
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
32
|
+
const progress = createProgressBar('file.bin', 0, 1);
|
|
33
|
+
expect(() => progress.update(50, 0)).not.toThrow();
|
|
34
|
+
expect(write).toHaveBeenCalledWith(expect.stringContaining('0%'));
|
|
35
|
+
});
|
|
36
|
+
});
|