From 4d4cf7bdc0418afb3a9af23281bbfb10fe9576cb Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 10:37:47 +0300 Subject: [PATCH 1/4] chore(deps): upgrade Effect to beta.107 --- AGENTS.md | 10 +++ CONTRIBUTING.md | 1 + package.json | 8 +- patches/@putdotio__sdk@10.0.1.patch | 11 +++ pnpm-lock.yaml | 108 ++++++++--------------- pnpm-workspace.yaml | 12 +-- src/bin.ts | 15 +--- src/internal/command.test.ts | 44 ++++++++++ src/internal/command.ts | 131 ++++++++++++++++++---------- src/internal/localizers/helpers.ts | 3 +- src/internal/main.test.ts | 15 ++++ src/internal/main.ts | 19 ++++ src/internal/output-service.ts | 4 +- src/internal/runtime.test.ts | 60 +++++++++++-- src/internal/runtime.ts | 44 +++++++--- src/sea.ts | 15 +--- 16 files changed, 330 insertions(+), 170 deletions(-) create mode 100644 patches/@putdotio__sdk@10.0.1.patch create mode 100644 src/internal/main.test.ts create mode 100644 src/internal/main.ts diff --git a/AGENTS.md b/AGENTS.md index eac14c8..a489123 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,16 @@ Run `vp install`, `vp config`, then `pnpm exec vp run verify`. - When the public CLI surface or agent-facing setup flow changes, update [`README.md`](README.md) and [`skills/putio-cli/SKILL.md`](skills/putio-cli/SKILL.md) together so the copy-paste prompt and consumer guidance stay aligned. - Keep docs free of volatile metrics. +## Learning more about the Effect + +This repository uses the Effect Typescript library. + +Before writing any Effect code, first read `node_modules/effect/AGENTS.md` +**completely**, and follow the links in the file when required. + +If you need to learn more about particular Effect apis and concepts that the +guide doesn't cover, search through the source code in `node_modules/effect/src`. + ## Testing - Prefer in-process tests unless the process boundary is the behavior under test. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d80b42e..2f4abfe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,6 +61,7 @@ See [Distribution](docs/DISTRIBUTION.md) for release automation, credentials, an - `verify` enforces the production Effect runtime boundary and dead-code checks, exercises the packed CLI through success and failure paths, and writes the smoke report to `.artifacts/smoke-packed-install.json`. - `vp config` installs the tracked pre-commit and pre-push hooks; pre-push runs the same `verify` gate as CI. - Prefer `vp install`, `vp test`, and `vp check` for day-to-day local loops. +- Keep the exact Effect versions, the Effect override, and the pnpm SDK compatibility patch aligned. The patch updates the SDK's schema-backed error constructor for the installed Effect runtime. - Keep top-level user docs in `README.md` and contributor workflow here. - Put deeper implementation detail in `docs/` instead of growing the top-level docs. - Keep `AGENTS.md` as repo-development guidance and `skills/*` as consumer-facing agent guidance. diff --git a/package.json b/package.json index e604a46..df02439 100644 --- a/package.json +++ b/package.json @@ -50,14 +50,14 @@ "verify": "node ./scripts/check-architecture.mts && knip && vp check . && vp run smoke:pack && vp test && vp test run --coverage" }, "dependencies": { - "@effect/platform-node": "4.0.0-beta.102", - "@putdotio/sdk": "^10.0.1", + "@effect/platform-node": "4.0.0-beta.107", + "@putdotio/sdk": "10.0.1", "cli-table3": "^0.6.5", - "effect": "4.0.0-beta.102", + "effect": "4.0.0-beta.107", "i18next": "^26.3.4" }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.102", + "@effect/vitest": "4.0.0-beta.107", "@types/node": "^26.1.0", "@vitest/coverage-v8": "catalog:", "esbuild": "^0.28.1", diff --git a/patches/@putdotio__sdk@10.0.1.patch b/patches/@putdotio__sdk@10.0.1.patch new file mode 100644 index 0000000..ec4ac59 --- /dev/null +++ b/patches/@putdotio__sdk@10.0.1.patch @@ -0,0 +1,11 @@ +diff --git a/dist/forms-ByF1AEEn.js b/dist/forms-ByF1AEEn.js +index a727d6b998eeb9126d25ab14c85dcab35c44408d..5a0716b2900693419ddd2ca1b7c2e6b64fbdb0dd 100644 +--- a/dist/forms-ByF1AEEn.js ++++ b/dist/forms-ByF1AEEn.js +@@ -45,3 +45,3 @@ +-var PutioApiError = class extends Schema.TaggedErrorClass()("PutioApiError", { ...PutioResponseErrorFields }) {}; +-var PutioAuthError = class extends Schema.TaggedErrorClass()("PutioAuthError", { ...PutioResponseErrorFields }) {}; +-var PutioRateLimitError = class extends Schema.TaggedErrorClass()("PutioRateLimitError", { ++var PutioApiError = class extends Schema.TaggedError()("PutioApiError", { ...PutioResponseErrorFields }) {}; ++var PutioAuthError = class extends Schema.TaggedError()("PutioAuthError", { ...PutioResponseErrorFields }) {}; ++var PutioRateLimitError = class extends Schema.TaggedError()("PutioRateLimitError", { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 103325c..9cc09dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,33 +11,36 @@ catalogs: version: 4.1.10 overrides: - effect: 4.0.0-beta.102 + effect: 4.0.0-beta.107 vite: npm:@voidzero-dev/vite-plus-core@0.2.6 vitest: 4.1.10 +patchedDependencies: + '@putdotio/sdk@10.0.1': debbe0ac2733ce35364e5706d8dc5da32973ba300e49c0364ebf8f24d0138c1b + importers: .: dependencies: '@effect/platform-node': - specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.10.1) + specifier: 4.0.0-beta.107 + version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.10.1) '@putdotio/sdk': - specifier: ^10.0.1 - version: 10.0.1 + specifier: 10.0.1 + version: 10.0.1(patch_hash=debbe0ac2733ce35364e5706d8dc5da32973ba300e49c0364ebf8f24d0138c1b) cli-table3: specifier: ^0.6.5 version: 0.6.5 effect: - specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102 + specifier: 4.0.0-beta.107 + version: 4.0.0-beta.107 i18next: specifier: ^26.3.4 version: 26.3.6(typescript@7.0.2) devDependencies: '@effect/vitest': - specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(effect@4.0.0-beta.102)(vitest@4.1.10) + specifier: 4.0.0-beta.107 + version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10) '@types/node': specifier: ^26.1.0 version: 26.1.2 @@ -104,23 +107,23 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} - '@effect/platform-node-shared@4.0.0-beta.102': - resolution: {integrity: sha512-gVd793I72MrkX4dXo7eYtRKfNj0RW4eMRfVEKEJI16h2+mBDCzQ+gqMrog2hSTHQnaIbvbYShNQ4TVGuRCYZeQ==} + '@effect/platform-node-shared@4.0.0-beta.107': + resolution: {integrity: sha512-y6BqcRi86BfTJv+tvDrob4ozYVHxxlHYcn/zIQqZjXI9CvKnkgD6ng+38G1o45c4f2ucU+6HRI9POCmFdMoVGA==} engines: {node: '>=18.0.0'} peerDependencies: - effect: 4.0.0-beta.102 + effect: 4.0.0-beta.107 - '@effect/platform-node@4.0.0-beta.102': - resolution: {integrity: sha512-wYVAU9jAePT+gouMr/EVz1CaW6yDLjPAgXYeEFLz7wugT5iZhFRag6mqajV/wwN3bzWP2fzZHokLuPmqD/rqeA==} + '@effect/platform-node@4.0.0-beta.107': + resolution: {integrity: sha512-k+6YNbV4Ck0L6YXtlgkvEnuP5tlxWD8EeWOrpn46PDqbGEwt4ONpRltTwm3tn2cyBXD0i+2P11cUH/6sdFagTA==} engines: {node: '>=18.0.0'} peerDependencies: - effect: 4.0.0-beta.102 - ioredis: ^5.7.0 + effect: 4.0.0-beta.107 + ioredis: '>=5.7.0 <6.0.0' - '@effect/vitest@4.0.0-beta.102': - resolution: {integrity: sha512-4dipFAYG6imOzrY3zy3BgzCJkbb9xESyzUef0WSx8bsK0/SpITqbJpdwKeOyDWLZ+rimjua8IbTFMAde865pIQ==} + '@effect/vitest@4.0.0-beta.107': + resolution: {integrity: sha512-n4/qsx4DnT4dEI/wNgMivxyUeJoeiU1TCSz0WnoHWk/dny40Oxjip2P9IXGQDgPb9fsYVnerF0QRA6nPUuExQA==} peerDependencies: - effect: 4.0.0-beta.102 + effect: 4.0.0-beta.107 vitest: 4.1.10 '@emnapi/core@1.11.2': @@ -1417,8 +1420,8 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - effect@4.0.0-beta.102: - resolution: {integrity: sha512-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w==} + effect@4.0.0-beta.107: + resolution: {integrity: sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1454,9 +1457,6 @@ packages: picomatch: optional: true - find-my-way-ts@0.1.6: - resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} - formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -1485,10 +1485,6 @@ packages: typescript: optional: true - ini@7.0.0: - resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - ioredis@5.10.1: resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} @@ -1640,9 +1636,6 @@ packages: msgpackr@2.0.4: resolution: {integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==} - multipasta@0.2.8: - resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} - nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1814,10 +1807,6 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} - toml@4.1.2: - resolution: {integrity: sha512-m0vXfHODcw3gk+KONAOlVQ5yNHc3yS3B1ybM3HS1vqDoS0RWTDDVBVVTYi8hH0k+2OM1vmo9fb1WX9EVqjqfHA==} - engines: {node: '>=20'} - totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -1908,18 +1897,6 @@ packages: engines: {node: '>=8'} hasBin: true - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.21.1: resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} @@ -1976,19 +1953,19 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@effect/platform-node-shared@4.0.0-beta.102(effect@4.0.0-beta.102)': + '@effect/platform-node-shared@4.0.0-beta.107(effect@4.0.0-beta.107)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.102 - ws: 8.21.0 + effect: 4.0.0-beta.107 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.102(effect@4.0.0-beta.102)(ioredis@5.10.1)': + '@effect/platform-node@4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.10.1)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.102(effect@4.0.0-beta.102) - effect: 4.0.0-beta.102 + '@effect/platform-node-shared': 4.0.0-beta.107(effect@4.0.0-beta.107) + effect: 4.0.0-beta.107 ioredis: 5.10.1 mime: 4.1.0 undici: 8.7.0 @@ -1996,9 +1973,9 @@ snapshots: - bufferutil - utf-8-validate - '@effect/vitest@4.0.0-beta.102(effect@4.0.0-beta.102)(vitest@4.1.10)': + '@effect/vitest@4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10)': dependencies: - effect: 4.0.0-beta.102 + effect: 4.0.0-beta.107 vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(typescript@7.0.2)(yaml@2.9.0)) '@emnapi/core@1.11.2': @@ -2398,9 +2375,9 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@putdotio/sdk@10.0.1': + '@putdotio/sdk@10.0.1(patch_hash=debbe0ac2733ce35364e5706d8dc5da32973ba300e49c0364ebf8f24d0138c1b)': dependencies: - effect: 4.0.0-beta.102 + effect: 4.0.0-beta.107 '@standard-schema/spec@1.1.0': {} @@ -2755,18 +2732,13 @@ snapshots: dom-accessibility-api@0.5.16: {} - effect@4.0.0-beta.102: + effect@4.0.0-beta.107: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 - find-my-way-ts: 0.1.6 - ini: 7.0.0 kubernetes-types: 1.30.0 msgpackr: 2.0.4 - multipasta: 0.2.8 - toml: 4.1.2 uuid: 14.0.1 - yaml: 2.9.0 emoji-regex@8.0.0: {} @@ -2819,8 +2791,6 @@ snapshots: optionalDependencies: picomatch: 4.0.5 - find-my-way-ts@0.1.6: {} - formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -2840,8 +2810,6 @@ snapshots: optionalDependencies: typescript: 7.0.2 - ini@7.0.0: {} - ioredis@5.10.1: dependencies: '@ioredis/commands': 1.5.1 @@ -2986,8 +2954,6 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.4 - multipasta@0.2.8: {} - nanoid@3.3.12: {} nanoid@3.3.16: {} @@ -3197,8 +3163,6 @@ snapshots: tinyrainbow@3.1.1: {} - toml@4.1.2: {} - totalist@3.0.1: {} tslib@2.8.1: @@ -3329,8 +3293,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - ws@8.21.0: {} - ws@8.21.1: {} yaml@2.9.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 418211a..f2e6503 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,5 @@ overrides: - effect: 4.0.0-beta.102 + effect: 4.0.0-beta.107 vite: "catalog:" vitest: "catalog:" @@ -9,10 +9,10 @@ allowBuilds: msgpackr-extract: true minimumReleaseAgeExclude: - - "@effect/platform-node-shared@4.0.0-beta.102" - - "@effect/platform-node@4.0.0-beta.102" - - "@effect/vitest@4.0.0-beta.102" - - effect@4.0.0-beta.102 + - "@effect/platform-node-shared@4.0.0-beta.107" + - "@effect/platform-node@4.0.0-beta.107" + - "@effect/vitest@4.0.0-beta.107" + - effect@4.0.0-beta.107 - knip@6.31.0 catalog: vite: npm:@voidzero-dev/vite-plus-core@0.2.6 @@ -25,3 +25,5 @@ peerDependencyRules: allowedVersions: vite: "*" vitest: "*" +patchedDependencies: + "@putdotio/sdk@10.0.1": patches/@putdotio__sdk@10.0.1.patch diff --git a/src/bin.ts b/src/bin.ts index b58d711..0222b16 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,26 +1,17 @@ #!/usr/bin/env node import { NodeRuntime } from "@effect/platform-node"; -import { Cause, Effect } from "effect"; +import { Effect } from "effect"; import { runCli } from "./cli.js"; import { makeCliAppLayer } from "./internal/app-layer.js"; -import { CliOutput, detectOutputModeFromArgv } from "./internal/output-service.js"; +import { handleCliCause } from "./internal/main.js"; import { CliRuntime } from "./internal/runtime.js"; NodeRuntime.runMain( Effect.scoped( Effect.flatMap(CliRuntime, (runtime) => runCli(runtime.argv)).pipe( - Effect.catchCause((cause) => - Effect.gen(function* () { - const cliOutput = yield* CliOutput; - const runtime = yield* CliRuntime; - const outputMode = detectOutputModeFromArgv(runtime.argv, runtime.isInteractiveTerminal); - - yield* cliOutput.error(cliOutput.formatError(Cause.squash(cause), outputMode)); - yield* runtime.setExitCode(1); - }), - ), + Effect.catchCause(handleCliCause), Effect.provide(makeCliAppLayer()), ), ), diff --git a/src/internal/command.test.ts b/src/internal/command.test.ts index e8bccbf..ab3ffa3 100644 --- a/src/internal/command.test.ts +++ b/src/internal/command.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { Effect, Option, Schema } from "effect"; import { + CliCommandInputError, collectAllCursorPages, decodeJsonOption, parseRepeatedIntegers, @@ -337,6 +338,49 @@ describe("collectAllCursorPages", () => { expect(String(exit.cause)).toContain("pagination returned a repeated cursor"); } }); + + it("surfaces pagination validation through the typed error channel", async () => { + const failure = await Effect.runPromise( + Effect.flip( + collectAllCursorPages({ + command: "files list", + continueWithCursor: () => + Effect.succeed({ + cursor: "cursor-1", + files: [{ id: 2 }], + }), + initial: { + cursor: "cursor-1", + files: [{ id: 1 }], + }, + itemKey: "files", + pageAll: true, + }), + ), + ); + + expect(failure).toBeInstanceOf(CliCommandInputError); + }); + + it("preserves continuation failures for their owning error boundary", async () => { + const upstreamError = new Error("upstream unavailable"); + const failure = await Effect.runPromise( + Effect.flip( + collectAllCursorPages({ + command: "files list", + continueWithCursor: () => Effect.fail(upstreamError), + initial: { + cursor: "cursor-1", + files: [{ id: 1 }], + }, + itemKey: "files", + pageAll: true, + }), + ), + ); + + expect(failure).toBe(upstreamError); + }); }); describe("agent-safe string validation", () => { diff --git a/src/internal/command.ts b/src/internal/command.ts index b65d97c..c719c9c 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -429,6 +429,12 @@ const mapInputError = (error: unknown, fallbackMessage: string) => message: fallbackMessage, }); +const tryCommandInput = (try_: () => A, fallbackMessage: string) => + Effect.try({ + try: try_, + catch: (error) => mapInputError(error, fallbackMessage), + }); + export const decodeJsonOption = (schema: Schema.Codec, raw: string) => Effect.try({ try: () => JSON.parse(raw) as unknown, @@ -543,39 +549,64 @@ export const collectAllCursorPages = , E, R>(i return input.initial; } - const collectedItems = [...readPageItems(input.initial, input.itemKey, input.command)]; + const collectedItems = [ + ...(yield* tryCommandInput( + () => readPageItems(input.initial, input.itemKey, input.command), + `Unable to validate the first page for \`${input.command}\`.`, + )), + ]; const seenCursors = new Set(); let cursor = readCursor(input.initial); let pageCount = 1; - assertCursorPageBudget({ - command: input.command, - itemCount: collectedItems.length, - pageCount, - }); + yield* tryCommandInput( + () => + assertCursorPageBudget({ + command: input.command, + itemCount: collectedItems.length, + pageCount, + }), + `Unable to validate pagination for \`${input.command}\`.`, + ); while (cursor !== null) { - assertCursorNotSeen({ - command: input.command, - cursor, - seenCursors, - }); + const currentCursor = cursor; + yield* tryCommandInput( + () => + assertCursorNotSeen({ + command: input.command, + cursor: currentCursor, + seenCursors, + }), + `Unable to validate pagination for \`${input.command}\`.`, + ); - const nextPage = yield* input.continueWithCursor(cursor); + const nextPage = yield* input.continueWithCursor(currentCursor); const nextCursor = readCursor(nextPage); - assertCursorNotRepeated({ - command: input.command, - cursor: nextCursor, - seenCursors, - }); - const pageItems = readPageItems(nextPage, input.itemKey, input.command); + yield* tryCommandInput( + () => + assertCursorNotRepeated({ + command: input.command, + cursor: nextCursor, + seenCursors, + }), + `Unable to validate pagination for \`${input.command}\`.`, + ); + const pageItems = yield* tryCommandInput( + () => readPageItems(nextPage, input.itemKey, input.command), + `Unable to validate a page for \`${input.command}\`.`, + ); pageCount += 1; collectedItems.push(...pageItems); - assertCursorPageBudget({ - command: input.command, - itemCount: collectedItems.length, - pageCount, - }); + yield* tryCommandInput( + () => + assertCursorPageBudget({ + command: input.command, + itemCount: collectedItems.length, + pageCount, + }), + `Unable to validate pagination for \`${input.command}\`.`, + ); cursor = nextCursor; } @@ -584,11 +615,7 @@ export const collectAllCursorPages = , E, R>(i [input.itemKey]: collectedItems, ...(Object.prototype.hasOwnProperty.call(input.initial, "cursor") ? { cursor: null } : {}), } as A; - }).pipe( - Effect.mapError((error) => - mapInputError(error, `Unable to collect all pages for \`${input.command}\`.`), - ), - ); + }); export const writeReadOutput = >(input: { readonly command: string; @@ -650,12 +677,20 @@ export const writeReadPages = , E, R>(input: { while (true) { if (input.itemKey) { - streamedItemCount += readPageItems(current, input.itemKey, input.command).length; - assertCursorPageBudget({ - command: input.command, - itemCount: streamedItemCount, - pageCount, - }); + const itemKey = input.itemKey; + streamedItemCount += (yield* tryCommandInput( + () => readPageItems(current, itemKey, input.command), + `Unable to validate a page for \`${input.command}\`.`, + )).length; + yield* tryCommandInput( + () => + assertCursorPageBudget({ + command: input.command, + itemCount: streamedItemCount, + pageCount, + }), + `Unable to validate pagination for \`${input.command}\`.`, + ); } const selectedValue = yield* selectTopLevelFields({ @@ -676,18 +711,26 @@ export const writeReadPages = , E, R>(input: { return; } - assertCursorNotSeen({ - command: input.command, - cursor, - seenCursors, - }); + yield* tryCommandInput( + () => + assertCursorNotSeen({ + command: input.command, + cursor, + seenCursors, + }), + `Unable to validate pagination for \`${input.command}\`.`, + ); const nextPage = yield* input.continueWithCursor(cursor); - assertCursorNotRepeated({ - command: input.command, - cursor: readCursor(nextPage), - seenCursors, - }); + yield* tryCommandInput( + () => + assertCursorNotRepeated({ + command: input.command, + cursor: readCursor(nextPage), + seenCursors, + }), + `Unable to validate pagination for \`${input.command}\`.`, + ); current = nextPage; pageCount += 1; } diff --git a/src/internal/localizers/helpers.ts b/src/internal/localizers/helpers.ts index 2d3d1e7..0e2dc36 100644 --- a/src/internal/localizers/helpers.ts +++ b/src/internal/localizers/helpers.ts @@ -1,7 +1,8 @@ import type { MatchConditionLocalizer } from "@putdotio/sdk/utilities"; +import { Predicate } from "effect"; export const isPlainRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; + Predicate.isObjectOrArray(value); export const parseRetryAfterSeconds = (value: unknown): number | undefined => { if (typeof value === "number" && Number.isFinite(value)) { diff --git a/src/internal/main.test.ts b/src/internal/main.test.ts new file mode 100644 index 0000000..5ed6ebf --- /dev/null +++ b/src/internal/main.test.ts @@ -0,0 +1,15 @@ +import { Cause, Effect } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { handleCliCause } from "./main.js"; + +describe("handleCliCause", () => { + it("preserves interrupt-only causes for NodeRuntime signal handling", async () => { + const exit = await Effect.runPromiseExit(handleCliCause(Cause.interrupt())); + + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); + } + }); +}); diff --git a/src/internal/main.ts b/src/internal/main.ts new file mode 100644 index 0000000..13dac6d --- /dev/null +++ b/src/internal/main.ts @@ -0,0 +1,19 @@ +import { Cause, Effect } from "effect"; + +import { CliOutput, detectOutputModeFromArgv } from "./output-service.js"; +import { CliRuntime } from "./runtime.js"; + +export const handleCliCause = (cause: Cause.Cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + + return Effect.gen(function* () { + const cliOutput = yield* CliOutput; + const runtime = yield* CliRuntime; + const outputMode = detectOutputModeFromArgv(runtime.argv, runtime.isInteractiveTerminal); + + yield* cliOutput.error(cliOutput.formatError(Cause.squash(cause), outputMode)); + yield* runtime.setExitCode(1); + }); +}; diff --git a/src/internal/output-service.ts b/src/internal/output-service.ts index f086285..f8dfc7e 100644 --- a/src/internal/output-service.ts +++ b/src/internal/output-service.ts @@ -1,5 +1,5 @@ import { LocalizedError } from "@putdotio/sdk/utilities"; -import { Console, Context, Effect, Layer } from "effect"; +import { Console, Context, Effect, Layer, Predicate } from "effect"; import { isLocalizedError, localizeCliError } from "./localize-error.js"; import { CliRuntime } from "./runtime.js"; @@ -81,7 +81,7 @@ const TERMINAL_CONTROL_PATTERN = new RegExp( ); const isPlainObject = (value: unknown): value is Record => { - if (typeof value !== "object" || value === null || Array.isArray(value)) { + if (!Predicate.isObject(value)) { return false; } diff --git a/src/internal/runtime.test.ts b/src/internal/runtime.test.ts index a402dfc..7af19cf 100644 --- a/src/internal/runtime.test.ts +++ b/src/internal/runtime.test.ts @@ -1,4 +1,5 @@ -import { Effect } from "effect"; +import { Effect, Fiber } from "effect"; +import { EventEmitter } from "node:events"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const { spawnMock } = vi.hoisted(() => ({ @@ -27,7 +28,16 @@ describe("makeCliRuntime", () => { }); it("opens URLs with the right command for each platform", async () => { - spawnMock.mockReturnValue({ unref: vi.fn() }); + spawnMock.mockImplementation(() => { + const child = new EventEmitter() as EventEmitter & { + pid: number; + unref: ReturnType; + }; + child.pid = 123; + child.unref = vi.fn(); + queueMicrotask(() => child.emit("spawn")); + return child; + }); const darwin = makeCliRuntime({ platform: "darwin" }); const linux = makeCliRuntime({ platform: "linux" }); @@ -41,19 +51,19 @@ describe("makeCliRuntime", () => { 1, "open", ["https://app.put.io"], - expect.objectContaining({ detached: true, stdio: "ignore" }), + expect.objectContaining({ detached: true, signal: expect.any(AbortSignal), stdio: "ignore" }), ); expect(spawnMock).toHaveBeenNthCalledWith( 2, "xdg-open", ["https://app.put.io"], - expect.objectContaining({ detached: true, stdio: "ignore" }), + expect.objectContaining({ detached: true, signal: expect.any(AbortSignal), stdio: "ignore" }), ); expect(spawnMock).toHaveBeenNthCalledWith( 3, "cmd", ["/c", "start", "", "https://app.put.io"], - expect.objectContaining({ detached: true, stdio: "ignore" }), + expect.objectContaining({ detached: true, signal: expect.any(AbortSignal), stdio: "ignore" }), ); }); @@ -69,6 +79,46 @@ describe("makeCliRuntime", () => { ); }); + it("returns false when the opener emits an asynchronous spawn error", async () => { + spawnMock.mockImplementation(() => { + const child = new EventEmitter() as EventEmitter & { + pid: undefined; + unref: ReturnType; + }; + child.pid = undefined; + child.unref = vi.fn(); + queueMicrotask(() => child.emit("error", new Error("missing opener"))); + return child; + }); + + const runtime = makeCliRuntime({ platform: "linux" }); + + await expect(Effect.runPromise(runtime.openExternal("https://app.put.io"))).resolves.toBe( + false, + ); + }); + + it("aborts opener startup and removes listeners when interrupted", async () => { + const child = new EventEmitter() as EventEmitter & { + unref: ReturnType; + }; + child.unref = vi.fn(); + let spawnSignal: AbortSignal | undefined; + spawnMock.mockImplementation((_file, _args, options: { signal: AbortSignal }) => { + spawnSignal = options.signal; + return child; + }); + + const runtime = makeCliRuntime({ platform: "linux" }); + const fiber = Effect.runFork(runtime.openExternal("https://app.put.io")); + + await Effect.runPromise(Fiber.interrupt(fiber)); + + expect(spawnSignal?.aborted).toBe(true); + expect(child.listenerCount("error")).toBe(0); + expect(child.listenerCount("spawn")).toBe(0); + }); + it("starts and stops a spinner and clears the terminal line", async () => { spawnMock.mockReturnValue({ unref: vi.fn() }); const writeSpy = vi.spyOn(process.stdout, "write").mockReturnValue(true); diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts index 9bdce83..1053eaf 100644 --- a/src/internal/runtime.ts +++ b/src/internal/runtime.ts @@ -34,18 +34,38 @@ const openExternalWithPlatform = (platform: NodeJS.Platform, url: string) => { ? { file: "cmd", args: ["/c", "start", "", url] } : { file: "xdg-open", args: [url] }; - try { - const child = spawn(command.file, command.args, { - detached: true, - stdio: "ignore", + return Effect.callback((resume, signal) => { + let child: ReturnType; + + try { + child = spawn(command.file, command.args, { + detached: true, + signal, + stdio: "ignore", + }); + } catch { + resume(Effect.succeed(false)); + return; + } + + const onError = () => { + child.removeListener("spawn", onSpawn); + resume(Effect.succeed(false)); + }; + const onSpawn = () => { + child.removeListener("error", onError); + child.unref(); + resume(Effect.succeed(true)); + }; + + child.once("error", onError); + child.once("spawn", onSpawn); + + return Effect.sync(() => { + child.removeListener("error", onError); + child.removeListener("spawn", onSpawn); }); - - child.unref(); - - return true; - } catch { - return false; - } + }); }; export const makeCliRuntime = ( @@ -92,7 +112,7 @@ export const makeCliRuntime = ( process.stderr.write(message); }), - openExternal: (url) => Effect.sync(() => openExternalWithPlatform(platform, url)), + openExternal: (url) => openExternalWithPlatform(platform, url), startSpinner: (message) => Effect.sync(() => { let frameIndex = 0; diff --git a/src/sea.ts b/src/sea.ts index ee797b2..937d847 100644 --- a/src/sea.ts +++ b/src/sea.ts @@ -1,24 +1,15 @@ import { NodeRuntime } from "@effect/platform-node"; -import { Cause, Effect } from "effect"; +import { Effect } from "effect"; import { runCli } from "./cli.js"; import { makeCliAppLayer } from "./internal/app-layer.js"; -import { CliOutput, detectOutputModeFromArgv } from "./internal/output-service.js"; +import { handleCliCause } from "./internal/main.js"; import { CliRuntime } from "./internal/runtime.js"; NodeRuntime.runMain( Effect.scoped( Effect.flatMap(CliRuntime, (runtime) => runCli(runtime.argv)).pipe( - Effect.catchCause((cause) => - Effect.gen(function* () { - const cliOutput = yield* CliOutput; - const runtime = yield* CliRuntime; - const outputMode = detectOutputModeFromArgv(runtime.argv, runtime.isInteractiveTerminal); - - yield* cliOutput.error(cliOutput.formatError(Cause.squash(cause), outputMode)); - yield* runtime.setExitCode(1); - }), - ), + Effect.catchCause(handleCliCause), Effect.provide(makeCliAppLayer()), ), ), From 09315d1fde2064663183ff1ac00722aa1b8d8922 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 10:42:56 +0300 Subject: [PATCH 2/4] chore: remove Effect source vendoring --- .gitignore | 1 - .worktreeinclude | 1 - AGENTS.md | 5 +- CONTRIBUTING.md | 12 ++--- package.json | 1 - scripts/prepare-effect.mts | 105 ------------------------------------- scripts/prepare-effect.sh | 5 -- 7 files changed, 9 insertions(+), 121 deletions(-) delete mode 100644 .worktreeinclude delete mode 100644 scripts/prepare-effect.mts delete mode 100755 scripts/prepare-effect.sh diff --git a/.gitignore b/.gitignore index b708aad..49a0bd8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ node_modules dist coverage .artifacts -/.repos .claude/worktrees/ *.log .DS_Store diff --git a/.worktreeinclude b/.worktreeinclude deleted file mode 100644 index c8bc571..0000000 --- a/.worktreeinclude +++ /dev/null @@ -1 +0,0 @@ -/.repos/effect/ diff --git a/AGENTS.md b/AGENTS.md index a489123..4c71bc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,8 +36,9 @@ Runtime proofs: ## Worktrees -`.worktreeinclude` reuses `.repos`; Claude symlinks it to avoid another copy. -Run `vp install`, `vp config`, then `pnpm exec vp run verify`. +Run `pnpm exec vp install`, `pnpm exec vp config`, then +`pnpm exec vp run verify`. Effect guidance and inspectable source come from the +installed package under `node_modules/effect`. ## Development Guidance diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f4abfe..ed0c874 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,13 +7,13 @@ Thanks for contributing to `putio-cli`. Use the Node version required by [`package.json`](./package.json), then install dependencies: ```bash -vp install +pnpm exec vp install ``` Then install the stock VitePlus hook wiring for this clone: ```bash -vp config +pnpm exec vp config ``` ## Run Locally @@ -21,13 +21,13 @@ vp config Start the local build watcher: ```bash -vp run dev +pnpm exec vp run dev ``` Build the CLI once and try the shipped entrypoint: ```bash -vp run build +pnpm exec vp run build ./dist/bin.mjs describe ``` @@ -59,8 +59,8 @@ See [Distribution](docs/DISTRIBUTION.md) for release automation, credentials, an - `verify` is the repository delivery gate. - `verify` enforces the production Effect runtime boundary and dead-code checks, exercises the packed CLI through success and failure paths, and writes the smoke report to `.artifacts/smoke-packed-install.json`. -- `vp config` installs the tracked pre-commit and pre-push hooks; pre-push runs the same `verify` gate as CI. -- Prefer `vp install`, `vp test`, and `vp check` for day-to-day local loops. +- `pnpm exec vp config` installs the tracked pre-commit and pre-push hooks; pre-push runs the same `verify` gate as CI. +- Prefer `pnpm exec vp install`, `pnpm exec vp test`, and `pnpm exec vp check` for day-to-day local loops. - Keep the exact Effect versions, the Effect override, and the pnpm SDK compatibility patch aligned. The patch updates the SDK's schema-backed error constructor for the installed Effect runtime. - Keep top-level user docs in `README.md` and contributor workflow here. - Put deeper implementation detail in `docs/` instead of growing the top-level docs. diff --git a/package.json b/package.json index df02439..0807299 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "check:dead-code": "knip", "coverage": "vp test run --coverage", "dev": "vp pack --watch", - "prepare": "node ./scripts/prepare-effect.mts", "prepack": "vp pack", "smoke:pack": "node ./scripts/smoke-packed-install.mts", "test": "vp test", diff --git a/scripts/prepare-effect.mts b/scripts/prepare-effect.mts deleted file mode 100644 index 76b40a4..0000000 --- a/scripts/prepare-effect.mts +++ /dev/null @@ -1,105 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync } from "node:fs"; - -if (process.env.CI || process.env.GITHUB_ACTIONS) { - process.exit(0); -} - -const repoDir = ".repos/effect"; -const repoUrl = "https://github.com/Effect-TS/effect.git"; - -const packageJson: unknown = JSON.parse(readFileSync("package.json", "utf8")); - -if ( - typeof packageJson !== "object" || - packageJson === null || - !("dependencies" in packageJson) || - typeof packageJson.dependencies !== "object" || - packageJson.dependencies === null || - !("effect" in packageJson.dependencies) || - typeof packageJson.dependencies.effect !== "string" -) { - throw new Error("Expected package.json dependencies.effect to own the Effect version."); -} - -const effectVersion = packageJson.dependencies.effect; - -if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(effectVersion)) { - throw new Error(`Expected an exact Effect version, received ${effectVersion}.`); -} - -const effectRef = `effect@${effectVersion}`; - -const git = (args: ReadonlyArray) => - execFileSync("git", ["-C", repoDir, ...args], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); - -const normalizeRepoUrl = (url: string) => - url - .replace(/^git@github\.com:/, "https://github.com/") - .replace(/\.git$/, "") - .replace(/\/$/, ""); - -if (!existsSync(`${repoDir}/.git`)) { - mkdirSync(".repos", { recursive: true }); - execFileSync( - "git", - [ - "clone", - "--branch", - effectRef, - "--depth", - "1", - "--filter=blob:none", - "--single-branch", - repoUrl, - repoDir, - ], - { stdio: "inherit" }, - ); - process.exit(0); -} - -const actualRepoUrl = git(["remote", "get-url", "origin"]); - -if (normalizeRepoUrl(actualRepoUrl) !== normalizeRepoUrl(repoUrl)) { - throw new Error( - `Expected ${repoDir} origin to be ${repoUrl}, received ${actualRepoUrl}. ` + - `Repair it with: git -C ${repoDir} remote set-url origin ${repoUrl}`, - ); -} - -if (git(["status", "--porcelain"]).length > 0) { - throw new Error( - `Refusing to align ${repoDir} while it has local changes. Commit or stash them first.`, - ); -} - -let resolvedRef: string; - -try { - resolvedRef = git(["rev-list", "-n", "1", effectRef]); -} catch { - execFileSync( - "git", - [ - "-C", - repoDir, - "fetch", - "--depth", - "1", - "origin", - `refs/tags/${effectRef}:refs/tags/${effectRef}`, - ], - { stdio: "inherit" }, - ); - resolvedRef = git(["rev-list", "-n", "1", effectRef]); -} - -if (git(["rev-parse", "HEAD"]) !== resolvedRef) { - execFileSync("git", ["-C", repoDir, "checkout", "--detach", effectRef], { - stdio: "inherit", - }); -} diff --git a/scripts/prepare-effect.sh b/scripts/prepare-effect.sh deleted file mode 100755 index da0e4af..0000000 --- a/scripts/prepare-effect.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env sh - -set -eu - -exec node ./scripts/prepare-effect.mts From bc7c6e7e8217cfa48be8a5f853ddfa6b47db6aac Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 10:56:26 +0300 Subject: [PATCH 3/4] fix(pack): bundle compatible SDK runtime --- .claude/settings.json | 5 -- CONTRIBUTING.md | 2 +- docs/DISTRIBUTION.md | 6 +- package.json | 7 +- pnpm-lock.yaml | 6 +- scripts/smoke-packed-install.mts | 127 ++++++++++++++++++++++++++++++- vite.config.ts | 6 +- 7 files changed, 145 insertions(+), 14 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 738c54d..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "worktree": { - "symlinkDirectories": [".repos"] - } -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed0c874..521885e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ See [Distribution](docs/DISTRIBUTION.md) for release automation, credentials, an - `verify` enforces the production Effect runtime boundary and dead-code checks, exercises the packed CLI through success and failure paths, and writes the smoke report to `.artifacts/smoke-packed-install.json`. - `pnpm exec vp config` installs the tracked pre-commit and pre-push hooks; pre-push runs the same `verify` gate as CI. - Prefer `pnpm exec vp install`, `pnpm exec vp test`, and `pnpm exec vp check` for day-to-day local loops. -- Keep the exact Effect versions, the Effect override, and the pnpm SDK compatibility patch aligned. The patch updates the SDK's schema-backed error constructor for the installed Effect runtime. +- Keep the exact Effect versions, the Effect override, and the pnpm SDK compatibility patch aligned. The build bundles the patched SDK so installed CLIs and the SDK share one Effect runtime. - Keep top-level user docs in `README.md` and contributor workflow here. - Put deeper implementation detail in `docs/` instead of growing the top-level docs. - Keep `AGENTS.md` as repo-development guidance and `skills/*` as consumer-facing agent guidance. diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 846858a..85b7c82 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -43,12 +43,16 @@ The npm package includes `dist`, `README.md`, `docs`, `skills`, `AGENTS.md`, library is part of the public package contract so consuming repos and agents can install the same guidance that maintainers use from git. +The build bundles the pinned, compatibility-patched put.io SDK into `dist`. +Effect remains a package dependency so the CLI and its bundled SDK execute on +the same installed Effect runtime. + ## Local Checks Before changing distribution wiring, validate the repo-local guardrails the workflow depends on: ```bash -vp install +pnpm exec vp install pnpm exec vp run verify pnpm exec vp run smoke:pack ``` diff --git a/package.json b/package.json index 0807299..22126b5 100644 --- a/package.json +++ b/package.json @@ -50,13 +50,13 @@ }, "dependencies": { "@effect/platform-node": "4.0.0-beta.107", - "@putdotio/sdk": "10.0.1", "cli-table3": "^0.6.5", "effect": "4.0.0-beta.107", "i18next": "^26.3.4" }, "devDependencies": { "@effect/vitest": "4.0.0-beta.107", + "@putdotio/sdk": "10.0.1", "@types/node": "^26.1.0", "@vitest/coverage-v8": "catalog:", "esbuild": "^0.28.1", @@ -70,5 +70,8 @@ "engines": { "node": ">=24.18.0" }, - "packageManager": "pnpm@11.2.2" + "packageManager": "pnpm@11.2.2", + "inlinedDependencies": { + "@putdotio/sdk": "10.0.1" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cc09dd..d062e8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,9 +25,6 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.107 version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.10.1) - '@putdotio/sdk': - specifier: 10.0.1 - version: 10.0.1(patch_hash=debbe0ac2733ce35364e5706d8dc5da32973ba300e49c0364ebf8f24d0138c1b) cli-table3: specifier: ^0.6.5 version: 0.6.5 @@ -41,6 +38,9 @@ importers: '@effect/vitest': specifier: 4.0.0-beta.107 version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10) + '@putdotio/sdk': + specifier: 10.0.1 + version: 10.0.1(patch_hash=debbe0ac2733ce35364e5706d8dc5da32973ba300e49c0364ebf8f24d0138c1b) '@types/node': specifier: ^26.1.0 version: 26.1.2 diff --git a/scripts/smoke-packed-install.mts b/scripts/smoke-packed-install.mts index c8eb58b..471b9ec 100644 --- a/scripts/smoke-packed-install.mts +++ b/scripts/smoke-packed-install.mts @@ -1,4 +1,4 @@ -import { execFileSync, spawnSync } from "node:child_process"; +import { execFileSync, spawn, spawnSync, type ChildProcess } from "node:child_process"; import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -28,12 +28,52 @@ type RemoveResult = { readonly removed: boolean; }; +type TransfersList = { + readonly cursor: string | null; + readonly total: number; + readonly transfers: ReadonlyArray; +}; + +type NpmDependencyTree = { + readonly dependencies?: Record; + readonly version?: string; +}; + const root = process.cwd(); const artifactsDir = join(root, ".artifacts"); const installDir = mkdtempSync(join(tmpdir(), "putio-cli-install-")); const configPath = join(installDir, "putio-config.json"); const commandTimeoutMs = 120_000; +const mockApiSource = ` +import { createServer } from "node:http"; + +const server = createServer((request, response) => { + const isExpectedRequest = + request.method === "GET" && + request.url?.startsWith("/v2/transfers/list?") === true && + request.headers.authorization === "Token packed-smoke-token"; + + response.statusCode = isExpectedRequest ? 200 : 400; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify( + isExpectedRequest + ? { cursor: null, status: "OK", total: 0, transfers: [] } + : { error_message: "Unexpected packed-install request", error_type: "BAD_REQUEST" }, + ), + ); +}); + +server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (typeof address !== "object" || address === null) process.exit(1); + process.stdout.write(String(address.port) + "\\n"); +}); + +process.on("SIGTERM", () => server.close(() => process.exit(0))); +`; + const run = (command: string, args: ReadonlyArray, options: object = {}) => execFileSync(command, args, { cwd: root, @@ -68,6 +108,56 @@ const assert = (condition: boolean, message: string) => { } }; +const startMockApi = () => + new Promise<{ readonly baseUrl: string; readonly child: ChildProcess }>((resolve, reject) => { + const child = spawn(process.execPath, ["--input-type=module", "--eval", mockApiSource], { + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + let stdout = ""; + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`Timed out starting the packed-install API server. ${stderr}`.trim())); + }, 10_000); + + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("exit", (code) => { + clearTimeout(timer); + reject(new Error(`Packed-install API server exited with code ${code}. ${stderr}`.trim())); + }); + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + const newline = stdout.indexOf("\n"); + + if (newline < 0) return; + + const port = Number(stdout.slice(0, newline)); + clearTimeout(timer); + assert(Number.isInteger(port) && port > 0, "Expected the API server to report a port."); + resolve({ baseUrl: `http://127.0.0.1:${port}`, child }); + }); + }); + +const collectEffectVersions = (tree: NpmDependencyTree) => { + const versions = new Set(); + + const visit = (node: NpmDependencyTree) => { + for (const [name, dependency] of Object.entries(node.dependencies ?? {})) { + if (name === "effect" && dependency.version) versions.add(dependency.version); + visit(dependency); + } + }; + + visit(tree); + return versions; +}; + const readFailureMessage = (value: unknown) => { if ( typeof value !== "object" || @@ -254,6 +344,8 @@ const smokeAuthProfiles = (binaryPath: string) => { ); }; +let mockApiProcess: ChildProcess | undefined; + try { rmSync(artifactsDir, { force: true, recursive: true }); run("pnpm", ["pack", "--pack-destination", artifactsDir]); @@ -299,6 +391,36 @@ try { JSON.parse(describeOutput); smokeAuthProfiles(binaryPath); + const dependencyTree = JSON.parse( + execFileSync("npm", ["ls", "effect", "--all", "--json"], { + cwd: installDir, + encoding: "utf8", + stdio: "pipe", + timeout: commandTimeoutMs, + }), + ) as NpmDependencyTree; + const effectVersions = collectEffectVersions(dependencyTree); + assert( + effectVersions.size === 1 && effectVersions.has("4.0.0-beta.107"), + `Expected the package to install one Effect 4.0.0-beta.107 runtime, received ${[ + ...effectVersions, + ].join(", ")}.`, + ); + + const mockApi = await startMockApi(); + mockApiProcess = mockApi.child; + const transfers = runPutioJson( + binaryPath, + ["transfers", "list", "--output", "json"], + { + PUTIO_CLI_API_BASE_URL: mockApi.baseUrl, + PUTIO_CLI_TOKEN: "packed-smoke-token", + }, + ); + assert(transfers.transfers.length === 0, "Expected the SDK-backed transfer list to be empty."); + assert(transfers.cursor === null, "Expected the SDK-backed transfer list cursor to be null."); + assert(transfers.total === 0, "Expected the SDK-backed transfer list total to be zero."); + const missingAuthMessage = runPutioFailure( binaryPath, ["whoami", "--fields", "auth", "--output", "json"], @@ -328,6 +450,8 @@ try { "packaged-install", "version", "describe", + "single-effect-runtime", + "authenticated-sdk-request", "auth-profile-round-trip", "missing-auth-failure", "invalid-config-failure", @@ -340,5 +464,6 @@ try { )}\n`, ); } finally { + mockApiProcess?.kill(); rmSync(installDir, { force: true, recursive: true }); } diff --git a/vite.config.ts b/vite.config.ts index 619a929..0922d30 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,6 +24,10 @@ const coverageConfig: CoverageConfig = { export default defineConfig({ pack: { clean: true, + deps: { + alwaysBundle: ["@putdotio/sdk"], + onlyBundle: ["@putdotio/sdk"], + }, dts: false, entry: { index: "src/index.ts", @@ -39,7 +43,7 @@ export default defineConfig({ "*.{js,ts,tsx,mjs,cjs,mts,cts}": "vp check --fix", }, test: { - exclude: ["node_modules/**", ".repos/**", "scripts/**/*.test.ts"], + exclude: ["node_modules/**", "scripts/**/*.test.ts"], coverage: { ...coverageConfig, exclude: [ From d94f792e51bcd7e45fa5d7d42aeb989d6787bad4 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 11:24:43 +0300 Subject: [PATCH 4/4] fix: address automated review findings --- AGENTS.md | 4 +-- scripts/smoke-packed-install.mts | 41 +++++++++++------------------ src/internal/localize-error.test.ts | 8 ++++++ src/internal/localizers/helpers.ts | 2 +- src/internal/main.test.ts | 37 +++++++++++++++++++++++++- src/internal/runtime.test.ts | 14 ++++++++-- src/internal/runtime.ts | 8 +++++- 7 files changed, 82 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4c71bc5..ffe54ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,12 +52,12 @@ installed package under `node_modules/effect`. ## Learning more about the Effect -This repository uses the Effect Typescript library. +This repository uses the Effect TypeScript library. Before writing any Effect code, first read `node_modules/effect/AGENTS.md` **completely**, and follow the links in the file when required. -If you need to learn more about particular Effect apis and concepts that the +If you need to learn more about particular Effect APIs and concepts that the guide doesn't cover, search through the source code in `node_modules/effect/src`. ## Testing diff --git a/scripts/smoke-packed-install.mts b/scripts/smoke-packed-install.mts index 471b9ec..24c18bc 100644 --- a/scripts/smoke-packed-install.mts +++ b/scripts/smoke-packed-install.mts @@ -34,8 +34,7 @@ type TransfersList = { readonly transfers: ReadonlyArray; }; -type NpmDependencyTree = { - readonly dependencies?: Record; +type NpmPackageInventoryEntry = { readonly version?: string; }; @@ -139,25 +138,17 @@ const startMockApi = () => const port = Number(stdout.slice(0, newline)); clearTimeout(timer); - assert(Number.isInteger(port) && port > 0, "Expected the API server to report a port."); + + if (!Number.isInteger(port) || port <= 0) { + child.kill(); + reject(new Error(`Expected the API server to report a valid port. ${stderr}`.trim())); + return; + } + resolve({ baseUrl: `http://127.0.0.1:${port}`, child }); }); }); -const collectEffectVersions = (tree: NpmDependencyTree) => { - const versions = new Set(); - - const visit = (node: NpmDependencyTree) => { - for (const [name, dependency] of Object.entries(node.dependencies ?? {})) { - if (name === "effect" && dependency.version) versions.add(dependency.version); - visit(dependency); - } - }; - - visit(tree); - return versions; -}; - const readFailureMessage = (value: unknown) => { if ( typeof value !== "object" || @@ -391,20 +382,20 @@ try { JSON.parse(describeOutput); smokeAuthProfiles(binaryPath); - const dependencyTree = JSON.parse( - execFileSync("npm", ["ls", "effect", "--all", "--json"], { + const effectInventory = JSON.parse( + execFileSync("npm", ["query", '[name="effect"]', "--json"], { cwd: installDir, encoding: "utf8", stdio: "pipe", timeout: commandTimeoutMs, }), - ) as NpmDependencyTree; - const effectVersions = collectEffectVersions(dependencyTree); + ) as ReadonlyArray; + const effectVersions = effectInventory.flatMap((entry) => + entry.version === undefined ? [] : [entry.version], + ); assert( - effectVersions.size === 1 && effectVersions.has("4.0.0-beta.107"), - `Expected the package to install one Effect 4.0.0-beta.107 runtime, received ${[ - ...effectVersions, - ].join(", ")}.`, + effectVersions.length === 1 && effectVersions[0] === "4.0.0-beta.107", + `Expected the package to install one Effect 4.0.0-beta.107 runtime, received ${effectVersions.join(", ")}.`, ); const mockApi = await startMockApi(); diff --git a/src/internal/localize-error.test.ts b/src/internal/localize-error.test.ts index c7a1c8c..0e26cb5 100644 --- a/src/internal/localize-error.test.ts +++ b/src/internal/localize-error.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { isLocalizedError, localizeCliError } from "./localize-error.js"; +import { isPlainRecord } from "./localizers/helpers.js"; const operationError = ( domain: string, @@ -39,6 +40,13 @@ const operationError = ( status, }); +describe("isPlainRecord", () => { + it("accepts records and rejects arrays", () => { + expect(isPlainRecord({ error: "boom" })).toBe(true); + expect(isPlainRecord(["boom"])).toBe(false); + }); +}); + describe("localizeCliError", () => { it("localizes rate limit errors with retry details", () => { const localized = localizeCliError({ diff --git a/src/internal/localizers/helpers.ts b/src/internal/localizers/helpers.ts index 0e2dc36..0ff08cb 100644 --- a/src/internal/localizers/helpers.ts +++ b/src/internal/localizers/helpers.ts @@ -2,7 +2,7 @@ import type { MatchConditionLocalizer } from "@putdotio/sdk/utilities"; import { Predicate } from "effect"; export const isPlainRecord = (value: unknown): value is Record => - Predicate.isObjectOrArray(value); + Predicate.isObject(value); export const parseRetryAfterSeconds = (value: unknown): number | undefined => { if (typeof value === "number" && Number.isFinite(value)) { diff --git a/src/internal/main.test.ts b/src/internal/main.test.ts index 5ed6ebf..d450b58 100644 --- a/src/internal/main.test.ts +++ b/src/internal/main.test.ts @@ -1,7 +1,9 @@ import { Cause, Effect } from "effect"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { handleCliCause } from "./main.js"; +import { CliOutput, type CliOutputService } from "./output-service.js"; +import { CliRuntime, makeCliRuntime } from "./runtime.js"; describe("handleCliCause", () => { it("preserves interrupt-only causes for NodeRuntime signal handling", async () => { @@ -12,4 +14,37 @@ describe("handleCliCause", () => { expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); } }); + + it("renders ordinary failures and sets the process exit code", async () => { + const failure = new Error("boom"); + let exitCode: number | undefined; + const formatError = vi.fn(() => "formatted failure"); + const writeError = vi.fn(() => Effect.void); + const cliOutput: CliOutputService = { + error: writeError, + formatError, + write: () => Effect.void, + }; + const runtime = { + ...makeCliRuntime({ + argv: ["node", "putio", "whoami", "--output", "json"], + isInteractiveTerminal: false, + }), + setExitCode: (code: number) => + Effect.sync(() => { + exitCode = code; + }), + }; + + await Effect.runPromise( + handleCliCause(Cause.fail(failure)).pipe( + Effect.provideService(CliOutput, cliOutput), + Effect.provideService(CliRuntime, runtime), + ), + ); + + expect(formatError).toHaveBeenCalledWith(failure, "json"); + expect(writeError).toHaveBeenCalledWith("formatted failure"); + expect(exitCode).toBe(1); + }); }); diff --git a/src/internal/runtime.test.ts b/src/internal/runtime.test.ts index 7af19cf..43a9803 100644 --- a/src/internal/runtime.test.ts +++ b/src/internal/runtime.test.ts @@ -80,13 +80,14 @@ describe("makeCliRuntime", () => { }); it("returns false when the opener emits an asynchronous spawn error", async () => { + const unref = vi.fn(); spawnMock.mockImplementation(() => { const child = new EventEmitter() as EventEmitter & { pid: undefined; unref: ReturnType; }; child.pid = undefined; - child.unref = vi.fn(); + child.unref = unref; queueMicrotask(() => child.emit("error", new Error("missing opener"))); return child; }); @@ -96,9 +97,10 @@ describe("makeCliRuntime", () => { await expect(Effect.runPromise(runtime.openExternal("https://app.put.io"))).resolves.toBe( false, ); + expect(unref).not.toHaveBeenCalled(); }); - it("aborts opener startup and removes listeners when interrupted", async () => { + it("aborts opener startup without leaving AbortError unhandled", async () => { const child = new EventEmitter() as EventEmitter & { unref: ReturnType; }; @@ -106,6 +108,11 @@ describe("makeCliRuntime", () => { let spawnSignal: AbortSignal | undefined; spawnMock.mockImplementation((_file, _args, options: { signal: AbortSignal }) => { spawnSignal = options.signal; + options.signal.addEventListener( + "abort", + () => queueMicrotask(() => child.emit("error", new Error("aborted"))), + { once: true }, + ); return child; }); @@ -113,10 +120,13 @@ describe("makeCliRuntime", () => { const fiber = Effect.runFork(runtime.openExternal("https://app.put.io")); await Effect.runPromise(Fiber.interrupt(fiber)); + await Promise.resolve(); expect(spawnSignal?.aborted).toBe(true); expect(child.listenerCount("error")).toBe(0); expect(child.listenerCount("spawn")).toBe(0); + expect(child.listenerCount("close")).toBe(0); + expect(child.unref).not.toHaveBeenCalled(); }); it("starts and stops a spinner and clears the terminal line", async () => { diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts index 1053eaf..0617221 100644 --- a/src/internal/runtime.ts +++ b/src/internal/runtime.ts @@ -50,19 +50,25 @@ const openExternalWithPlatform = (platform: NodeJS.Platform, url: string) => { const onError = () => { child.removeListener("spawn", onSpawn); + child.removeListener("close", onClose); resume(Effect.succeed(false)); }; + const onClose = () => { + child.removeListener("error", onError); + child.removeListener("spawn", onSpawn); + }; const onSpawn = () => { child.removeListener("error", onError); + child.removeListener("close", onClose); child.unref(); resume(Effect.succeed(true)); }; + child.once("close", onClose); child.once("error", onError); child.once("spawn", onSpawn); return Effect.sync(() => { - child.removeListener("error", onError); child.removeListener("spawn", onSpawn); }); });