diff --git a/.claude/skills/dedalus-cli-sdk/SKILL.md b/.claude/skills/dedalus-cli-sdk/SKILL.md new file mode 100644 index 0000000..fd1e924 --- /dev/null +++ b/.claude/skills/dedalus-cli-sdk/SKILL.md @@ -0,0 +1,72 @@ +--- +name: dedalus-cli-sdk +description: "CLI SDK for Dedalus API. Use when writing CLI code that calls Dedalus API with the dedalus-cli package: installing it, constructing and authenticating the client, and calling API operations." +--- + +# Dedalus CLI SDK + +Generated CLI client for Dedalus API, published as `dedalus-cli`. Use the generated client instead of hand-writing HTTP requests. + +## Install + +```sh +# npm (requires Node.js) +npm install -g dedalus-cli + +# Homebrew — standalone binary, no Node.js required +brew install dedalus-labs/tap/dedalus + +# Direct download — standalone binary, no Node.js required +curl -fsSL "https://github.com/dedalus-labs/dedalus-cli/releases/latest/download/dedalus-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz" | tar xz +sudo mv dedalus /usr/local/bin/ + +# Windows — download and extract dedalus-windows-x64.zip, then add it to PATH +# https://github.com/dedalus-labs/dedalus-cli/releases/latest/download/dedalus-windows-x64.zip +``` + +## Client setup and authentication + +Provide credentials using the options below. Environment variables are read automatically when the target runtime supports them: + +- `--api-key` (env: `DEDALUS_API_KEY`) — Dedalus API key or short-lived delegated access token in Authorization: Bearer . +- `--x-api-key` (env: `DEDALUS_X_API_KEY`) — Dedalus API key. Alternative to Bearer token. + +## Calling operations + +```sh +dedalus [resource] [command] [flags] + +dedalus machines create \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --autosleep '300s' \ + --memory-mib '4096' \ + --storage-gib '10' \ + --vcpu '1' +``` + +Method names, parameter shapes, and response types are generated from the API description — do not guess them. Look up the exact call signature in [api.md](../../../api.md) before writing a call. + +## Pagination + +Paginated commands fetch subsequent pages for you. Use `--max-items ` to cap the total number of items returned. + +## Error handling + +Failed requests print a structured error to standard error and exit with a status that identifies the failure class. The error body carries the API's own `message` plus a stable `code`, the HTTP `status`, the `requestId`, and — where one applies — an actionable `hint`. Usage errors (exit `2`) are reported as a plain message instead, since no request was made. Exit statuses: `0` success, `1` `error`, `2` `usage`, `10` `auth-failed`, `11` `not-found`, `12` `rate-limited`, `13` `client-error`, `14` `server-error`, `15` `connection-error`. + +## Working with this SDK programmatically + +- Pass `--format toon` for token-efficient structured output: a uniform list collapses into one header plus a row per item, with a definitive `[N]` count. Use `--format json` when the output is fed to a JSON parser. +- Use `--max-items ` to bound paginated, streaming, and WebSocket commands before they fill the context, and `--transform ` to keep only the field you need. +- Endpoint commands never prompt, so they are safe to run non-interactively. Credentials come from the documented environment variables or their flags. `login` is meant for a human at a terminal, so do not drive it: a flow that reads a credential refuses a non-terminal stdin outright, and a browser or device flow waits for a person to finish signing in elsewhere before it gives up. Set the credential through its environment variable or flag instead. +- Branch on the exit status rather than on stderr text: `0` success, `1` `error`, `2` `usage`, `10` `auth-failed`, `11` `not-found`, `12` `rate-limited`, `13` `client-error`, `14` `server-error`, `15` `connection-error`. A failed request repeats its class on stderr as a stable `code`, with a `hint` when there is a concrete next step; exit `2` is a plain message with no structured body, because the command never ran. +- Run `dedalus --help` or `dedalus --help` to discover commands and flags, and `man dedalus` for the full reference. + +## Requirements + +- Node.js 20 or newer — for the npm install only; the standalone binaries bundle their own runtime. + +## Reference files + +- [README.md](../../../README.md) — full feature tour: client options, retries and timeouts, logging. +- [api.md](../../../api.md) — complete catalogue of every operation with request and response types. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 0dd0347..0000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,4 +0,0 @@ -# This file is used to automatically assign reviewers to PRs -# For more information see: https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners - -* @windsornguyen diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..12ce746 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - blacksmith-4vcpu-ubuntu-2404 diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml deleted file mode 100644 index 1c80417..0000000 --- a/.github/actions/setup-go/action.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Setup Go -description: 'Sets up Go environment with private modules' -inputs: - stainless-api-key: - required: false - description: the value of the STAINLESS_API_KEY secret -runs: - using: composite - steps: - - uses: stainless-api/retrieve-github-access-token@1f03f929b746c5b03dcdafa2bebbb18ca5672e1a # v1.0.0 - if: github.repository == 'stainless-sdks/dedalus-cli' - id: get_token - with: - repo: stainless-sdks/dedalus-go - stainless-api-key: ${{ inputs.stainless-api-key }} - - - name: Configure Git for access to the Go SDK's staging repo - if: github.repository == 'stainless-sdks/dedalus-cli' - shell: bash - run: git config --global url."https://x-access-token:${{ steps.get_token.outputs.github_access_token }}@github.com/stainless-sdks/dedalus-go".insteadOf "https://github.com/stainless-sdks/dedalus-go" - - - name: Setup go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 - with: - go-version-file: ./go.mod - - - name: Bootstrap - shell: bash - run: ./scripts/bootstrap diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 1738eb5..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: CI -on: - push: - branches: - - '**' - - '!integrated/**' - - '!stl-preview-head/**' - - '!stl-preview-base/**' - - '!generated' - - '!codegen/**' - - 'codegen/stl/**' - pull_request: - branches-ignore: - - 'stl-preview-head/**' - - 'stl-preview-base/**' - -env: - GOPRIVATE: github.com/dedalus-labs/dedalus-go,github.com/stainless-sdks/dedalus-go - -jobs: - lint: - timeout-minutes: 10 - name: lint - runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: ./.github/actions/setup-go - with: - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} - - - name: Link staging branch - if: github.repository == 'stainless-sdks/dedalus-cli' - run: | - ./scripts/link 'github.com/stainless-sdks/dedalus-go@${{ github.ref_name }}' || true - - - name: Bootstrap - run: ./scripts/bootstrap - - - name: Run lints - run: ./scripts/lint - - build: - timeout-minutes: 10 - name: build - permissions: - contents: read - id-token: write - runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: ./.github/actions/setup-go - with: - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} - - - name: Link staging branch - if: github.repository == 'stainless-sdks/dedalus-cli' - run: | - ./scripts/link 'github.com/stainless-sdks/dedalus-go@${{ github.ref_name }}' || true - - - name: Bootstrap - run: ./scripts/bootstrap - - - name: Run goreleaser - uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0 - with: - version: latest - args: release --snapshot --clean --skip=publish - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get GitHub OIDC Token - if: |- - github.repository == 'stainless-sdks/dedalus-cli' && - !startsWith(github.ref, 'refs/heads/stl/') - id: github-oidc - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: core.setOutput('github_token', await core.getIDToken()); - - - name: Upload tarball - if: |- - github.repository == 'stainless-sdks/dedalus-cli' && - !startsWith(github.ref, 'refs/heads/stl/') - env: - URL: https://pkg.stainless.com/s - AUTH: ${{ steps.github-oidc.outputs.github_token }} - SHA: ${{ github.sha }} - run: ./scripts/utils/upload-artifact.sh - - test: - timeout-minutes: 10 - name: test - runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: ./.github/actions/setup-go - with: - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} - - - name: Link staging branch - if: github.repository == 'stainless-sdks/dedalus-cli' - run: | - ./scripts/link 'github.com/stainless-sdks/dedalus-go@${{ github.ref_name }}' || true - - - name: Bootstrap - run: ./scripts/bootstrap - - - name: Run tests - run: ./scripts/test diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml deleted file mode 100644 index aaa5171..0000000 --- a/.github/workflows/publish-release.yml +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: Publish Release -permissions: - contents: write - -concurrency: - group: publish - -on: - push: - tags: - - "v*" - workflow_dispatch: {} -jobs: - goreleaser: - runs-on: ubuntu-latest - environment: production - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 - with: - go-version-file: "go.mod" - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0 - with: - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} - MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} - MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} - MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} - MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} - MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} \ No newline at end of file diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..9001871 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,225 @@ +name: Release Please + +on: + # A release is cut when a human merges the platform-managed release PR into + # main (the PR's base, so its diff shows the full pending release). + # Every other push here runs release-please as well — commits written straight to + # this branch included — and cuts nothing, there being no release commit at the tip. + push: + branches: + - main + # The Scalar platform pushes these workflow files straight to main to keep them + # current between releases; that is bookkeeping, never a release. Running anyway is worse + # than pointless on a branch that has not been handed a .release-please-manifest.json yet — + # release-please fails the run outright when it cannot read one. A release commit always + # rewrites the manifest and the changelog, so this filter can never swallow one. + paths-ignore: + - '.github/workflows/**' + # Manual fallback only; nothing in the automated chain depends on dispatch. + workflow_dispatch: + +permissions: + contents: write + # Required to update the merged release PR's autorelease labels after tagging. + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + # Consumed by the publish job's checkout: without this mapping the checkout ref would be + # empty and the publish would build the triggering branch head instead of the released + # tag's exact commit. + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + id: release + with: + target-branch: main + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + # Release PRs are opened and updated by the Scalar platform with its own + # credential (so their CI runs without manual approval); this workflow only + # cuts the tag + GitHub Release once a release PR is merged. + skip-github-pull-request: true + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: ${{ steps.release.outputs.release_created == 'true' }} + with: + fetch-depth: 0 + # Merging the release PR into main is itself the promotion, but the + # version bump and changelog must also land back on scalar-next, or the next + # release PR would propose this release again. A real merge (never a plain + # commit push) so a squash- or rebase-merged release PR syncs just as well; the + # non-conventional message keeps the sync commit out of future changelogs. Plain + # (non-force) push on purpose: losing a race against a concurrent regeneration + # push fails loudly here, and the platform's next sync re-converges. + - name: Sync release back to scalar-next + if: ${{ steps.release.outputs.release_created == 'true' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin scalar-next + git checkout -B scalar-next origin/scalar-next + git merge --no-edit -m "Sync release ${{ steps.release.outputs.tag_name }} back to scalar-next" "${{ steps.release.outputs.sha }}" + git push origin scalar-next + publish: + needs: release-please + # `!cancelled()` keeps publishing even when the back-sync to the integration branch + # failed: a raced integration branch must not block the release from reaching its + # registry. + if: ${{ !cancelled() && needs.release-please.outputs.release_created == 'true' }} + runs-on: ubuntu-latest + environment: production + # Job-level permissions replace the workflow's release-cutting grants with the publish + # floor. OIDC trusted publishing verifies this run's claims, which name this workflow + # file — register release-please.yml as the trusted publisher on the registry. + permissions: + contents: write + id-token: write + packages: write + env: + VERSIONING_POLICY: manual + steps: + # Publishing runs inline — a top-level job of this same run — rather than dispatching + # sdk-release.yml: workflow_dispatch resolves the target workflow on the repository's + # default branch only, so a dispatch would 404 whenever the release line is any other + # branch, while OIDC trusted publishing accepts this job because it stays top-level + # (never a reusable-workflow call). The release-please job cut the tag earlier in this + # run; checking it out publishes exactly the released commit, not the branch head that + # triggered the workflow. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + - run: npm install + - run: npm run build + # The version is owned by release tooling in this repository, never by the SDK generator, + # so the asset and Homebrew steps read it from package.json at run time. + - name: Read package version + run: echo "PKG_VERSION=$(node -p 'require("./package.json").version')" >> "$GITHUB_ENV" + # OIDC trusted publishing needs npm >= 11.5; pinned to the major (not `latest`) so a new + # npm major raising its Node engine floor cannot break publishes against the pinned Node. + # No token is stored: `npm publish` mints a short-lived, provenance-signed credential once + # this repository is registered as a trusted publisher for the package. + - run: npm install -g npm@11 + - name: Publish to npm + run: node scripts/publish-npm.mjs + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.4.0" + # `bun build --compile` embeds the Bun runtime and every dependency in the executable, so the + # published artifacts run on a machine with no Node, no Bun, and no `node_modules` — unlike the + # npm package, which stays a normal Node CLI. Bun cross-compiles every target from this one + # Linux runner by downloading the matching runtime, so the matrix needs no extra jobs. + # + # The compile reads `src/bin.ts` rather than the `tsc` output in `dist/`: Bun bundles + # TypeScript directly, and going through `dist/` would only add the emitted ESM specifier + # rewriting as a failure mode between the source and the shipped binary. + # + # Asset names carry no version: the release tag in the download path already does, and a + # stable file name is what makes `releases/latest/download/` resolve, so install + # instructions can point at "latest" without being rewritten every release. + - name: Compile standalone binaries + run: | + ROOT="$PWD" + mkdir -p release-binaries + for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 windows-x64; do + rm -rf compile-out && mkdir -p compile-out + case "$target" in + windows-*) + bun build src/bin.ts --compile --minify --target="bun-${target}" --outfile "compile-out/dedalus.exe" + (cd compile-out && zip -q "$ROOT/release-binaries/dedalus-${target}.zip" "dedalus.exe") + ;; + *) + bun build src/bin.ts --compile --minify --target="bun-${target}" --outfile "compile-out/dedalus" + tar -czf "release-binaries/dedalus-${target}.tar.gz" -C compile-out "dedalus" + ;; + esac + done + rm -rf compile-out + - name: Attach binaries to the release + run: gh release upload "v${PKG_VERSION}" release-binaries/* --clobber + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Update Homebrew tap + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + # Reads back the archives the preceding asset step just uploaded. That step runs + # `gh release upload` on the ambient GITHUB_TOKEN, which can only write releases in + # the repository the workflow runs in, so the download must resolve against + # `github.repository` too. Deriving it from configured productionRepo instead lets the + # two disagree — a renamed, transferred, or forked destination repo then 404s here on + # every release, having uploaded the archives perfectly well one step earlier. + BASE_URL="https://github.com/${{ github.repository }}/releases/download/v${PKG_VERSION}" + git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/dedalus-labs/homebrew-tap.git" tap + mkdir -p tap/Formula + # Each archive holds a single `bun build --compile` executable with the Bun runtime and + # every dependency embedded, so the formula declares no `depends_on` and installs the + # binary as-is — `brew install` no longer pulls Node in as a dependency. + # + # The formula file is lowercase even when the command it installs is not: Homebrew resolves + # `brew install //` to `Formula/.rb`, and that lookup is case-sensitive + # on Linux. The `bin.install` below still installs the command under its own spelling. + cat > "tap/Formula/dedalus.rb" <<'EOF' + class Dedalus < Formula + desc "The official CLI for Dedalus." + homepage "https://docs.dedaluslabs.ai" + version "__VERSION__" + + on_macos do + on_arm do + url "__URL_DARWIN_ARM64__" + sha256 "__SHA_DARWIN_ARM64__" + end + on_intel do + url "__URL_DARWIN_X64__" + sha256 "__SHA_DARWIN_X64__" + end + end + + on_linux do + on_arm do + url "__URL_LINUX_ARM64__" + sha256 "__SHA_LINUX_ARM64__" + end + on_intel do + url "__URL_LINUX_X64__" + sha256 "__SHA_LINUX_X64__" + end + end + + def install + bin.install "dedalus" + end + + test do + system "#{bin}/dedalus", "--help" + end + end + EOF + FORMULA="tap/Formula/dedalus.rb" + # Homebrew resolves one url/sha256 pair per platform at install time, but the formula is a + # single file, so every platform the tap supports has to be pinned on every release. + for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do + ASSET="dedalus-${target}.tar.gz" + curl -fsSL "$BASE_URL/$ASSET" -o "$ASSET" + SHA256="$(shasum -a 256 "$ASSET" | cut -d" " -f1)" + # `darwin-arm64` names the `__URL_DARWIN_ARM64__`/`__SHA_DARWIN_ARM64__` placeholder + # pair, so the loop stays driven by the target list instead of a hand-written sed per + # platform that can silently fall out of step with the formula above. + TOKEN="$(echo "$target" | tr "a-z-" "A-Z_")" + sed -i -e "s|__URL_${TOKEN}__|$BASE_URL/$ASSET|" -e "s|__SHA_${TOKEN}__|$SHA256|" "$FORMULA" + done + sed -i -e "s|__VERSION__|$PKG_VERSION|" "$FORMULA" + cd tap + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add "Formula/dedalus.rb" + git commit -m "dedalus ${PKG_VERSION}" || { echo "formula unchanged, skipping"; exit 0; } + git push diff --git a/.github/workflows/release-title-edit.yml b/.github/workflows/release-title-edit.yml new file mode 100644 index 0000000..c836f65 --- /dev/null +++ b/.github/workflows/release-title-edit.yml @@ -0,0 +1,143 @@ +name: Release PR Version + +on: + # `edited` is not one of the default pull_request types, but it is the point of this + # workflow: retitling the release PR is how a maintainer picks an exact version. The + # other types keep the consistency check attached to the PR as it evolves. + pull_request: + types: [opened, reopened, edited, synchronize] + +# Read-only by default; only the job that pushes the Release-As commit widens this. +permissions: + contents: read + +jobs: + version-consistency: + # Also the check name the release PR's footer tells maintainers to wait for. + name: Release PR version + # A release-shaped title against the release branch is the whole test here, with no clause + # on who opened the pull request or what branch it is rendered onto. This job only reads, + # and a pull request claiming to be a release is worth checking against the committed + # version whoever opened it. Every other pull request skips this job, which GitHub reports + # as neutral. + # + # Loose on purpose: the prefix, not the full semver pattern the script matches. A version + # typo'd into the title still reaches the script and fails there, rather than falling out + # of the guard and leaving no check at all — a missing check is not a failing one, and this + # is the check the release PR's footer tells maintainers to wait for before merging. + # + # It is also what keeps the workflow from failing silently if the platform ever opens + # release PRs from a different account: the bridge below stops firing, but this check still + # runs, the retitled version and the committed one disagree, and it turns red. The merge is + # blocked loudly instead of releasing the old version. + if: >- + ${{ github.event.pull_request.base.ref == 'main' + && startsWith(github.event.pull_request.title, 'release: ') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The version committed on the PR head is what merging would actually release. + ref: ${{ github.event.pull_request.head.sha }} + # Fails while the title version and the committed version disagree — the window between + # a maintainer's retitle and the platform re-rendering the PR from it. Without this a + # merge in that window would tag a release whose own files self-report the old version, + # and a title release-please cannot parse would silently cut no release at all. + - name: Compare the title version with the committed version + env: + # The title is human input, so it is bound through the environment (never + # interpolated into the script) and matched against a strict semver pattern + # before it is read. + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + pattern='^release: ((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?)$' + if [[ ! "$PR_TITLE" =~ $pattern ]]; then + echo "::error::Release PR title must be \"release: X.Y.Z\" with a full semver version, got: $PR_TITLE" + exit 1 + fi + title_version="${BASH_REMATCH[1]}" + manifest_version="$(jq -r '.["."]' .release-please-manifest.json)" + if [[ "$title_version" != "$manifest_version" ]]; then + echo "::error::Release PR title says $title_version but the pull request is versioned $manifest_version. Wait for the release PR to be re-rendered at $title_version before merging." + exit 1 + fi + echo "Release PR title and committed version agree on $title_version." + + apply-title-version: + name: Apply edited release version + # A human retitle of the open release PR is bridged into the canonical explicit-version + # mechanism (a Release-As commit on scalar-next, which the platform re-renders the + # release PR from). `changes.title` is only set when the title itself changed, and bot + # senders are ignored so the platform's own retitles cannot bounce back into another + # commit. + # + # Both signals are required here, unlike the read-only check above, and each covers what the + # other cannot. The author, because this job pushes with `contents: write` and a title is + # free text — anyone able to open a pull request against main could otherwise + # name a version and have it committed to scalar-next. `user.login` is set by GitHub when + # the pull request is opened and cannot be forged by whoever edits the title afterwards. + # The title, because those accounts open pull requests other than release PRs, and retitling + # one of those must not push a release. + # + # One login per platform deployment, since a repo generated by staging carries staging's + # app. Parenthesised because the group is ANDed with the title clause below: without the + # parens that `&&` would bind to the last login alone, letting the other accounts push a + # release off any title. Listed rather than matched on the shared `scalar-docs` stem — a + # prefix test would also admit any future `scalar-docs-*[bot]`, including someone else's. + if: >- + ${{ github.event.action == 'edited' + && github.event.changes.title != null + && github.event.sender.type != 'Bot' + && github.event.pull_request.state == 'open' + && github.event.pull_request.base.ref == 'main' + && (github.event.pull_request.user.login == 'scalar-docs[bot]' + || github.event.pull_request.user.login == 'scalar-docs-staging[bot]' + || github.event.pull_request.user.login == 'scalar-docs-development[bot]') + && startsWith(github.event.pull_request.title, 'release: ') }} + # One bridge run at a time per pull request, newest retitle wins. Two retitles in quick + # succession (a version typo corrected seconds later) would otherwise start two runs that + # both read the version committed on the PR head, both get past the no-op guard, and both + # push — and since each fetches scalar-next immediately before committing, the second + # push fast-forwards instead of failing. release-please honours the newest Release-As + # footer, so the release would land on whichever run happened to push last, not on the + # title the maintainer actually left behind. + concurrency: + group: release-title-edit-${{ github.event.pull_request.number }} + cancel-in-progress: true + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The PR head, so the version the pull request currently carries can be read before + # deciding whether anything needs to change. + ref: ${{ github.event.pull_request.head.sha }} + - name: Push a Release-As commit for the edited version + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set -euo pipefail + pattern='^release: ((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?)$' + if [[ ! "$PR_TITLE" =~ $pattern ]]; then + echo "::error::Release PR title must be \"release: X.Y.Z\" with a full semver version, got: $PR_TITLE" + exit 1 + fi + # Only ever a validated semver from here on, so it is safe in a commit message. + version="${BASH_REMATCH[1]}" + manifest_version="$(jq -r '.["."]' .release-please-manifest.json)" + # The re-rendered PR is retitled to the version it now carries; stopping here keeps + # that from producing an endless chain of Release-As commits. + if [[ "$version" == "$manifest_version" ]]; then + echo "This release PR already carries $version; nothing to do." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin scalar-next + git checkout -B scalar-next origin/scalar-next + git commit --allow-empty -m "chore: release $version" -m "Release-As: $version" + # Plain (non-force) push: losing a race against a regeneration push fails loudly + # rather than discarding it, and the retitle can simply be repeated. + git push origin scalar-next + echo "Pushed Release-As: $version to scalar-next. The release PR will be re-rendered at that version." diff --git a/.github/workflows/sdk-ci.yml b/.github/workflows/sdk-ci.yml new file mode 100644 index 0000000..670ba82 --- /dev/null +++ b/.github/workflows/sdk-ci.yml @@ -0,0 +1,42 @@ +# @generated by Hollywood. Do not edit by hand. + +name: CLI SDK CI +on: + push: {} + pull_request: {} +permissions: + contents: read +jobs: + verify: + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "24" + - uses: pnpm/action-setup@a15d269cd4658e1107c09f1fabf4cbd7bd1f308a + with: + version: 10.20.0 + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + shell: bash + - name: Generate workflows + run: pnpm run ci:generate + shell: bash + - name: Check generated workflows + run: git diff --exit-code -- .github/workflows/sdk-ci.yml + shell: bash + - name: Build CLI + run: pnpm run build + shell: bash + - name: Install and test staging package + run: node scripts/verify-staging-package.mjs + shell: bash + - name: Upload tested staging package + uses: actions/upload-artifact@v7 + with: + name: dedalus-cli-staging + path: artifacts/* + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/sdk-release.yml b/.github/workflows/sdk-release.yml new file mode 100644 index 0000000..1bcd06d --- /dev/null +++ b/.github/workflows/sdk-release.yml @@ -0,0 +1,158 @@ +name: CLI SDK Release + +on: + # Manual re-publish only: the automated publish for each release runs as the `publish` job + # inside release-please.yml, in the same run that cuts the tag (dispatching a workflow here + # instead would resolve it on the repository's default branch only, which breaks releasing + # from any other release line). Dispatch this workflow at an existing release tag to re-run + # a failed or skipped publish. A dispatched run is top-level, so OIDC trusted-publisher + # claims name this file; register it as an additional trusted publisher only when manual + # re-publishes are used. Never add a workflow_call trigger here — registries reject + # publishes from called (reusable) workflows. + workflow_dispatch: + +permissions: + contents: write + id-token: write + packages: write + +jobs: + release: + runs-on: ubuntu-latest + environment: production + env: + VERSIONING_POLICY: manual + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + - run: npm install + - run: npm run build + # The version is owned by release tooling in this repository, never by the SDK generator, + # so the asset and Homebrew steps read it from package.json at run time. + - name: Read package version + run: echo "PKG_VERSION=$(node -p 'require("./package.json").version')" >> "$GITHUB_ENV" + # OIDC trusted publishing needs npm >= 11.5; pinned to the major (not `latest`) so a new + # npm major raising its Node engine floor cannot break publishes against the pinned Node. + # No token is stored: `npm publish` mints a short-lived, provenance-signed credential once + # this repository is registered as a trusted publisher for the package. + - run: npm install -g npm@11 + - name: Publish to npm + run: node scripts/publish-npm.mjs + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.4.0" + # `bun build --compile` embeds the Bun runtime and every dependency in the executable, so the + # published artifacts run on a machine with no Node, no Bun, and no `node_modules` — unlike the + # npm package, which stays a normal Node CLI. Bun cross-compiles every target from this one + # Linux runner by downloading the matching runtime, so the matrix needs no extra jobs. + # + # The compile reads `src/bin.ts` rather than the `tsc` output in `dist/`: Bun bundles + # TypeScript directly, and going through `dist/` would only add the emitted ESM specifier + # rewriting as a failure mode between the source and the shipped binary. + # + # Asset names carry no version: the release tag in the download path already does, and a + # stable file name is what makes `releases/latest/download/` resolve, so install + # instructions can point at "latest" without being rewritten every release. + - name: Compile standalone binaries + run: | + ROOT="$PWD" + mkdir -p release-binaries + for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 windows-x64; do + rm -rf compile-out && mkdir -p compile-out + case "$target" in + windows-*) + bun build src/bin.ts --compile --minify --target="bun-${target}" --outfile "compile-out/dedalus.exe" + (cd compile-out && zip -q "$ROOT/release-binaries/dedalus-${target}.zip" "dedalus.exe") + ;; + *) + bun build src/bin.ts --compile --minify --target="bun-${target}" --outfile "compile-out/dedalus" + tar -czf "release-binaries/dedalus-${target}.tar.gz" -C compile-out "dedalus" + ;; + esac + done + rm -rf compile-out + - name: Attach binaries to the release + run: gh release upload "v${PKG_VERSION}" release-binaries/* --clobber + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Update Homebrew tap + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + # Reads back the archives the preceding asset step just uploaded. That step runs + # `gh release upload` on the ambient GITHUB_TOKEN, which can only write releases in + # the repository the workflow runs in, so the download must resolve against + # `github.repository` too. Deriving it from configured productionRepo instead lets the + # two disagree — a renamed, transferred, or forked destination repo then 404s here on + # every release, having uploaded the archives perfectly well one step earlier. + BASE_URL="https://github.com/${{ github.repository }}/releases/download/v${PKG_VERSION}" + git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/dedalus-labs/homebrew-tap.git" tap + mkdir -p tap/Formula + # Each archive holds a single `bun build --compile` executable with the Bun runtime and + # every dependency embedded, so the formula declares no `depends_on` and installs the + # binary as-is — `brew install` no longer pulls Node in as a dependency. + # + # The formula file is lowercase even when the command it installs is not: Homebrew resolves + # `brew install //` to `Formula/.rb`, and that lookup is case-sensitive + # on Linux. The `bin.install` below still installs the command under its own spelling. + cat > "tap/Formula/dedalus.rb" <<'EOF' + class Dedalus < Formula + desc "The official CLI for Dedalus." + homepage "https://docs.dedaluslabs.ai" + version "__VERSION__" + + on_macos do + on_arm do + url "__URL_DARWIN_ARM64__" + sha256 "__SHA_DARWIN_ARM64__" + end + on_intel do + url "__URL_DARWIN_X64__" + sha256 "__SHA_DARWIN_X64__" + end + end + + on_linux do + on_arm do + url "__URL_LINUX_ARM64__" + sha256 "__SHA_LINUX_ARM64__" + end + on_intel do + url "__URL_LINUX_X64__" + sha256 "__SHA_LINUX_X64__" + end + end + + def install + bin.install "dedalus" + end + + test do + system "#{bin}/dedalus", "--help" + end + end + EOF + FORMULA="tap/Formula/dedalus.rb" + # Homebrew resolves one url/sha256 pair per platform at install time, but the formula is a + # single file, so every platform the tap supports has to be pinned on every release. + for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do + ASSET="dedalus-${target}.tar.gz" + curl -fsSL "$BASE_URL/$ASSET" -o "$ASSET" + SHA256="$(shasum -a 256 "$ASSET" | cut -d" " -f1)" + # `darwin-arm64` names the `__URL_DARWIN_ARM64__`/`__SHA_DARWIN_ARM64__` placeholder + # pair, so the loop stays driven by the target list instead of a hand-written sed per + # platform that can silently fall out of step with the formula above. + TOKEN="$(echo "$target" | tr "a-z-" "A-Z_")" + sed -i -e "s|__URL_${TOKEN}__|$BASE_URL/$ASSET|" -e "s|__SHA_${TOKEN}__|$SHA256|" "$FORMULA" + done + sed -i -e "s|__VERSION__|$PKG_VERSION|" "$FORMULA" + cd tap + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add "Formula/dedalus.rb" + git commit -m "dedalus ${PKG_VERSION}" || { echo "formula unchanged, skipping"; exit 0; } + git push diff --git a/.gitignore b/.gitignore index 3911bfe..02bae7c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ -.prism.log -.stdy.log -dist/ -/dedalus -*.exe +node_modules/ +/dist/ +*.tsbuildinfo +.env +.env.* +/*.tar.gz +/compile-out/ +/release-binaries/ +/tap/ +/artifacts/ diff --git a/.goreleaser.yml b/.goreleaser.yml deleted file mode 100644 index 3a2e2ed..0000000 --- a/.goreleaser.yml +++ /dev/null @@ -1,110 +0,0 @@ -project_name: dedalus -version: 2 - -before: - hooks: - - mkdir -p completions - - sh -c "go run ./cmd/dedalus/main.go @completion bash > completions/dedalus.bash" - - sh -c "go run ./cmd/dedalus/main.go @completion zsh > completions/dedalus.zsh" - - sh -c "go run ./cmd/dedalus/main.go @completion fish > completions/dedalus.fish" - - sh -c "go run ./cmd/dedalus/main.go @manpages -o man" - -builds: - - id: macos - goos: [darwin] - goarch: [amd64, arm64] - binary: '{{ .ProjectName }}' - main: ./cmd/dedalus/main.go - mod_timestamp: '{{ .CommitTimestamp }}' - ldflags: - - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' - - - id: linux - goos: [linux] - goarch: ['386', arm, amd64, arm64] - env: - - CGO_ENABLED=0 - binary: '{{ .ProjectName }}' - main: ./cmd/dedalus/main.go - mod_timestamp: '{{ .CommitTimestamp }}' - ldflags: - - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' - - - id: windows - goos: [windows] - goarch: ['386', amd64, arm64] - binary: '{{ .ProjectName }}' - main: ./cmd/dedalus/main.go - mod_timestamp: '{{ .CommitTimestamp }}' - ldflags: - - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' - -archives: - - id: linux-archive - ids: [linux] - name_template: '{{ .ProjectName }}_{{ .Version }}_linux_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' - formats: [tar.gz] - files: - - completions/* - - man/*/* - - id: macos-archive - ids: [macos] - name_template: '{{ .ProjectName }}_{{ .Version }}_macos_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' - formats: [zip] - files: - - completions/* - - man/*/* - - id: windows-archive - ids: [windows] - name_template: '{{ .ProjectName }}_{{ .Version }}_windows_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' - formats: [zip] - files: - - completions/* - - man/*/* - -snapshot: - version_template: '{{ .Tag }}-next' - -nfpms: - - license: MIT - maintainer: oss@dedaluslabs.ai - bindir: /usr - formats: - - apk - - deb - - rpm - - termux.deb - - archlinux - contents: - - src: man/man1/*.1.gz - dst: /usr/share/man/man1/ -homebrew_casks: - - name: dedalus - repository: - owner: dedalus-labs - name: homebrew-tap - token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" - homepage: https://docs.dedaluslabs.ai - description: The official CLI for Dedalus. - license: MIT - binary: "dedalus" - completions: - bash: "completions/dedalus.bash" - zsh: "completions/dedalus.zsh" - fish: "completions/dedalus.fish" - manpages: - - man/man1/dedalus.1.gz - -notarize: - macos: - - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' - ids: [macos] - - sign: - certificate: "{{.Env.MACOS_SIGN_P12}}" - password: "{{.Env.MACOS_SIGN_PASSWORD}}" - - notarize: - issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}" - key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}" - key: "{{.Env.MACOS_NOTARY_KEY}}" diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4208b5c..1b77f50 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.6.0" + ".": "0.7.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml deleted file mode 100644 index 6d1924d..0000000 --- a/.stats.yml +++ /dev/null @@ -1,4 +0,0 @@ -configured_endpoints: 17 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/dedalus-labs/dedalus-c0e28b234478af75f61bcf42f5f32818d96ec93b1d8239fa0ff1c801c0e4b64f.yml -openapi_spec_hash: a70c391bb0957fc90c5dbf251de8afe4 -config_hash: fa45a5bf161b7291cb5ea20c2da83cc0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ef59b..466eff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,112 @@ # Changelog +## [0.7.0](https://github.com/dedalus-labs/dedalus-cli/compare/v0.6.0...v0.7.0) (2026-09-29) + + +### ⚠ BREAKING CHANGES + +* **api:** 3 breaking changes to the SDK surface. + - Removed `bearer` auth scheme `Bearer`. + - Removed operation `machines.terminals.connect` (`GET /v1/machines/{machine_id}/terminals/{terminal_id}/stream`). + - Removed optional property `RetrieveResponseHeaders.ETag`. +* **api:** 72 breaking changes to the SDK surface. + - Configuration of `bearer` auth scheme `BearerAuth` changed. + - Removed header param `X-Dedalus-Org-Id` from `machines.list`. + - Removed header param `X-Dedalus-Org-Id` from `machines.create`. + - Removed header param `X-Dedalus-Org-Id` from `machines.retrieve`. + - Removed header param `X-Dedalus-Org-Id` from `machines.update`. + - Removed header param `X-Dedalus-Org-Id` from `machines.delete`. + - Removed header param `X-Dedalus-Org-Id` from `machines.sleep`. + - Removed header param `X-Dedalus-Org-Id` from `machines.wake`. + - Removed header param `X-Dedalus-Org-Id` from `machines.ssh.list`. + - Removed header param `X-Dedalus-Org-Id` from `machines.ssh.create`. + - Removed header param `X-Dedalus-Org-Id` from `machines.ssh.retrieve`. + - Removed header param `X-Dedalus-Org-Id` from `machines.ssh.delete`. + - Removed header param `X-Dedalus-Org-Id` from `machines.executions.list`. + - Removed header param `X-Dedalus-Org-Id` from `machines.executions.create`. + - Removed header param `X-Dedalus-Org-Id` from `machines.executions.retrieve`. + - Removed header param `X-Dedalus-Org-Id` from `machines.executions.delete`. + - Removed header param `X-Dedalus-Org-Id` from `machines.executions.output`. + - Removed header param `X-Dedalus-Org-Id` from `machines.executions.events`. + - Serialization or defaults of path param `machine_id` on `machines.terminals.connect` changed. + - Serialization or defaults of path param `terminal_id` on `machines.terminals.connect` changed. + - Removed header param `X-Dedalus-Org-Id` from `machines.terminals.connect`. + - Removed operation `machines.watch` (`GET /v1/machines/{machine_id}/status/stream`). + - Removed operation `machines.network.retrieve` (`GET /v1/machines/{machine_id}/network`). + - Removed operation `machines.artifacts.list` (`GET /v1/machines/{machine_id}/artifacts`). + - Removed operation `machines.artifacts.retrieve` (`GET /v1/machines/{machine_id}/artifacts/{artifact_id}`). + - Removed operation `machines.artifacts.delete` (`DELETE /v1/machines/{machine_id}/artifacts/{artifact_id}`). + - Removed operation `machines.ports.list` (`GET /v1/machines/{machine_id}/ports`). + - Removed operation `machines.ports.create` (`POST /v1/machines/{machine_id}/ports`). + - Removed operation `machines.ports.retrieve` (`GET /v1/machines/{machine_id}/ports/{port_id}`). + - Removed operation `machines.ports.delete` (`DELETE /v1/machines/{machine_id}/ports/{port_id}`). + - Removed operation `machines.terminals.list` (`GET /v1/machines/{machine_id}/terminals`). + - Removed operation `machines.terminals.create` (`POST /v1/machines/{machine_id}/terminals`). + - Removed operation `machines.terminals.retrieve` (`GET /v1/machines/{machine_id}/terminals/{terminal_id}`). + - Removed operation `machines.terminals.delete` (`DELETE /v1/machines/{machine_id}/terminals/{terminal_id}`). + - Removed operation `networks.retrieve` (`GET /v1/networks/{network_id}`). + - Removed operation `usage.retrieve` (`GET /v1/usage`). + - Removed operation `usage.machineCompute` (`GET /v1/usage/machines/compute`). + - Removed operation `usage.machineStorage` (`GET /v1/usage/machines/storage`). + - Added required property `execution.log_capture`. + - Property `execution.machine_id` type changed from `string` to `string`. + - Property `machine.machine_id` type changed from `string` to `string`. + - Added required property `lifecycle_status.memory_configured_mib`. + - Property `machine_detail_response.machine_id` type changed from `string` to `string`. + - Schema `machine_id_path_segment` shape changed. + - Property `machine_list_item.machine_id` type changed from `string` to `string`. + - Property `ssh_session.machine_id` type changed from `string` to `string`. + - Property `ssh_session.status` type changed from `enum(wake_in_progress | ready | closed | …)` to `enum(wake_in_progress | ssh_in_progress | ready | …)`. + - Removed optional property `CreateResponseHeaders.ETag`. + - Removed optional property `CreateResponseHeaders.X-Dedalus-Storage-Operation-Id`. + - Removed schema `artifact_list`. + - Removed schema `artifact`. + - Removed schema `port_create_params`. + - Removed schema `terminal_create_params`. + - Removed schema `machine_compute_usage`. + - Removed schema `machine_compute_usage_row`. + - Removed schema `machine_network`. + - Removed schema `machine_storage_usage`. + - Removed schema `machine_storage_usage_row`. + - Removed schema `network_gateway`. + - Removed schema `network`. + - Removed schema `port_list`. + - Removed schema `port`. + - Removed schema `terminal_client_event`. + - Removed schema `terminal_closed_event`. + - Removed schema `terminal_error_event`. + - Removed schema `terminal_input_event`. + - Removed schema `terminal_list`. + - Removed schema `terminal_output_event`. + - Removed schema `terminal_resize_event`. + - Removed schema `terminal`. + - Removed schema `terminal_server_event`. + - Removed schema `org_usage`. + +### Features + +* **api:** remove auth scheme Bearer (+25 more changes) ([c88225c](https://github.com/dedalus-labs/dedalus-cli/commit/c88225c22c8ddd3f4b9419f80c0e07db0ca7cac9)) +* **api:** update auth scheme BearerAuth (+122 more changes) ([634e8df](https://github.com/dedalus-labs/dedalus-cli/commit/634e8df198b0328288ebbd6600e962de1a851115)) +* **auth:** enable generated browser login ([01c3c79](https://github.com/dedalus-labs/dedalus-cli/commit/01c3c79b4573329c2d55322354117d8d2672a0f7)) +* **auth:** enable generated browser login ([#106](https://github.com/dedalus-labs/dedalus-cli/issues/106)) ([8200172](https://github.com/dedalus-labs/dedalus-cli/commit/8200172eaff6c6ac9105fa0f07c4407eff010d2b)) + + +### Bug Fixes + +* **auth:** allow local logout when revocation fails ([6ad1f27](https://github.com/dedalus-labs/dedalus-cli/commit/6ad1f2748968d4ff3efc07c7217cec9e50e8a073)) +* **auth:** revoke saved browser sessions on logout ([c4507a0](https://github.com/dedalus-labs/dedalus-cli/commit/c4507a059b832ce300c1b2dfae1b42aada491ae8)) +* **cli:** integrate current public API contracts ([cbd1e3a](https://github.com/dedalus-labs/dedalus-cli/commit/cbd1e3a6813f22c02608f1987a560cf02822c49e)) +* **cli:** preserve custom commands through regeneration ([a77f461](https://github.com/dedalus-labs/dedalus-cli/commit/a77f461361d80bfc2a7d5d6ae93f77ea751e0ec4)) + + +### Chores + +* **api:** regenerate SDK ([ce2684d](https://github.com/dedalus-labs/dedalus-cli/commit/ce2684d4a005c3bfa8510327aabc17764e38ee04)) +* **api:** update generated SDK content ([58afece](https://github.com/dedalus-labs/dedalus-cli/commit/58afecef76d33f34876a14b3a173b7b6e2c01fcd)) +* **cli:** reconcile published release history ([#102](https://github.com/dedalus-labs/dedalus-cli/issues/102)) ([930b93e](https://github.com/dedalus-labs/dedalus-cli/commit/930b93ed5a41f6389a47021f287f2a13d61d9e48)) +* **cli:** reconcile published v0.6.0 history ([367adb4](https://github.com/dedalus-labs/dedalus-cli/commit/367adb4246aad9983002de664e80f2874e41ca5b)) +* **cli:** synchronize scalar source history ([fcd272f](https://github.com/dedalus-labs/dedalus-cli/commit/fcd272f738c34a321f8734bedd54825d368302ec)) + ## 0.6.0 (2026-08-19) Full Changelog: [v0.5.0...v0.6.0](https://github.com/dedalus-labs/dedalus-cli/compare/v0.5.0...v0.6.0) diff --git a/LICENSE b/LICENSE index 3aa6139..d6e38b3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,201 @@ -Copyright 2026 Dedalus + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + 1. Definitions. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Dedalus + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 12abefd..817837e 100644 --- a/README.md +++ b/README.md @@ -1,200 +1,280 @@ -# Dedalus CLI +# Dedalus -The official CLI for the [Dedalus REST API](https://docs.dedaluslabs.ai). +This library provides convenient access to the Dedalus REST API from the command line. -It is generated with [Stainless](https://www.stainless.com/). +The full API of this library can be found in [api.md](./api.md). - +
-## Installation +## Contents + +- [Installation](#installation) +- [Usage](#usage) +- [Feedback](#feedback) +- [Staging package testing](#staging-package-testing) +- [API Reference](./api.md) +- [Signing In](#signing-in) +- [File Arguments](#file-arguments) +- [Shell Completion](#shell-completion) +- [Manual Pages](#manual-pages) +- [Authentication](#authentication) +- [Errors](#errors) +- [Client Options](#client-options) +- [Retries and Timeouts](#retries-and-timeouts) +- [Pagination](#pagination) +- [Helpers](#helpers) +- [Logging](#logging) +- [Requirements](#requirements) + +
-### Installing with Homebrew +## Installation ```sh +# npm (requires Node.js) +npm install -g dedalus-cli + +# Homebrew — standalone binary, no Node.js required brew install dedalus-labs/tap/dedalus + +# Direct download — standalone binary, no Node.js required +curl -fsSL "https://github.com/dedalus-labs/dedalus-cli/releases/latest/download/dedalus-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz" | tar xz +sudo mv dedalus /usr/local/bin/ + +# Windows — download and extract dedalus-windows-x64.zip, then add it to PATH +# https://github.com/dedalus-labs/dedalus-cli/releases/latest/download/dedalus-windows-x64.zip ``` -### Installing with Go +
-To test or install the CLI locally, you need [Go](https://go.dev/doc/install) version 1.22 or later installed. +## Usage ```sh -go install 'github.com/dedalus-labs/dedalus-cli/cmd/dedalus@latest' +dedalus [resource] [command] [flags] + +dedalus machines create \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --autosleep '300s' \ + --memory-mib '4096' \ + --storage-gib '10' \ + --vcpu '1' ``` -Once you have run `go install`, the binary is placed in your Go bin directory: +Every command accepts the global flags below, so the examples that follow show only what is specific to them. -- **Default location**: `$HOME/go/bin` (or `$GOPATH/bin` if GOPATH is set) -- **Check your path**: Run `go env GOPATH` to see the base directory +See the [API reference](./api.md) for every available operation. -If commands aren't found after installation, add the Go bin directory to your PATH: +
-```sh -# Add to your shell profile (.zshrc, .bashrc, etc.) -export PATH="$PATH:$(go env GOPATH)/bin" -``` +## Signing In - + +Browser sign-in is not enabled in the public package until its provider and +client registration are verified. `dedalus login --flow browser` fails before +opening a browser when that configuration is absent. Use an API key meanwhile. +A configured provider must supply its exact callback issuer, authorization, +token, refresh, and revocation endpoints, client ID, scopes, and API resource. +The API resource must match the command's base URL. -### Updating +The configured browser flow uses PKCE, verifies callback state and issuer, and +saves access and refresh tokens. Token refresh retains the revocation endpoint. +`dedalus logout` attempts to revoke the refresh token, or the access token when +no refresh token exists, with a 10-second timeout per request. It then removes +local credentials even if revocation fails or the device is offline. A warning +means the server session may still be active; local deletion does not confirm +server-side revocation. Local credential deletion failures still fail logout. +`--all` applies this to all saved profiles. Manually entered credentials are +removed locally without OAuth revocation. Older sessions without a saved +revocation endpoint are cleared locally with a warning; the CLI does not guess +their provider endpoint. -```sh -dedalus update -``` +Logout does not sign out the browser or read sessions from the older +`dedalus auth login` implementation. Local tests do not prove hosted readiness. + -To check the latest available release without installing it: +`dedalus login` signs you in and saves the credential for later commands, so it does not have to be passed every time. It goes into your operating system's credential store — the system keyring on Linux, Credential Manager on Windows — and falls back to a file in your state directory, readable only by you, when no such store is available. On macOS it is always that file, because the system's own tool accepts a password only on its command line, where other processes could read it. Either way it is filed under the base URL it was captured for, so a credential saved for one host is never sent to another. `dedalus logout` forgets it. A credential passed with a flag, or set in the environment, still takes precedence over a saved one. Sign-in methods: browser, api-key, x-api-key. Pass `--flow ` to pick one without being asked. ```sh -dedalus update --check +dedalus login --flow browser +dedalus login --flow api-key +dedalus logout +dedalus logout --all ``` -The updater respects how the CLI was installed. Homebrew installs delegate to -`brew upgrade`, macOS/Linux curl installs rerun the install script for the -current executable directory, and Windows installs print the PowerShell installer -command because Windows cannot replace the running `dedalus.exe` process. +
-### Running Locally +## File Arguments -After cloning the git repository for this project, you can use the -`scripts/run` script to run the tool locally: +Any command flag or credential reads its value from a file when the value begins with `@`, so a body field holding a whole document does not have to survive shell quoting. `@file://` always sends the file as text and `@data://` always sends it base64-encoded; a bare `@` lets the file decide. A flag that uploads a file takes its path with or without the `@`. Escape a literal value that begins with `@` as `\@`. The global options (`--base-url`, `--timeout`, `--format` and the rest) are read exactly as written. ```sh -./scripts/run args... +dedalus COMMAND --FLAG @./body.json +dedalus COMMAND --FLAG @file://./notes.txt +dedalus COMMAND --FLAG @data://./logo.png +dedalus COMMAND --FLAG '\@not-a-file' ``` -## Usage - -The CLI follows a resource-based command structure: +
+## Feedback ```sh -dedalus [resource] [flags...] +dedalus feedback "Machine creation failed" --include-logs auto +dedalus feedback "A suggestion" --include-logs false +dedalus feedback "Connection problem" --include-logs true +dedalus doctor --json ``` -```sh -dedalus machines create \ - --api-key 'My API Key' \ - --memory-mib 2048 \ - --storage-gib 10 \ - --vcpu 1 -``` +`--include-logs auto` is the default. It attaches diagnostics from a failed command +within the last 15 minutes for the same API host, credentials, and organization. +`false` sends no files; a recent server request receipt may still be included as +metadata. `true` includes a local runtime and proxy-configuration report even when +no failed command is available. Proxy values are omitted. -For details about specific commands, use the `--help` flag. +For a failed command, the receipt comes from its last API response, including a +successful response followed by a local connection failure. A later response +without a receipt clears the candidate. Successful responses are not reported as +HTTP failures. -Machine API commands cover creation, listing, retrieval, updates, deletion, -sleep, and wake. Secure Shell (SSH) session and execution operations are nested -under `machines ssh` and `machines executions`. +The CLI records command names, route templates, response status, duration, and +server-issued request IDs under `~/.dedalus/debug`. It excludes command arguments, +request and response bodies, credentials, terminal output, and workspace files. +Files expire after 10 days and the directory is capped at 50 MiB. Cleanup runs +when recording new diagnostics. Each process partition is capped at 1,000 events +and 10 MiB. Feedback submissions never become diagnostic candidates themselves. -The generated contract is defined by the [OpenAPI snapshot](https://storage.googleapis.com/stainless-sdk-openapi-specs/dedalus-labs/dedalus-c0e28b234478af75f61bcf42f5f32818d96ec93b1d8239fa0ff1c801c0e4b64f.yml). +Selected files are rebuilt from permitted fields and sent with their exact byte +size and SHA-256 digest. HTTP retries reuse one idempotency key. A successful +response means the report was accepted for delivery; it does not confirm that +support has received it yet. -### Running commands on a machine +## Staging package testing -`exec` is an alias for `executions`. Pass the executable and its arguments after -`--` to create an asynchronous execution: +The `CLI SDK CI` workflow builds a versioned package, installs it in a temporary +directory, runs the feedback tests against the installed package, and uploads the +`dedalus-cli-staging` artifact. The artifact contains a `.tgz` and its checksum +record. This workflow does not publish to npm or Homebrew. + +Staging packages are marked private and default to `https://staging.dcs.dedaluslabs.ai`. +Pass the staging URL explicitly when testing a downloaded package: ```sh -dedalus machines exec --machine-id "$MACHINE_ID" -- echo "hello world" +mkdir -p /tmp/dedalus-feedback-test +npm install --prefix /tmp/dedalus-feedback-test /absolute/path/to/dedalus-cli-VERSION.tgz +/tmp/dedalus-feedback-test/node_modules/.bin/dedalus doctor --json +/tmp/dedalus-feedback-test/node_modules/.bin/dedalus feedback "Staging smoke test" \ + --include-logs false --base-url https://staging.dcs.dedaluslabs.ai ``` -`create` is optional in this form. The existing JSON form also works: +Set `DEDALUS_API_KEY` to a staging credential before submitting. To reproduce CI +locally, run `pnpm install --frozen-lockfile`, `pnpm build`, and `pnpm test:package`. +Custom feedback code lives under `src/feedback` and is integrated on `scalar-next`. +Validate Scalar regeneration there before promoting a release. + +## Shell Completion + +`dedalus completion ` prints a completion script for bash, zsh, and fish. Add the matching line to your shell startup file to complete commands, subcommands, and flags with Tab. ```sh -dedalus machines exec create --machine-id "$MACHINE_ID" --command '["echo", "hello world"]' -dedalus machines exec retrieve --machine-id "$MACHINE_ID" --execution-id "$EXECUTION_ID" +# bash (~/.bashrc) +eval "$(dedalus completion bash)" + +# zsh (~/.zshrc) +eval "$(dedalus completion zsh)" + +# fish (~/.config/fish/config.fish) +dedalus completion fish | source ``` -Put CLI options such as `--cwd`, `--env`, `--stdin`, and `--timeout-ms` before -`--`. Arguments after it are sent literally, including spaces, leading dashes, -and `@` prefixes. Use either those arguments or `--command` in one request. +
-To interpret shell operators remotely, invoke a shell and quote its script: +## Manual Pages + +Installing the package globally also installs man pages. `man dedalus` lists every command, and each command has its own page named after the command with spaces and `:` replaced by `-`. ```sh -dedalus machines exec --machine-id "$MACHINE_ID" -- sh -c 'echo "hello world" && ls -la' +man dedalus +man dedalus-- ``` -An unquoted `&&` is interpreted by your local shell before the CLI runs. -Creating an execution returns an execution ID. Use `retrieve` to check its -status and `output` to read its output. The `list`, `events`, and `delete` -subcommands also work under `exec`. +
-### Environment variables +## Authentication -| Environment variable | Description | Required | Default value | -| -------------------- | --------------------------------------------- | -------- | ------------- | -| `DEDALUS_API_KEY` | Dedalus API key sent as Authorization Bearer. | no | `null` | -| `DEDALUS_X_API_KEY` | Dedalus API key sent as x-api-key header. | no | `null` | -| `DEDALUS_ORG_ID` | Organization ID header for all DCS requests. | no | `null` | +Pass credentials to the generated client constructor. Environment variables are read automatically when supported by the target runtime. -### Global flags +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `--api-key` | `string \| provider` | - | Dedalus API key or short-lived delegated access token in Authorization: Bearer . Defaults to DEDALUS_API_KEY. | +| `--x-api-key` | `string \| provider` | - | Dedalus API key. Alternative to Bearer token. Defaults to DEDALUS_X_API_KEY. | -- `--api-key` - Dedalus API key sent as Authorization Bearer. (can also be set with `DEDALUS_API_KEY` env var) -- `--x-api-key` - Dedalus API key sent as x-api-key header. (can also be set with `DEDALUS_X_API_KEY` env var) -- `--dedalus-org-id` - Organization ID header for all DCS requests. (can also be set with `DEDALUS_ORG_ID` env var) -- `--help` - Show command line usage -- `--debug` - Enable debug logging (includes HTTP request/response details) -- `--version`, `-v` - Show the CLI version -- `--base-url` - Use a custom API backend URL -- `--format` - Change the output format (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) -- `--format-error` - Change the output format for errors (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) -- `--transform` - Transform the data output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) -- `--transform-error` - Transform the error output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) +Declared schemes: -### Passing files as arguments +- `ApiKeyAuth` API key in header `x-api-key` +- `BearerAuth` bearer token -To pass files to your API, you can use the `@myfile.ext` syntax: +
-```bash -dedalus --arg @abe.jpg -``` +## Errors -Files can also be passed inside JSON or YAML blobs: +Failed requests print a structured error to standard error and exit with a status that identifies the failure class. The error body carries the API's own `message` plus a stable `code`, the HTTP `status`, the `requestId`, and — where one applies — an actionable `hint`. Usage errors (exit `2`) are reported as a plain message instead, since no request was made. Exit statuses: `0` success, `1` `error`, `2` `usage`, `10` `auth-failed`, `11` `not-found`, `12` `rate-limited`, `13` `client-error`, `14` `server-error`, `15` `connection-error`. -```bash -dedalus --arg '{image: "@abe.jpg"}' -# Equivalent: -dedalus < -```bash -dedalus --username '\@abe' -``` +## Client Options -#### Explicit encoding +Configure the generated client by setting any of these options when you create it. -For JSON endpoints, the CLI tool does filetype sniffing to determine whether the -file contents should be sent as a string literal (for plain text files) or as a -base64-encoded string literal (for binary files). If you need to explicitly send -the file as either plain text or base64-encoded data, you can use -`@file://myfile.txt` (for string encoding) or `@data://myfile.dat` (for -base64-encoding). Note that absolute paths will begin with `@file://` or -`@data://`, followed by a third `/` (for example, `@file:///tmp/file.txt`). +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `--base-url` | `` | - | Override the base URL for API requests. | +| `--timeout` | `` | - | Request timeout in milliseconds. | +| `--max-retries` | `` | - | Number of retries for retryable failures. | +| `--debug` | `flag` | - | Enable SDK debug logging. | -```bash -dedalus --arg @data://file.txt -``` +
-## Linking different Go SDK versions +## Retries and Timeouts -You can link the CLI against a different version of the Dedalus Go SDK -for development purposes using the `./scripts/link` script. +Generated clients support request timeouts and retry temporary failures such as network errors, 408, 409, 429, and 5xx responses. Retry delays honor `Retry-After` headers when present. Tune the retry and timeout client options shown above, or override them per request. -To link to a specific version from a repository (version can be a branch, -git tag, or commit hash): +
-```bash -./scripts/link github.com/org/repo@version -``` +## Pagination -To link to a local copy of the SDK: +Paginated commands fetch subsequent pages for you. Use `--max-items ` to cap the total number of items returned. -```bash -./scripts/link ../path/to/dedalus-go -``` +
+ +## Helpers + +- `--format ` — output format: `auto`, `json`, `jsonl`, `pretty`, `raw`, `toon`, or `yaml`. +- `--format-error ` — error output format: `auto`, `json`, `jsonl`, `pretty`, `raw`, `toon`, or `yaml`. +- `--format toon` — token-efficient structured output for AI agents; uniform lists collapse into one header plus a row per item, with a definitive item count. +- `--transform ` and `--transform-error ` — dot-path transform for data/error output. +- `--raw-output`, `-r` — print transformed string values without JSON quotes. +- `--max-items ` — bound iterator, streaming, and WebSocket command output. +- Errors carry a stable `code` and an actionable `hint` beside the API's own message, and each failure class exits with its own status: `1` `error`, `2` `usage`, `10` `auth-failed`, `11` `not-found`, `12` `rate-limited`, `13` `client-error`, `14` `server-error`, `15` `connection-error`. + +
+ +## Logging + +- Pass `--debug` to any command to enable SDK debug logging on stderr. + +
+ +## Requirements + +- Node.js 20 or newer — for the npm install only; the standalone binaries bundle their own runtime. + +Powered by Scalar. + + +### Automatic retry identity -If you run the link script without any arguments, it will default to `../dedalus-go`. +Client-generated idempotency keys use the API's 32-character UUIDv7 format. +Automatic retries reuse the original key. Independent submissions receive new +keys, and callers can still supply an explicit key when retrying a saved request. + diff --git a/SECURITY.md b/SECURITY.md index 8efb336..f3510bb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,26 +2,18 @@ ## Reporting Security Issues -This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. +This is a generated TypeScript CLI for Dedalus, produced by the Scalar SDK Generator (https://scalar.com). -To report a security issue, please contact the Stainless team at security@stainless.com. +If the issue is in the generated CLI itself — its runtime, authentication handling, path construction, or serialization — report it to the Scalar team at support@scalar.com. We will acknowledge the report, triage it for severity and impact, and communicate a timeline for a fix or an explanation if we decline it. ## Responsible Disclosure -We appreciate the efforts of security researchers and individuals who help us maintain the security of -SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible -disclosure practices by allowing us a reasonable amount of time to investigate and address the issue -before making any information public. +Please allow us reasonable time to investigate and address the issue before disclosing it publicly. Do not include credentials, tokens, or private API payloads in a public issue. -## Reporting Non-SDK Related Security Issues +## Reporting Non-CLI Security Issues -If you encounter security issues that are not directly related to SDKs but pertain to the services -or products provided by Dedalus, please follow the respective company's security reporting guidelines. - -### Dedalus Terms and Policies - -Please contact security@dedaluslabs.ai for any questions or concerns regarding the security of our services. +If the issue is in the Dedalus API or service rather than in this CLI, report it through the security process Dedalus has published. --- -Thank you for helping us keep the SDKs and systems they interact with secure. +Generated code validates emitted paths and escapes spec-derived strings, but OpenAPI documents should still be treated as untrusted input before generation. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..0babfdb --- /dev/null +++ b/SKILL.md @@ -0,0 +1,72 @@ +--- +name: dedalus-cli-sdk +description: "CLI SDK for Dedalus API. Use when writing CLI code that calls Dedalus API with the dedalus-cli package: installing it, constructing and authenticating the client, and calling API operations." +--- + +# Dedalus CLI SDK + +Generated CLI client for Dedalus API, published as `dedalus-cli`. Use the generated client instead of hand-writing HTTP requests. + +## Install + +```sh +# npm (requires Node.js) +npm install -g dedalus-cli + +# Homebrew — standalone binary, no Node.js required +brew install dedalus-labs/tap/dedalus + +# Direct download — standalone binary, no Node.js required +curl -fsSL "https://github.com/dedalus-labs/dedalus-cli/releases/latest/download/dedalus-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz" | tar xz +sudo mv dedalus /usr/local/bin/ + +# Windows — download and extract dedalus-windows-x64.zip, then add it to PATH +# https://github.com/dedalus-labs/dedalus-cli/releases/latest/download/dedalus-windows-x64.zip +``` + +## Client setup and authentication + +Provide credentials using the options below. Environment variables are read automatically when the target runtime supports them: + +- `--api-key` (env: `DEDALUS_API_KEY`) — Dedalus API key or short-lived delegated access token in Authorization: Bearer . +- `--x-api-key` (env: `DEDALUS_X_API_KEY`) — Dedalus API key. Alternative to Bearer token. + +## Calling operations + +```sh +dedalus [resource] [command] [flags] + +dedalus machines create \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --autosleep '300s' \ + --memory-mib '4096' \ + --storage-gib '10' \ + --vcpu '1' +``` + +Method names, parameter shapes, and response types are generated from the API description — do not guess them. Look up the exact call signature in [api.md](./api.md) before writing a call. + +## Pagination + +Paginated commands fetch subsequent pages for you. Use `--max-items ` to cap the total number of items returned. + +## Error handling + +Failed requests print a structured error to standard error and exit with a status that identifies the failure class. The error body carries the API's own `message` plus a stable `code`, the HTTP `status`, the `requestId`, and — where one applies — an actionable `hint`. Usage errors (exit `2`) are reported as a plain message instead, since no request was made. Exit statuses: `0` success, `1` `error`, `2` `usage`, `10` `auth-failed`, `11` `not-found`, `12` `rate-limited`, `13` `client-error`, `14` `server-error`, `15` `connection-error`. + +## Working with this SDK programmatically + +- Pass `--format toon` for token-efficient structured output: a uniform list collapses into one header plus a row per item, with a definitive `[N]` count. Use `--format json` when the output is fed to a JSON parser. +- Use `--max-items ` to bound paginated, streaming, and WebSocket commands before they fill the context, and `--transform ` to keep only the field you need. +- Endpoint commands never prompt, so they are safe to run non-interactively. Credentials come from the documented environment variables or their flags. `login` is meant for a human at a terminal, so do not drive it: a flow that reads a credential refuses a non-terminal stdin outright, and a browser or device flow waits for a person to finish signing in elsewhere before it gives up. Set the credential through its environment variable or flag instead. +- Branch on the exit status rather than on stderr text: `0` success, `1` `error`, `2` `usage`, `10` `auth-failed`, `11` `not-found`, `12` `rate-limited`, `13` `client-error`, `14` `server-error`, `15` `connection-error`. A failed request repeats its class on stderr as a stable `code`, with a `hint` when there is a concrete next step; exit `2` is a plain message with no structured body, because the command never ran. +- Run `dedalus --help` or `dedalus --help` to discover commands and flags, and `man dedalus` for the full reference. + +## Requirements + +- Node.js 20 or newer — for the npm install only; the standalone binaries bundle their own runtime. + +## Reference files + +- [README.md](./README.md) — full feature tour: client options, retries and timeouts, logging. +- [api.md](./api.md) — complete catalogue of every operation with request and response types. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..f9cf7dd --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,100 @@ +# Versioning + +This SDK is configured with the `manual` versioning policy. + +- `manual`: package versions are set explicitly before release. +- `semver`: releases should follow semantic versioning based on API and SDK surface changes. +- `calendar`: releases should use a calendar-derived version chosen by the release workflow or maintainer. + +## Branches and releases + +This repository follows a three-branch flow, managed by the Scalar platform together +with the generated workflows (which need only the default `GITHUB_TOKEN` — no extra +token required): + +- **`scalar-generated`** — pristine generator output. Pushed by the Scalar platform; do + not commit here. +- **`scalar-next`** — generated output merged with this repository's custom code. Commit + your customizations here (directly or via PRs). The Scalar platform merges each + regeneration into this branch and keeps the release PR up to date; merge conflicts + arrive as a PR from `scalar-merge-conflict` for you to resolve. +- **Default branch** — seeded from the first generated snapshot, then only ever receives + released states, each one the merge of a release PR. + +Release PRs are opened by the Scalar platform from `scalar-next` against the default +branch — so the PR diff shows the full pending release — and are versioned from +[Conventional Commits](https://www.conventionalcommits.org). Merging a release PR tags the +release, publishes it, and syncs the version bump and changelog back to `scalar-next`. +Pre-1.0, breaking changes bump the minor version. + +### Choosing an exact version + +To release a specific version — `1.0.0`, a hotfix number, anything the commit history would +not have picked — **edit the release PR title** to the version you want: + +```text +release: 1.0.0 +``` + +The `Release PR version` check turns red as soon as you save, because the version in the +title no longer matches the version committed in the PR. The Scalar platform then re-renders +the release PR at your version (changelog, manifest, and every version-bearing file), the +title comes back as `release: 1.0.0`, and the check turns green. **Wait for it to be green +before merging** — merging in between would tag a release whose own files still carry the +old version. + +The git-native equivalent, if you would rather not touch the PR: push an empty commit with a +`Release-As` footer to `scalar-next`. This is exactly what the title edit does for you. + +```sh +git commit --allow-empty -m "chore: release 1.0.0" -m "Release-As: 1.0.0" +``` + +### When a release PR does not merge cleanly + +Nothing is ever force-pushed automatically: a release PR that conflicts with the default +branch simply cannot be merged, and GitHub disables its merge button. The two causes have +different fixes: + +- **The default branch received direct commits** (for example a hotfix) that are not in + `scalar-next`. Land those commits on `scalar-next` (merge the default branch into it, or + cherry-pick), and the refreshed release PR merges cleanly again. Do not force-push — + that would discard the direct commits. +- **The repository was adopted with pre-existing content**, so the default branch shares + no history with `scalar-next`. The Scalar platform adds a checkbox to the release PR + description offering to replace the default branch with this release; checking it + authorizes the platform to force-push the released state over the old content. This is + destructive for anything on the default branch that never reached `scalar-next`, which + is why it requires that explicit opt-in. + +### Repository prerequisites + +- Branch protection on `scalar-next` and the default branch must allow the Scalar + platform and the `github-actions` bot to push (or be left unprotected); the default + branch only ever advances by merging release PRs, and `scalar-next` receives each + released state back from the release workflow. +- **`scalar-next` must not enable "Require linear history."** The Scalar platform merges each + regeneration into it, and merges a resolved conflict back the same way; the release sync + adds another merge whenever a release PR is squash- or rebase-merged. That setting rejects + all of them. +- No Actions settings changes are required: the generated workflows declare their own + permissions and never create pull requests. +- If this package publishes through OIDC trusted publishing (for example PyPI or npm), + register the trusted publisher on the registry against the **`release-please.yml`** + workflow filename. Merging a release PR publishes from the `publish` job inside that + same workflow run (checked out at the released tag), so the automated path's OIDC + claims name that file — and, because nothing is dispatched, releasing works from any + release branch, not only the repository default branch. `sdk-release.yml` exists for + manual re-publishes at an existing tag; register it as an additional trusted publisher + only if you use it. If the publish job is configured with a deployment environment, + include that environment in the registration too. + +### Recommended, not required + +Releases work without this; it closes a window where one can go out at the wrong version. + +- **If this repository uses auto-merge, make the `Release PR version` check a required status + check.** Auto-merge merges as soon as the checks that are *required* go green, with no + human present — so a check that is not required but still red at that moment stops + nothing, which is exactly the window between retitling a release PR and the platform + re-rendering it. diff --git a/api.md b/api.md new file mode 100644 index 0000000..7103b9b --- /dev/null +++ b/api.md @@ -0,0 +1,266 @@ +# Dedalus CLI API + +Complete reference of every operation, grouped by resource. See [the README](./README.md) for usage and configuration. + +## Contents + +- [`Machines`](#machines) + - [List machines](#list-machines) + - [Create machine](#create-machine) + - [Get machine](#get-machine) + - [Update machine](#update-machine) + - [Destroy machine](#destroy-machine) + - [Sleep a running machine](#sleep-a-running-machine) + - [Wake a sleeping machine](#wake-a-sleeping-machine) + - [Reboot a machine with fresh memory](#reboot-a-machine-with-fresh-memory) + - [`Machines Ssh`](#machines-ssh) + - [List SSH sessions](#list-ssh-sessions) + - [Create SSH session](#create-ssh-session) + - [Get SSH session](#get-ssh-session) + - [Delete SSH session](#delete-ssh-session) + - [`Machines Executions`](#machines-executions) + - [List executions](#list-executions) + - [Create execution](#create-execution) + - [Get execution](#get-execution) + - [Delete execution](#delete-execution) + - [Get execution output](#get-execution-output) + - [List execution events](#list-execution-events) + - [`Machines Executions Logs`](#machines-executions-logs) + - [Get execution log status](#get-execution-log-status) + - [Reauthorize execution log publication](#reauthorize-execution-log-publication) + - [Create execution log read token](#create-execution-log-read-token) + - [`Machines Autoresizing`](#machines-autoresizing) + - [Read this machine's RAM autoresizing settings](#read-this-machines-ram-autoresizing-settings) + - [Set this machine's RAM autoresizing settings](#set-this-machines-ram-autoresizing-settings) +- [`Organization`](#organization) + - [`Organization Autoresizing`](#organization-autoresizing) + - [Read organization RAM autoresizing policy](#read-organization-ram-autoresizing-policy) + - [Set organization RAM autoresizing policy](#set-organization-ram-autoresizing-policy) + +## `Machines` + +### List machines + +```sh +dedalus machines list --x-api-key "$DEDALUS_X_API_KEY" --max-items 10 +``` + +### Create machine + +```sh +dedalus machines create \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --autosleep '300s' \ + --memory-mib '4096' \ + --storage-gib '10' \ + --vcpu '1' +``` + +### Get machine + +```sh +dedalus machines retrieve \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' +``` + +### Update machine + +```sh +dedalus machines update \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' +``` + +### Destroy machine + +```sh +dedalus machines delete \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' +``` + +### Sleep a running machine + +```sh +dedalus machines sleep \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' +``` + +### Wake a sleeping machine + +```sh +dedalus machines wake \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' +``` + +### Reboot a machine with fresh memory + +Checkpoints files and replaces the runtime. The machine ID and filesystem are preserved. RAM, processes, and temporary mounts are cleared. Poll the machine until its phase is running. Retry the same Idempotency-Key after a lost response. + +```sh +dedalus machines reboot \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' +``` + +### `Machines Ssh` + +#### List SSH sessions + +```sh +dedalus machines:ssh list \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --max-items 10 +``` + +#### Create SSH session + +```sh +dedalus machines:ssh create \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --public-key '' +``` + +#### Get SSH session + +```sh +dedalus machines:ssh retrieve \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --session-id 'session_id' +``` + +#### Delete SSH session + +```sh +dedalus machines:ssh delete \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --session-id 'session_id' +``` + +### `Machines Executions` + +#### List executions + +```sh +dedalus machines:executions list \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --max-items 10 +``` + +#### Create execution + +```sh +dedalus machines:executions create \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --command '[""]' +``` + +#### Get execution + +```sh +dedalus machines:executions retrieve \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --execution-id 'execution_id' +``` + +#### Delete execution + +```sh +dedalus machines:executions delete \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --execution-id 'execution_id' +``` + +#### Get execution output + +```sh +dedalus machines:executions output \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --execution-id 'execution_id' +``` + +#### List execution events + +```sh +dedalus machines:executions events \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --execution-id 'execution_id' \ + --max-items 10 +``` + +#### `Machines Executions Logs` + +##### Get execution log status + +```sh +dedalus machines:executions:logs retrieve \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --execution-id 'execution_id' +``` + +##### Reauthorize execution log publication + +```sh +dedalus machines:executions:logs reauthorize \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --execution-id 'execution_id' +``` + +##### Create execution log read token + +```sh +dedalus machines:executions:logs create-token \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --execution-id 'execution_id' +``` + +### `Machines Autoresizing` + +#### Read this machine's RAM autoresizing settings + +```sh +dedalus machines:autoresizing retrieve \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' +``` + +#### Set this machine's RAM autoresizing settings + +```sh +dedalus machines:autoresizing update \ + --x-api-key "$DEDALUS_X_API_KEY" \ + --machine-id '017f22e2-79b0-7cc3-98c4-dc0c0c07398f' \ + --enabled +``` + +## `Organization` + +### `Organization Autoresizing` + +#### Read organization RAM autoresizing policy + +```sh +dedalus organization:autoresizing retrieve --x-api-key "$DEDALUS_X_API_KEY" +``` + +#### Set organization RAM autoresizing policy + +```sh +dedalus organization:autoresizing update --x-api-key "$DEDALUS_X_API_KEY" --enabled +``` diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..b2ce844 --- /dev/null +++ b/biome.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json", + "root": true, + "files": { + "includes": [ + "**/*.{js,mjs,cjs,ts,json}", + "!dist", + "!node_modules", + "!coverage", + "!scalar-sdk.manifest.json", + "!openapi.augmented.json" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 110, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "enabled": true, + "quoteStyle": "single", + "quoteProperties": "asNeeded", + "trailingCommas": "all", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSpacing": true, + "bracketSameLine": false + } + }, + "linter": { + "enabled": false + }, + "assist": { + "enabled": false + } +} diff --git a/ci/sdk-ci.ts b/ci/sdk-ci.ts new file mode 100644 index 0000000..cd536fd --- /dev/null +++ b/ci/sdk-ci.ts @@ -0,0 +1,57 @@ +import { command, workflow } from '@dedalus-labs/hollywood'; + +export const sdkCI = workflow({ + name: 'CLI SDK CI', + on: { push: {}, pull_request: {} }, + permissions: { contents: 'read' }, + jobs: { + verify: { + 'runs-on': 'blacksmith-4vcpu-ubuntu-2404', + 'timeout-minutes': 15, + steps: [ + { uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' }, + { + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020', + with: { 'node-version': '24' }, + }, + { + uses: 'pnpm/action-setup@a15d269cd4658e1107c09f1fabf4cbd7bd1f308a', + with: { version: '10.20.0' }, + }, + { + name: 'Install dependencies', + run: command({ + file: 'pnpm', + args: ['install', '--frozen-lockfile', '--ignore-scripts'], + }), + }, + { + name: 'Generate workflows', + run: command({ file: 'pnpm', args: ['run', 'ci:generate'] }), + }, + { + name: 'Check generated workflows', + run: command({ + file: 'git', + args: ['diff', '--exit-code', '--', '.github/workflows/sdk-ci.yml'], + }), + }, + { name: 'Build CLI', run: command({ file: 'pnpm', args: ['run', 'build'] }) }, + { + name: 'Install and test staging package', + run: command({ file: 'node', args: ['scripts/verify-staging-package.mjs'] }), + }, + { + name: 'Upload tested staging package', + uses: 'actions/upload-artifact@v7', + with: { + name: 'dedalus-cli-staging', + path: 'artifacts/*', + 'if-no-files-found': 'error', + 'retention-days': 7, + }, + }, + ], + }, + }, +}); diff --git a/cmd/dedalus/main.go b/cmd/dedalus/main.go deleted file mode 100644 index 2ccba35..0000000 --- a/cmd/dedalus/main.go +++ /dev/null @@ -1,83 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package main - -import ( - "context" - "errors" - "fmt" - "net/http" - "os" - "slices" - - "github.com/dedalus-labs/dedalus-cli/pkg/cmd" - "github.com/dedalus-labs/dedalus-go" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -func main() { - app := cmd.Command - ctx := context.Background() - - if slices.Contains(os.Args, "__complete") { - prepareForAutocomplete(app) - } - - if baseURL, ok := os.LookupEnv("DEDALUS_BASE_URL"); ok { - if err := cmd.ValidateBaseURL(baseURL, "DEDALUS_BASE_URL"); err != nil { - fmt.Fprintf(os.Stderr, "%s\n", err.Error()) - os.Exit(1) - } - } - - updated, err := cmd.MaybeRunStartupUpdate(ctx, os.Args, os.Stdin, os.Stdout, os.Stderr) - if err == nil && updated { - return - } - - if err == nil { - err = app.Run(ctx, os.Args) - } - if err != nil { - exitCode := 1 - - // Check if error has a custom exit code - if exitErr, ok := err.(cli.ExitCoder); ok { - exitCode = exitErr.ExitCode() - } - - var apierr *dedalus.Error - if errors.As(err, &apierr) { - fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode)) - format := app.String("format-error") - json := gjson.Parse(apierr.RawJSON()) - show_err := cmd.ShowJSON(json, cmd.ShowJSONOpts{ - ExplicitFormat: app.IsSet("format-error"), - Format: format, - Title: "Error", - Transform: app.String("transform-error"), - }) - if show_err != nil { - // Just print the original error: - fmt.Fprintf(os.Stderr, "%s\n", err.Error()) - } - } else { - if cmd.CommandErrorBuffer.Len() > 0 { - os.Stderr.Write(cmd.CommandErrorBuffer.Bytes()) - } else { - fmt.Fprintf(os.Stderr, "%s\n", err.Error()) - } - } - os.Exit(exitCode) - } -} - -func prepareForAutocomplete(cmd *cli.Command) { - // urfave/cli does not handle flag completions and will print an error if we inspect a command with invalid flags. - // This skips that sort of validation - cmd.SkipFlagParsing = true - for _, child := range cmd.Commands { - prepareForAutocomplete(child) - } -} diff --git a/examples/.gitkeep b/examples/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/go.mod b/go.mod deleted file mode 100644 index eb005e2..0000000 --- a/go.mod +++ /dev/null @@ -1,48 +0,0 @@ -module github.com/dedalus-labs/dedalus-cli - -go 1.25.0 - -require ( - github.com/charmbracelet/bubbles v0.21.0 - github.com/charmbracelet/bubbletea v1.3.6 - github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/x/term v0.2.1 - github.com/dedalus-labs/dedalus-go v0.4.0 - github.com/goccy/go-yaml v1.18.0 - github.com/itchyny/json2yaml v0.1.4 - github.com/muesli/reflow v0.3.0 - github.com/stretchr/testify v1.10.0 - github.com/tidwall/gjson v1.18.0 - github.com/tidwall/pretty v1.2.1 - github.com/urfave/cli-docs/v3 v3.0.0-alpha6 - github.com/urfave/cli/v3 v3.3.2 - golang.org/x/crypto v0.49.0 - golang.org/x/sys v0.42.0 -) - -require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/text v0.35.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 1beb577..0000000 --- a/go.sum +++ /dev/null @@ -1,95 +0,0 @@ -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dedalus-labs/dedalus-go v0.4.0 h1:4rrMfcXH48Y8d+mfIheZ8HnlqnpeRrB0iZnkZWWH2lc= -github.com/dedalus-labs/dedalus-go v0.4.0/go.mod h1:tcwRinHcyjTtLhXOiHaCPEHmm9tPwsAvagi5qiXaxDs= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8= -github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/urfave/cli-docs/v3 v3.0.0-alpha6 h1:w/l/N0xw1rO/aHRIGXJ0lDwwYFOzilup1qGvIytP3BI= -github.com/urfave/cli-docs/v3 v3.0.0-alpha6/go.mod h1:p7Z4lg8FSTrPB9GTaNyTrK3ygffHZcK3w0cU2VE+mzU= -github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o= -github.com/urfave/cli/v3 v3.3.2/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go deleted file mode 100644 index 857fe14..0000000 --- a/internal/apiform/encoder.go +++ /dev/null @@ -1,236 +0,0 @@ -package apiform - -import ( - "fmt" - "io" - "mime/multipart" - "net/textproto" - "path" - "reflect" - "sort" - "strconv" - "strings" -) - -// Marshal encodes a value as multipart form data using default settings -func Marshal(value any, writer *multipart.Writer) error { - e := &encoder{ - format: FormatRepeat, - } - return e.marshal(value, writer) -} - -// MarshalWithSettings encodes a value with custom array format -func MarshalWithSettings(value any, writer *multipart.Writer, arrayFormat FormFormat) error { - e := &encoder{ - format: arrayFormat, - } - return e.marshal(value, writer) -} - -type encoder struct { - format FormFormat -} - -func (e *encoder) marshal(value any, writer *multipart.Writer) error { - val := reflect.ValueOf(value) - if !val.IsValid() { - return nil - } - return e.encodeValue("", val, writer) -} - -func (e *encoder) encodeValue(key string, val reflect.Value, writer *multipart.Writer) error { - if !val.IsValid() { - return writer.WriteField(key, "") - } - - t := val.Type() - - if t.Implements(reflect.TypeOf((*io.Reader)(nil)).Elem()) { - return e.encodeReader(key, val, writer) - } - - switch t.Kind() { - case reflect.Pointer: - if val.IsNil() || !val.IsValid() { - return writer.WriteField(key, "") - } - return e.encodeValue(key, val.Elem(), writer) - - case reflect.Slice, reflect.Array: - return e.encodeArray(key, val, writer) - - case reflect.Map: - return e.encodeMap(key, val, writer) - - case reflect.Interface: - if val.IsNil() { - return writer.WriteField(key, "") - } - return e.encodeValue(key, val.Elem(), writer) - - case reflect.String: - return writer.WriteField(key, val.String()) - - case reflect.Bool: - if val.Bool() { - return writer.WriteField(key, "true") - } - return writer.WriteField(key, "false") - - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return writer.WriteField(key, strconv.FormatInt(val.Int(), 10)) - - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return writer.WriteField(key, strconv.FormatUint(val.Uint(), 10)) - - case reflect.Float32: - return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 32)) - - case reflect.Float64: - return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 64)) - - default: - return fmt.Errorf("unknown type: %s", t.String()) - } -} - -func (e *encoder) encodeArray(key string, val reflect.Value, writer *multipart.Writer) error { - if e.format == FormatComma { - var values []string - for i := 0; i < val.Len(); i++ { - item := val.Index(i) - if (item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface) && item.IsNil() { - // Null values are sent as an empty string - values = append(values, "") - continue - } - // If item is an interface, reduce it to the concrete type - if item.Kind() == reflect.Interface { - item = item.Elem() - } - var strValue string - switch item.Kind() { - case reflect.String: - strValue = item.String() - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - strValue = strconv.FormatInt(item.Int(), 10) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - strValue = strconv.FormatUint(item.Uint(), 10) - case reflect.Float32, reflect.Float64: - strValue = strconv.FormatFloat(item.Float(), 'f', -1, 64) - case reflect.Bool: - strValue = strconv.FormatBool(item.Bool()) - default: - return fmt.Errorf("comma format not supported for complex array elements") - } - values = append(values, strValue) - } - return writer.WriteField(key, strings.Join(values, ",")) - } - - for i := 0; i < val.Len(); i++ { - var formattedKey string - switch e.format { - case FormatRepeat: - formattedKey = key - case FormatBrackets: - formattedKey = key + "[]" - case FormatIndicesDots: - if key == "" { - formattedKey = strconv.Itoa(i) - } else { - formattedKey = key + "." + strconv.Itoa(i) - } - case FormatIndicesBrackets: - if key == "" { - formattedKey = strconv.Itoa(i) - } else { - formattedKey = key + "[" + strconv.Itoa(i) + "]" - } - default: - return fmt.Errorf("apiform: unsupported array format") - } - - if err := e.encodeValue(formattedKey, val.Index(i), writer); err != nil { - return err - } - } - return nil -} - -var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") - -func escapeQuotes(s string) string { - return quoteEscaper.Replace(s) -} - -func (e *encoder) encodeReader(key string, val reflect.Value, writer *multipart.Writer) error { - reader, ok := val.Convert(reflect.TypeOf((*io.Reader)(nil)).Elem()).Interface().(io.Reader) - if !ok { - return nil - } - - // Set defaults - filename := "anonymous_file" - contentType := "application/octet-stream" - - // Get filename if available - if named, ok := reader.(interface{ Filename() string }); ok { - filename = named.Filename() - } else if named, ok := reader.(interface{ Name() string }); ok { - filename = path.Base(named.Name()) - } - - // Get content type if available - if typed, ok := reader.(interface{ ContentType() string }); ok { - contentType = typed.ContentType() - } - - h := make(textproto.MIMEHeader) - h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, - escapeQuotes(key), escapeQuotes(filename))) - h.Set("Content-Type", contentType) - - filewriter, err := writer.CreatePart(h) - if err != nil { - return err - } - _, err = io.Copy(filewriter, reader) - return err -} - -func (e *encoder) encodeMap(key string, val reflect.Value, writer *multipart.Writer) error { - type mapPair struct { - key string - value reflect.Value - } - - if key != "" { - key = key + "." - } - - // Collect and sort map entries for deterministic output - pairs := []mapPair{} - iter := val.MapRange() - for iter.Next() { - if iter.Key().Type().Kind() != reflect.String { - return fmt.Errorf("cannot encode a map with a non string key") - } - pairs = append(pairs, mapPair{key: iter.Key().String(), value: iter.Value()}) - } - - sort.Slice(pairs, func(i, j int) bool { - return pairs[i].key < pairs[j].key - }) - - // Process sorted pairs - for _, p := range pairs { - if err := e.encodeValue(key+p.key, p.value, writer); err != nil { - return err - } - } - - return nil -} diff --git a/internal/apiform/form.go b/internal/apiform/form.go deleted file mode 100644 index 024de27..0000000 --- a/internal/apiform/form.go +++ /dev/null @@ -1,20 +0,0 @@ -package apiform - -type Marshaler interface { - MarshalMultipart() ([]byte, string, error) -} - -type FormFormat int - -const ( - // FormatRepeat represents arrays as repeated keys with the same value - FormatRepeat FormFormat = iota - // Comma-separated values 1,2,3 - FormatComma - // FormatBrackets uses the key[] notation for arrays - FormatBrackets - // FormatIndicesDots uses key.0, key.1, etc. notation - FormatIndicesDots - // FormatIndicesBrackets uses key[0], key[1], etc. notation - FormatIndicesBrackets -) diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go deleted file mode 100644 index f68cfd1..0000000 --- a/internal/apiform/form_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package apiform - -import ( - "bytes" - "mime/multipart" - "testing" -) - -// Define test cases -var tests = map[string]struct { - value any - format FormFormat - expected string -}{ - "nil": { - value: nil, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n\r\n--xxx--\r\n", - }, - "string": { - value: "hello", - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\nhello\r\n--xxx--\r\n", - }, - "int": { - value: 42, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n42\r\n--xxx--\r\n", - }, - "float": { - value: 3.14, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n3.14\r\n--xxx--\r\n", - }, - "bool": { - value: true, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\ntrue\r\n--xxx--\r\n", - }, - "empty slice": { - value: []string{}, - expected: "\r\n--xxx--\r\n", - }, - "nil slice": { - value: []string(nil), - expected: "\r\n--xxx--\r\n", - }, - "slice with dot indices": { - value: []string{"a", "b", "c"}, - format: FormatIndicesDots, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.0\"\r\n\r\na\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.1\"\r\n\r\nb\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.2\"\r\n\r\nc\r\n--xxx--\r\n", - }, - "slice with bracket indices": { - value: []int{10, 20, 30}, - format: FormatIndicesBrackets, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo[0]\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo[1]\"\r\n\r\n20\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo[2]\"\r\n\r\n30\r\n--xxx--\r\n", - }, - "slice with repeat": { - value: []int{10, 20, 30}, - format: FormatRepeat, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n20\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n30\r\n--xxx--\r\n", - }, - "slice with commas": { - value: []int{10, 20, 30}, - format: FormatComma, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n10,20,30\r\n--xxx--\r\n", - }, - "empty map": { - value: map[string]any{}, - expected: "\r\n--xxx--\r\n", - }, - "nil map": { - value: map[string]any(nil), - expected: "\r\n--xxx--\r\n", - }, - "map": { - value: map[string]any{"key1": "value1", "key2": "value2"}, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.key1\"\r\n\r\nvalue1\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.key2\"\r\n\r\nvalue2\r\n--xxx--\r\n", - }, - "nested_map": { - value: map[string]any{"outer": map[string]int{"inner1": 10, "inner2": 20}}, - format: FormatIndicesDots, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.outer.inner1\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.outer.inner2\"\r\n\r\n20\r\n--xxx--\r\n", - }, - "mixed_map": { - value: map[string]any{"name": "John", "ages": []int{25, 30, 35}}, - format: FormatIndicesDots, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.0\"\r\n\r\n25\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.1\"\r\n\r\n30\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.2\"\r\n\r\n35\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.name\"\r\n\r\nJohn\r\n--xxx--\r\n", - }, -} - -func TestEncode(t *testing.T) { - t.Parallel() - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - buf := bytes.NewBuffer(nil) - writer := multipart.NewWriter(buf) - writer.SetBoundary("xxx") - - form := map[string]any{"foo": test.value} - err := MarshalWithSettings(form, writer, test.format) - if err != nil { - t.Errorf("serialization of %v failed with error %v", test.value, err) - } - err = writer.Close() - if err != nil { - t.Errorf("serialization of %v failed with error %v", test.value, err) - } - result := buf.String() - if result != test.expected { - t.Errorf("expected %+#v to serialize to:\n\t%q\nbut got:\n\t%q", test.value, test.expected, result) - } - }) - } -} diff --git a/internal/apiquery/encoder.go b/internal/apiquery/encoder.go deleted file mode 100644 index 0d09dee..0000000 --- a/internal/apiquery/encoder.go +++ /dev/null @@ -1,166 +0,0 @@ -package apiquery - -import ( - "fmt" - "reflect" - "strconv" - "strings" -) - -type encoder struct { - settings QuerySettings -} - -type Pair struct { - key string - value string -} - -func (e *encoder) Encode(key string, value reflect.Value) ([]Pair, error) { - t := value.Type() - switch t.Kind() { - case reflect.Pointer: - if value.IsNil() || !value.IsValid() { - return []Pair{{key, ""}}, nil - } - return e.Encode(key, value.Elem()) - - case reflect.Array, reflect.Slice: - return e.encodeArray(key, value) - - case reflect.Map: - return e.encodeMap(key, value) - - case reflect.Interface: - if !value.Elem().IsValid() { - return []Pair{{key, ""}}, nil - } - return e.Encode(key, value.Elem()) - - default: - return e.encodePrimitive(key, value) - } -} - -func (e *encoder) encodeMap(key string, value reflect.Value) ([]Pair, error) { - var pairs []Pair - iter := value.MapRange() - for iter.Next() { - subkey := iter.Key().String() - keyPath := subkey - if len(key) > 0 { - if e.settings.NestedFormat == NestedQueryFormatDots { - keyPath = fmt.Sprintf("%s.%s", key, subkey) - } else { - keyPath = fmt.Sprintf("%s[%s]", key, subkey) - } - } - - subpairs, err := e.Encode(keyPath, iter.Value()) - if err != nil { - return nil, err - } - pairs = append(pairs, subpairs...) - } - return pairs, nil -} - -func (e *encoder) encodeArray(key string, value reflect.Value) ([]Pair, error) { - switch e.settings.ArrayFormat { - case ArrayQueryFormatComma: - elements := []string{} - for i := 0; i < value.Len(); i++ { - innerPairs, err := e.Encode("", value.Index(i)) - if err != nil { - return nil, err - } - for _, pair := range innerPairs { - elements = append(elements, pair.value) - } - } - return []Pair{{key, strings.Join(elements, ",")}}, nil - - case ArrayQueryFormatRepeat: - var pairs []Pair - for i := 0; i < value.Len(); i++ { - subpairs, err := e.Encode(key, value.Index(i)) - if err != nil { - return nil, err - } - pairs = append(pairs, subpairs...) - } - return pairs, nil - - case ArrayQueryFormatIndices: - var pairs []Pair - for i := 0; i < value.Len(); i++ { - subpairs, err := e.Encode(fmt.Sprintf("%s[%d]", key, i), value.Index(i)) - if err != nil { - return nil, err - } - pairs = append(pairs, subpairs...) - } - return pairs, nil - - case ArrayQueryFormatBrackets: - var pairs []Pair - for i := 0; i < value.Len(); i++ { - subpairs, err := e.Encode(key+"[]", value.Index(i)) - if err != nil { - return nil, err - } - pairs = append(pairs, subpairs...) - } - return pairs, nil - - default: - panic(fmt.Sprintf("Unknown ArrayFormat value: %d", e.settings.ArrayFormat)) - } -} - -func (e *encoder) encodePrimitive(key string, value reflect.Value) ([]Pair, error) { - switch value.Kind() { - case reflect.Pointer: - if !value.IsValid() || value.IsNil() { - return nil, nil - } - return e.encodePrimitive(key, value.Elem()) - - case reflect.String: - return []Pair{{key, value.String()}}, nil - - case reflect.Bool: - if value.Bool() { - return []Pair{{key, "true"}}, nil - } - return []Pair{{key, "false"}}, nil - - case reflect.Int, reflect.Int16, reflect.Int32, reflect.Int64: - return []Pair{{key, strconv.FormatInt(value.Int(), 10)}}, nil - - case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return []Pair{{key, strconv.FormatUint(value.Uint(), 10)}}, nil - - case reflect.Float32, reflect.Float64: - return []Pair{{key, strconv.FormatFloat(value.Float(), 'f', -1, 64)}}, nil - - default: - return nil, nil - } -} - -func (e *encoder) encodeField(key string, value reflect.Value) ([]Pair, error) { - present := value.FieldByName("Present") - if !present.Bool() { - return nil, nil - } - null := value.FieldByName("Null") - if null.Bool() { - return nil, fmt.Errorf("apiquery: field cannot be null") - } - raw := value.FieldByName("Raw") - if !raw.IsNil() { - return e.Encode(key, raw) - } - return e.Encode(key, value.FieldByName("Value")) -} diff --git a/internal/apiquery/query.go b/internal/apiquery/query.go deleted file mode 100644 index fd07a2f..0000000 --- a/internal/apiquery/query.go +++ /dev/null @@ -1,53 +0,0 @@ -package apiquery - -import ( - "net/url" - "reflect" -) - -func MarshalWithSettings(value any, settings QuerySettings) (url.Values, error) { - val := reflect.ValueOf(value) - if !val.IsValid() { - return nil, nil - } - - e := encoder{settings} - pairs, err := e.Encode("", val) - if err != nil { - return nil, err - } - - kv := url.Values{} - for _, pair := range pairs { - kv.Add(pair.key, pair.value) - } - return kv, nil -} -func Marshal(value any) (url.Values, error) { - return MarshalWithSettings(value, QuerySettings{}) -} - -type Queryer interface { - URLQuery() (url.Values, error) -} - -type NestedQueryFormat int - -const ( - NestedQueryFormatBrackets NestedQueryFormat = iota - NestedQueryFormatDots -) - -type ArrayQueryFormat int - -const ( - ArrayQueryFormatComma ArrayQueryFormat = iota - ArrayQueryFormatRepeat - ArrayQueryFormatIndices - ArrayQueryFormatBrackets -) - -type QuerySettings struct { - NestedFormat NestedQueryFormat - ArrayFormat ArrayQueryFormat -} diff --git a/internal/apiquery/query_test.go b/internal/apiquery/query_test.go deleted file mode 100644 index 3791ec9..0000000 --- a/internal/apiquery/query_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package apiquery - -import ( - "net/url" - "testing" -) - -func TestEncode(t *testing.T) { - t.Parallel() - - tests := map[string]struct { - val any - settings QuerySettings - enc string - }{ - "null": { - val: nil, - enc: "query=", - }, - "string": { - val: "hello world", - enc: "query=hello world", - }, - "int": { - val: 42, - enc: "query=42", - }, - "float": { - val: 3.14, - enc: "query=3.14", - }, - "bool": { - val: true, - enc: "query=true", - }, - "empty_slice": { - val: []any{}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, - enc: "query=", - }, - "nil_slice": { - val: []any(nil), - settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, - enc: "query=", - }, - "slice_of_ints": { - val: []any{10, 20, 30}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, - enc: "query=10,20,30", - }, - "slice_of_ints_repeat": { - val: []any{10, 20, 30}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatRepeat}, - enc: "query=10&query=20&query=30", - }, - "slice_of_ints_indices": { - val: []any{10, 20, 30}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatIndices}, - enc: "query[0]=10&query[1]=20&query[2]=30", - }, - "slice_of_ints_brackets": { - val: []any{10, 20, 30}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatBrackets}, - enc: "query[]=10&query[]=20&query[]=30", - }, - "slice_of_strings": { - val: []any{"a", "b", "c"}, - settings: QuerySettings{}, - enc: "query=a,b,c", - }, - "empty_map": { - val: map[string]any{}, - settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, - enc: "", - }, - "nil_map": { - val: map[string]any(nil), - settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, - enc: "", - }, - "map_string_to_int_brackets": { - val: map[string]any{"one": 1, "two": 2}, - settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, - enc: "query[one]=1&query[two]=2", - }, - "map_string_to_int_dots": { - val: map[string]any{"one": 1, "two": 2}, - settings: QuerySettings{NestedFormat: NestedQueryFormatDots}, - enc: "query.one=1&query.two=2", - }, - "map_string_to_slice": { - val: map[string][]any{"nums": {10, 20, 30}}, - settings: QuerySettings{}, - enc: "query[nums]=10,20,30", - }, - "map_string_to_slice_repeat_dots": { - val: map[string][]any{"nums": {10, 20, 30}}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatRepeat, NestedFormat: NestedQueryFormatDots}, - enc: "query.nums=10&query.nums=20&query.nums=30", - }, - "map_with_empties": { - val: map[string]any{ - "empty-array": []any{}, - "nil-array": []any(nil), - "null": nil, - }, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma, NestedFormat: NestedQueryFormatDots}, - enc: "query.empty-array=&query.nil-array=&query.null=", - }, - "nested_map": { - val: map[string]map[string]any{"outer": {"inner": 42}}, - settings: QuerySettings{}, - enc: "query[outer][inner]=42", - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - query := map[string]any{"query": test.val} - values, err := MarshalWithSettings(query, test.settings) - if err != nil { - t.Fatalf("failed to marshal url %s", err) - } - str, _ := url.QueryUnescape(values.Encode()) - if str != test.enc { - t.Fatalf("expected %+#v to serialize to:\n\t%q\nbut got:\n\t%q", test.val, test.enc, str) - } - }) - } -} diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go deleted file mode 100644 index 97fe1a8..0000000 --- a/internal/autocomplete/autocomplete.go +++ /dev/null @@ -1,361 +0,0 @@ -package autocomplete - -import ( - "context" - "embed" - "fmt" - "os" - "slices" - "strings" - - "github.com/urfave/cli/v3" -) - -type CompletionStyle string - -const ( - CompletionStyleZsh CompletionStyle = "zsh" - CompletionStyleBash CompletionStyle = "bash" - CompletionStylePowershell CompletionStyle = "pwsh" - CompletionStyleFish CompletionStyle = "fish" -) - -type renderCompletion func(cmd *cli.Command, appName string) (string, error) - -var ( - //go:embed shellscripts - autoCompleteFS embed.FS - - shellCompletions = map[CompletionStyle]renderCompletion{ - "bash": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/bash_autocomplete.bash") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - "fish": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/fish_autocomplete.fish") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - "pwsh": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/pwsh_autocomplete.ps1") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - "zsh": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/zsh_autocomplete.zsh") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - } -) - -func OutputCompletionScript(ctx context.Context, cmd *cli.Command) error { - shells := make([]CompletionStyle, 0, len(shellCompletions)) - for k := range shellCompletions { - shells = append(shells, k) - } - - if cmd.Args().Len() == 0 { - return cli.Exit(fmt.Sprintf("no shell provided for completion command. available shells are %+v", shells), 1) - } - s := CompletionStyle(cmd.Args().First()) - - renderCompletion, ok := shellCompletions[s] - if !ok { - return cli.Exit(fmt.Sprintf("unknown shell %s, available shells are %+v", s, shells), 1) - } - - completionScript, err := renderCompletion(cmd, cmd.Root().Name) - if err != nil { - return cli.Exit(err, 1) - } - - _, err = cmd.Writer.Write([]byte(completionScript)) - if err != nil { - return cli.Exit(err, 1) - } - - return nil -} - -type ShellCompletion struct { - Name string - Usage string -} - -func NewShellCompletion(name string, usage string) ShellCompletion { - return ShellCompletion{Name: name, Usage: usage} -} - -type ShellCompletionBehavior int - -const ( - ShellCompletionBehaviorDefault ShellCompletionBehavior = iota - ShellCompletionBehaviorFile = 10 - ShellCompletionBehaviorNoComplete -) - -type CompletionResult struct { - Completions []ShellCompletion - Behavior ShellCompletionBehavior -} - -func isFlag(arg string) bool { - return strings.HasPrefix(arg, "-") -} - -func findFlag(cmd *cli.Command, arg string) *cli.Flag { - name := strings.TrimLeft(arg, "-") - for _, flag := range cmd.Flags { - if vf, ok := flag.(cli.VisibleFlag); ok && !vf.IsVisible() { - continue - } - - if slices.Contains(flag.Names(), name) { - return &flag - } - } - return nil -} - -func findChild(cmd *cli.Command, name string) *cli.Command { - for _, c := range cmd.Commands { - if !c.Hidden && c.Name == name { - return c - } - } - return nil -} - -type shellCompletionBuilder struct { - completionStyle CompletionStyle -} - -func (scb *shellCompletionBuilder) createFromCommand(input string, command *cli.Command, result []ShellCompletion) []ShellCompletion { - matchingNames := make([]string, 0, len(command.Names())) - - for _, name := range command.Names() { - if strings.HasPrefix(name, input) { - matchingNames = append(matchingNames, name) - } - } - - if scb.completionStyle == CompletionStyleBash { - index := strings.LastIndex(input, ":") + 1 - if index > 0 { - for _, name := range matchingNames { - result = append(result, NewShellCompletion(name[index:], command.Usage)) - } - return result - } - } - - for _, name := range matchingNames { - result = append(result, NewShellCompletion(name, command.Usage)) - } - return result -} - -func (scb *shellCompletionBuilder) createFromFlag(input string, flag *cli.Flag, result []ShellCompletion) []ShellCompletion { - matchingNames := make([]string, 0, len((*flag).Names())) - - for _, name := range (*flag).Names() { - withPrefix := "" - if len(name) == 1 { - withPrefix = "-" + name - } else { - withPrefix = "--" + name - } - - if strings.HasPrefix(withPrefix, input) { - matchingNames = append(matchingNames, withPrefix) - } - } - - usage := "" - if dgf, ok := (*flag).(cli.DocGenerationFlag); ok { - usage = dgf.GetUsage() - } - - for _, name := range matchingNames { - result = append(result, NewShellCompletion(name, usage)) - } - - return result -} - -func GetCompletions(completionStyle CompletionStyle, root *cli.Command, args []string) CompletionResult { - result := getAllPossibleCompletions(completionStyle, root, args) - - // If the user has not put in a colon, filter out colon commands - if len(args) > 0 && !strings.Contains(args[len(args)-1], ":") { - // Nothing with anything after a colon. Create a single entry for groups with the same colon subset - foundNames := make([]string, 0, len(result.Completions)) - filteredCompletions := make([]ShellCompletion, 0, len(result.Completions)) - - for _, completion := range result.Completions { - name := completion.Name - firstColonIndex := strings.Index(name, ":") - if firstColonIndex > -1 { - name = name[0:firstColonIndex] - completion.Name = name - completion.Usage = "" - } - - if !slices.Contains(foundNames, name) { - foundNames = append(foundNames, name) - filteredCompletions = append(filteredCompletions, completion) - } - } - - result.Completions = filteredCompletions - } - - return result -} - -func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Command, args []string) CompletionResult { - builder := shellCompletionBuilder{completionStyle: completionStyle} - completions := make([]ShellCompletion, 0) - if len(args) == 0 { - for _, child := range root.Commands { - completions = builder.createFromCommand("", child, completions) - } - return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorDefault} - } - - current := args[len(args)-1] - preceding := args[0 : len(args)-1] - cmd := root - i := 0 - for i < len(preceding) { - arg := preceding[i] - - if isFlag(arg) { - flag := findFlag(cmd, arg) - if flag == nil { - i++ - } else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() { - // All flags except for bool flags take values - i += 2 - } else { - i++ - } - } else { - child := findChild(cmd, arg) - if child != nil { - cmd = child - } - i++ - } - } - - // Check if the previous arg was a flag expecting a value - if len(preceding) > 0 { - prev := preceding[len(preceding)-1] - if isFlag(prev) { - flag := findFlag(cmd, prev) - if flag != nil { - if fb, ok := (*flag).(*cli.StringFlag); ok && fb.TakesFile { - return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorFile} - } else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() { - return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorNoComplete} - } - } - } - } - - // Completing a flag name - if isFlag(current) { - for _, flag := range cmd.Flags { - completions = builder.createFromFlag(current, &flag, completions) - } - } - - for _, child := range cmd.Commands { - if !child.Hidden { - completions = builder.createFromCommand(current, child, completions) - } - } - - return CompletionResult{ - Completions: completions, - Behavior: ShellCompletionBehaviorDefault, - } -} - -func ExecuteShellCompletion(ctx context.Context, cmd *cli.Command) error { - root := cmd.Root() - args := rebuildColonSeparatedArgs(root.Args().Slice()[1:]) - - var completionStyle CompletionStyle - if style, ok := os.LookupEnv("COMPLETION_STYLE"); ok { - switch style { - case "bash": - completionStyle = CompletionStyleBash - case "zsh": - completionStyle = CompletionStyleZsh - case "pwsh": - completionStyle = CompletionStylePowershell - case "fish": - completionStyle = CompletionStyleFish - default: - return cli.Exit("COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', or 'fish'", 1) - } - } else { - return cli.Exit("COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', 'fish'", 1) - } - - result := GetCompletions(completionStyle, root, args) - - for _, completion := range result.Completions { - name := completion.Name - if completionStyle == CompletionStyleZsh { - name = strings.ReplaceAll(name, ":", "\\:") - } - if completionStyle == CompletionStyleZsh && len(completion.Usage) > 0 { - _, _ = fmt.Fprintf(cmd.Writer, "%s:%s\n", name, completion.Usage) - } else if completionStyle == CompletionStyleFish && len(completion.Usage) > 0 { - _, _ = fmt.Fprintf(cmd.Writer, "%s\t%s\n", name, completion.Usage) - } else { - _, _ = fmt.Fprintf(cmd.Writer, "%s\n", name) - } - } - return cli.Exit("", int(result.Behavior)) -} - -// When CLI arguments are passed in, they are separated on word barriers. -// Most commonly this is whitespace but in some cases that may also be colons. -// We wish to allow arguments with colons. To handle this, we append/prepend colons to their neighboring -// arguments. -// -// Example: `rebuildColonSeparatedArgs(["a", "b", ":", "c", "d"])` => `["a", "b:c", "d"]` -func rebuildColonSeparatedArgs(args []string) []string { - if len(args) == 0 { - return args - } - - result := []string{} - i := 0 - - for i < len(args) { - current := args[i] - - // Keep joining while the next element is ":" or the current element ends with ":" - for i+1 < len(args) && (args[i+1] == ":" || strings.HasSuffix(current, ":")) { - if args[i+1] == ":" { - current += ":" - i++ - // Check if there's a following element after the ":" - if i+1 < len(args) && args[i+1] != ":" { - current += args[i+1] - i++ - } - } else { - break - } - } - - result = append(result, current) - i++ - } - - return result -} diff --git a/internal/autocomplete/autocomplete_test.go b/internal/autocomplete/autocomplete_test.go deleted file mode 100644 index 2338924..0000000 --- a/internal/autocomplete/autocomplete_test.go +++ /dev/null @@ -1,433 +0,0 @@ -package autocomplete - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/urfave/cli/v3" -) - -func TestGetCompletions_EmptyArgs(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "generate", Usage: "Generate SDK"}, - {Name: "test", Usage: "Run tests"}, - {Name: "build", Usage: "Build project"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 3) - assert.Contains(t, result.Completions, ShellCompletion{Name: "generate", Usage: "Generate SDK"}) - assert.Contains(t, result.Completions, ShellCompletion{Name: "test", Usage: "Run tests"}) - assert.Contains(t, result.Completions, ShellCompletion{Name: "build", Usage: "Build project"}) -} - -func TestGetCompletions_SubcommandPrefix(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "generate", Usage: "Generate SDK"}, - {Name: "test", Usage: "Run tests"}, - {Name: "build", Usage: "Build project"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"ge"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "generate", result.Completions[0].Name) - assert.Equal(t, "Generate SDK", result.Completions[0].Usage) -} - -func TestGetCompletions_HiddenCommand(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "visible", Usage: "Visible command"}, - {Name: "hidden", Usage: "Hidden command", Hidden: true}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{""}) - - assert.Len(t, result.Completions, 1) - assert.Equal(t, "visible", result.Completions[0].Name) -} - -func TestGetCompletions_NestedSubcommand(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "config", - Usage: "Configuration commands", - Commands: []*cli.Command{ - {Name: "get", Usage: "Get config value"}, - {Name: "set", Usage: "Set config value"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"config", "s"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "set", result.Completions[0].Name) - assert.Equal(t, "Set config value", result.Completions[0].Usage) -} - -func TestGetCompletions_FlagCompletion(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "Output directory"}, - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"}, - &cli.StringFlag{Name: "format", Usage: "Output format"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--o"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "--output", result.Completions[0].Name) - assert.Equal(t, "Output directory", result.Completions[0].Usage) -} - -func TestGetCompletions_ShortFlagCompletion(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "Output directory"}, - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-v"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "-v", result.Completions[0].Name) -} - -func TestGetCompletions_FileFlagBehavior(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "Config file", TakesFile: true}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--config", ""}) - - assert.EqualValues(t, ShellCompletionBehaviorFile, result.Behavior) - assert.Empty(t, result.Completions) -} - -func TestGetCompletions_NonBoolFlagValue(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "format", Usage: "Output format"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--format", ""}) - - assert.EqualValues(t, ShellCompletionBehaviorNoComplete, result.Behavior) - assert.Empty(t, result.Completions) -} - -func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"}, - }, - Commands: []*cli.Command{ - {Name: "typescript", Usage: "Generate TypeScript SDK"}, - {Name: "python", Usage: "Generate Python SDK"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--verbose", "ty"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "typescript", result.Completions[0].Name) -} - -func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - {Name: "config:list", Usage: "List config values"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"co"}) - - // Should collapse to single "config" entry without usage - assert.Len(t, result.Completions, 1) - assert.Equal(t, "config", result.Completions[0].Name) - assert.Equal(t, "", result.Completions[0].Usage) -} - -func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - {Name: "config:list", Usage: "List config values"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"config:"}) - - // For bash, should show suffixes only - assert.Len(t, result.Completions, 3) - names := []string{result.Completions[0].Name, result.Completions[1].Name, result.Completions[2].Name} - assert.Contains(t, names, "get") - assert.Contains(t, names, "set") - assert.Contains(t, names, "list") -} - -func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - {Name: "config:list", Usage: "List config values"}, - }, - } - - result := GetCompletions(CompletionStyleZsh, root, []string{"config:"}) - - // For zsh, should show full names - assert.Len(t, result.Completions, 3) - names := []string{result.Completions[0].Name, result.Completions[1].Name, result.Completions[2].Name} - assert.Contains(t, names, "config:get") - assert.Contains(t, names, "config:set") - assert.Contains(t, names, "config:list") -} - -func TestGetCompletions_BashStyleColonCompletion(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"config:g"}) - - // For bash, should return suffix from after the colon in the input - // Input "config:g" has colon at index 6, so we take name[7:] from matched commands - assert.Len(t, result.Completions, 1) - assert.Equal(t, "get", result.Completions[0].Name) - assert.Equal(t, "Get config value", result.Completions[0].Usage) -} - -func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"other:g"}) - - // No matches - assert.Len(t, result.Completions, 0) -} - -func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - }, - } - - result := GetCompletions(CompletionStyleZsh, root, []string{"config:g"}) - - // For zsh, should return full name - assert.Len(t, result.Completions, 1) - assert.Equal(t, "config:get", result.Completions[0].Name) - assert.Equal(t, "Get config value", result.Completions[0].Usage) -} - -func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "generate", Usage: "Generate SDK"}, - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{""}) - - // Should show "generate" and "config" (collapsed) - assert.Len(t, result.Completions, 2) - names := []string{result.Completions[0].Name, result.Completions[1].Name} - assert.Contains(t, names, "generate") - assert.Contains(t, names, "config") -} - -func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}}, - &cli.StringFlag{Name: "output", Aliases: []string{"o"}}, - }, - Commands: []*cli.Command{ - {Name: "typescript", Usage: "TypeScript SDK"}, - }, - }, - }, - } - - // Bool flag should not consume the next arg as a value - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-v", "ty"}) - - assert.Len(t, result.Completions, 1) - assert.Equal(t, "typescript", result.Completions[0].Name) -} - -func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "config", Aliases: []string{"c"}}, - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}}, - }, - Commands: []*cli.Command{ - {Name: "typescript", Usage: "TypeScript SDK"}, - {Name: "python", Usage: "Python SDK"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-c", "config.yml", "-v", "py"}) - - assert.Len(t, result.Completions, 1) - assert.Equal(t, "python", result.Completions[0].Name) -} - -func TestGetCompletions_CommandAliases(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "generate", Aliases: []string{"gen", "g"}, Usage: "Generate SDK"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"g"}) - - // Should match all aliases that start with "g" - assert.GreaterOrEqual(t, len(result.Completions), 2) // "generate" and "gen", possibly "g" too - names := []string{} - for _, c := range result.Completions { - names = append(names, c.Name) - } - assert.Contains(t, names, "generate") - assert.Contains(t, names, "gen") -} - -func TestGetCompletions_AllFlagsWhenNoPrefix(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "output", Aliases: []string{"o"}}, - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}}, - &cli.StringFlag{Name: "format", Aliases: []string{"f"}}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-"}) - - // Should show all flag variations - assert.GreaterOrEqual(t, len(result.Completions), 6) // -o, --output, -v, --verbose, -f, --format -} diff --git a/internal/autocomplete/shellscripts/bash_autocomplete.bash b/internal/autocomplete/shellscripts/bash_autocomplete.bash deleted file mode 100755 index 8fb7b0b..0000000 --- a/internal/autocomplete/shellscripts/bash_autocomplete.bash +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash - -____APPNAME___bash_autocomplete() { - if [[ "${COMP_WORDS[0]}" != "source" ]]; then - local cur completions exit_code - local IFS=$'\n' - cur="${COMP_WORDS[COMP_CWORD]}" - - completions=$(COMPLETION_STYLE=bash "${COMP_WORDS[0]}" __complete -- "${COMP_WORDS[@]:1:$COMP_CWORD-1}" "$cur" 2>/dev/null) - exit_code=$? - - local last_token="$cur" - - # If the last token has been split apart by a ':', join it back together. - # Ex: 'a:b' will be represented in COMP_WORDS as 'a', ':', 'b' - if [[ $COMP_CWORD -ge 2 ]]; then - local prev2="${COMP_WORDS[COMP_CWORD - 2]}" - local prev1="${COMP_WORDS[COMP_CWORD - 1]}" - if [[ "$prev2" =~ ^@(file|data)$ && "$prev1" == ":" && "$cur" =~ ^// ]]; then - last_token="$prev2:$cur" - fi - fi - - # Check for custom file completion patterns - local prefix="" - local file_part="$cur" - local force_file_completion=false - if [[ "$last_token" =~ (.*)@(file://|data://)?(.*)$ ]]; then - local before_at="${BASH_REMATCH[1]}" - local protocol="${BASH_REMATCH[2]}" - file_part="${BASH_REMATCH[3]}" - - if [[ "$protocol" == "" ]]; then - prefix="$before_at@" - else - if [[ "$before_at" == "" ]]; then - prefix="//" - else - prefix="$before_at@$protocol" - fi - fi - - force_file_completion=true - fi - - if [[ "$force_file_completion" == true ]]; then - mapfile -t COMPREPLY < <(compgen -f -- "$file_part" | sed "s|^|$prefix|") - else - case $exit_code in - 10) mapfile -t COMPREPLY < <(compgen -f -- "$cur") ;; # file completion - 11) COMPREPLY=() ;; # no completion - 0) mapfile -t COMPREPLY <<<"$completions" ;; # use returned completions - esac - fi - return 0 - fi -} - -complete -F ____APPNAME___bash_autocomplete __APPNAME__ diff --git a/internal/autocomplete/shellscripts/fish_autocomplete.fish b/internal/autocomplete/shellscripts/fish_autocomplete.fish deleted file mode 100644 index b853057..0000000 --- a/internal/autocomplete/shellscripts/fish_autocomplete.fish +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env fish - -function ____APPNAME___fish_autocomplete - set -l tokens (commandline -xpc) - set -l current (commandline -ct) - - set -l cmd $tokens[1] - set -l args $tokens[2..-1] - - set -l completions (env COMPLETION_STYLE=fish $cmd __complete -- $args $current 2>>/tmp/fish-debug.log) - set -l exit_code $status - - # Check for custom file completion patterns - # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path') - set -l prefix "" - set -l file_part "$current" - set -l force_file_completion 0 - - if string match -gqr '^(?.*)@(?file://|data://)?(?.*)$' -- $current - if string match -qr '^[\'"]' -- $before - # Ensures we don't insert an extra quote when the user is building an argument in quotes - set before (string sub -s 2 -- $before) - end - - set prefix "$before@$protocol" - set force_file_completion 1 - end - - if test $force_file_completion -eq 1 - for path in (__fish_complete_path "$file_part") - echo $prefix$path - end - else - switch $exit_code - case 10 - # File completion - __fish_complete_path "$current" - case 11 - # No completion - return 0 - case 0 - # Use returned completions - for completion in $completions - echo $completion - end - end - end -end - -complete -c __APPNAME__ -f -a '(____APPNAME___fish_autocomplete)' - diff --git a/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 b/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 deleted file mode 100644 index 7cd6e62..0000000 --- a/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 +++ /dev/null @@ -1,97 +0,0 @@ -Register-ArgumentCompleter -Native -CommandName __APPNAME__ -ScriptBlock { - param($wordToComplete, $commandAst, $cursorPosition) - - $elements = $commandAst.CommandElements - $completionArgs = @() - - # Extract each of the arguments - for ($i = 0; $i -lt $elements.Count; $i++) { - $completionArgs += $elements[$i].Extent.Text - } - - # Add empty string if there's a trailing space (wordToComplete is empty but cursor is after space) - # Necessary for differentiating between getting completions for namespaced commands vs. subcommands - if ($wordToComplete.Length -eq 0 -and $elements.Count -gt 0) { - $completionArgs += "" - } - - $output = & { - $env:COMPLETION_STYLE = 'pwsh' - __APPNAME__ __complete @completionArgs 2>&1 - } - $exitCode = $LASTEXITCODE - - # Check for custom file completion patterns - # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path') - $prefix = "" - $filePart = $wordToComplete - $forceFileCompletion = $false - - # PowerShell includes quotes in $wordToComplete - strip them for pattern matching - # but preserve them in the prefix for the completion result - $wordContent = $wordToComplete - $leadingQuote = "" - if ($wordToComplete -match '^([''"])(.*)(\1)$') { - # Fully quoted: "content" or 'content' - $leadingQuote = $Matches[1] - $wordContent = $Matches[2] - } elseif ($wordToComplete -match '^([''"])(.*)$') { - # Opening quote only: "content or 'content - $leadingQuote = $Matches[1] - $wordContent = $Matches[2] - } - - if ($wordContent -match '^(.*)@(file://|data://)?(.*)$') { - $prefix = $leadingQuote + $Matches[1] + '@' + $Matches[2] - $filePart = $Matches[3] - $forceFileCompletion = $true - } - - if ($forceFileCompletion) { - # Handle empty filePart (e.g., "@" or "@file://") by listing current directory - $items = if ([string]::IsNullOrEmpty($filePart)) { - Get-ChildItem -ErrorAction SilentlyContinue - } else { - Get-ChildItem -Path "$filePart*" -ErrorAction SilentlyContinue - } - $items | ForEach-Object { - $completionText = if ($_.PSIsContainer) { $prefix + $_.Name + "/" } else { $prefix + $_.Name } - [System.Management.Automation.CompletionResult]::new( - $completionText, - $completionText, - 'ProviderItem', - $completionText - ) - } - } else { - switch ($exitCode) { - 10 { - # File completion behavior - $items = if ([string]::IsNullOrEmpty($wordToComplete)) { - Get-ChildItem -ErrorAction SilentlyContinue - } else { - Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue - } - $items | ForEach-Object { - $completionText = if ($_.PSIsContainer) { $_.Name + "/" } else { $_.Name } - [System.Management.Automation.CompletionResult]::new( - $completionText, - $completionText, - 'ProviderItem', - $completionText - ) - } - } - 11 { - # No reasonable suggestions - [System.Management.Automation.CompletionResult]::new(' ', ' ', 'ParameterValue', ' ') - } - default { - # Default behavior - show command completions - $output | ForEach-Object { - [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) - } - } - } - } -} diff --git a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh deleted file mode 100644 index d937171..0000000 --- a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh +++ /dev/null @@ -1,56 +0,0 @@ -#compdef __APPNAME__ - -____APPNAME___zsh_autocomplete() { - - local -a opts - local temp - local exit_code - - temp=$(COMPLETION_STYLE=zsh "${words[1]}" __complete "${words[@]:1}") - exit_code=$? - - # Check for custom file completion patterns - # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path') - local cur="${words[CURRENT]}" - - if [[ "$cur" = *'@'* ]]; then - # Extract everything after the last @ - local after_last_at="${cur##*@}" - - if [[ $after_last_at =~ ^(file://|data://) ]]; then - compset -P "*$MATCH" - _files - else - compset -P '*@' - _files - fi - return - fi - - case $exit_code in - 10) - # File completion behavior - _files - ;; - 11) - # No completion behavior - return nothing - return 1 - ;; - 0) - # Default behavior - show command completions - opts=("${(@f)temp}") - _describe 'values' opts - ;; - esac -} - -# When installed in fpath (e.g., via Homebrew's zsh_completion stanza), this file -# is autoloaded as the function ___APPNAME__ and its body becomes that function's -# body. Detect that case via funcstack and dispatch to the completion function. -# When sourced (e.g., `source <(__APPNAME__ @completion zsh)`), register the -# function with compdef instead. -if [[ "${funcstack[1]}" = "___APPNAME__" ]]; then - ____APPNAME___zsh_autocomplete "$@" -else - compdef ____APPNAME___zsh_autocomplete __APPNAME__ -fi diff --git a/internal/binaryparam/binary_param.go b/internal/binaryparam/binary_param.go deleted file mode 100644 index 40d4ecf..0000000 --- a/internal/binaryparam/binary_param.go +++ /dev/null @@ -1,30 +0,0 @@ -package binaryparam - -import ( - "io" - "os" -) - -const stdinGlyph = "-" - -// FileOrStdin opens the file at the given path for reading. If the path is "-", stdin is returned instead. -// -// It's the caller's responsibility to close the returned ReadCloser (usually with `defer`). -// -// Returns a boolean indicating whether stdin is being used. If true, no other components of the calling -// program should attempt to read from stdin for anything else. -func FileOrStdin(stdin io.ReadCloser, path string) (io.ReadCloser, bool, error) { - // When the special glyph "-" is used, read from stdin. Although probably less necessary, also support - // special Unix files that refer to stdin. - switch path { - case "", stdinGlyph, "/dev/fd/0", "/dev/stdin": - return stdin, true, nil - } - - readCloser, err := os.Open(path) - if err != nil { - return nil, false, err - } - - return readCloser, false, err -} diff --git a/internal/binaryparam/binary_param_test.go b/internal/binaryparam/binary_param_test.go deleted file mode 100644 index 7a66682..0000000 --- a/internal/binaryparam/binary_param_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package binaryparam - -import ( - "io" - "os" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestFileOrStdin(t *testing.T) { - t.Parallel() - - const expectedContents = "test file contents" - - t.Run("WithFile", func(t *testing.T) { - tempFile := t.TempDir() + "/test_file.txt" - require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) - - readCloser, stdinInUse, err := FileOrStdin(os.Stdin, tempFile) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, readCloser.Close()) }) - - actualContents, err := io.ReadAll(readCloser) - require.NoError(t, err) - require.Equal(t, expectedContents, string(actualContents)) - - require.False(t, stdinInUse) - }) - - stdinTests := []struct { - testName string - path string - }{ - {"TestEmptyString", ""}, - {"TestDash", "-"}, - {"TestDevStdin", "/dev/stdin"}, - {"TestDevFD0", "/dev/fd/0"}, - } - for _, test := range stdinTests { - t.Run(test.testName, func(t *testing.T) { - tempFile := t.TempDir() + "/test_file.txt" - require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) - - stubStdin, err := os.Open(tempFile) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, stubStdin.Close()) }) - - readCloser, stdinInUse, err := FileOrStdin(stubStdin, test.path) - require.NoError(t, err) - - actualContents, err := io.ReadAll(readCloser) - require.NoError(t, err) - require.Equal(t, expectedContents, string(actualContents)) - - require.True(t, stdinInUse) - }) - } -} diff --git a/internal/debugmiddleware/debug_middleware.go b/internal/debugmiddleware/debug_middleware.go deleted file mode 100644 index 647f1de..0000000 --- a/internal/debugmiddleware/debug_middleware.go +++ /dev/null @@ -1,132 +0,0 @@ -package debugmiddleware - -import ( - "bytes" - "io" - "log" - "net/http" - "net/http/httputil" - "reflect" - "strings" -) - -// For the time being these type definitions are duplicated here so that we can -// test this file in a non-generated context. -type ( - Middleware = func(*http.Request, MiddlewareNext) (*http.Response, error) - MiddlewareNext = func(*http.Request) (*http.Response, error) -) - -const redactedPlaceholder = "" - -// Headers known to contain sensitive information like an API key. Note that this exclude `Authorization`, -// which is handled specially in `redactRequest` below. -var sensitiveHeaders = []string{ - "api-key", - "x-api-key", - "cookie", - "set-cookie", -} - -// RequestLogger is a middleware that logs HTTP requests and responses. -type RequestLogger struct { - logger interface{ Printf(string, ...any) } // field for testability; usually log.Default() - sensitiveHeaders []string // field for testability; usually sensitiveHeaders -} - -// NewRequestLogger returns a new RequestLogger instance with default options. -func NewRequestLogger() *RequestLogger { - return &RequestLogger{ - logger: log.Default(), - sensitiveHeaders: sensitiveHeaders, - } -} - -func (m *RequestLogger) Middleware() Middleware { - return func(req *http.Request, mn MiddlewareNext) (*http.Response, error) { - redacted, err := m.redactRequest(req) - if err != nil { - return nil, err - } - if reqBytes, err := httputil.DumpRequest(redacted, true); err == nil { - m.logger.Printf("Request Content:\n%s\n", reqBytes) - } - - resp, err := mn(req) - if err != nil { - return resp, err - } - - if respBytes, err := httputil.DumpResponse(resp, true); err == nil { - m.logger.Printf("Response Content:\n%s\n", respBytes) - } - - return resp, err - } -} - -// redactRequest redacts sensitive information from the request for logging -// purposes. If redaction is necessary, the request is cloned before mutating -// the original and that clone is returned. As a small optimization, the -// original is request is returned unchanged if no redaction is necessary. -func (m *RequestLogger) redactRequest(req *http.Request) (*http.Request, error) { - redactedHeaders := req.Header.Clone() - - // Notably, the clauses below are written so they can redact multiple - // headers of the same name if necessary. - if values := redactedHeaders.Values("Authorization"); len(values) > 0 { - redactedHeaders.Del("Authorization") - - for _, value := range values { - // In case we're using something like a bearer token (e.g. `Bearer - // `), keep the `Bearer` part for more debugging - // information. - if authKind, _, ok := strings.Cut(value, " "); ok { - redactedHeaders.Add("Authorization", authKind+" "+redactedPlaceholder) - } else { - redactedHeaders.Add("Authorization", redactedPlaceholder) - } - } - } - - for _, header := range m.sensitiveHeaders { - values := redactedHeaders.Values(header) - if len(values) == 0 { - continue - } - - redactedHeaders.Del(header) - - for range values { - redactedHeaders.Add(header, redactedPlaceholder) - } - } - - if reflect.DeepEqual(req.Header, redactedHeaders) { - return req, nil - } - - redacted := req.Clone(req.Context()) - redacted.Header = redactedHeaders - var err error - redacted.Body, req.Body, err = cloneBody(req.Body) - return redacted, err -} - -// This function returns two copies of an HTTP request body that can each be -// read independently without affecting the other. -// This logic is taken from `drainBody` in net/http/httputil. -func cloneBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) { - if b == nil || b == http.NoBody { - // No copying needed. Preserve the magic sentinel meaning of NoBody. - return http.NoBody, http.NoBody, nil - } - var buf bytes.Buffer - if _, err = buf.ReadFrom(b); err != nil { - return nil, b, err - } - if err = b.Close(); err != nil { - return nil, b, err - } - return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil -} diff --git a/internal/debugmiddleware/debug_middleware_test.go b/internal/debugmiddleware/debug_middleware_test.go deleted file mode 100644 index 4e46fbc..0000000 --- a/internal/debugmiddleware/debug_middleware_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package debugmiddleware - -import ( - "bytes" - "io" - "log" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestDebugMiddleware(t *testing.T) { - t.Parallel() - - setup := func() (*RequestLogger, *bytes.Buffer) { - var ( - logBuf bytes.Buffer - middleware = NewRequestLogger() - ) - middleware.logger = log.New(&logBuf, "", 0) - return middleware, &logBuf - } - - t.Run("DoesNotRedactMostHeaders", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - const stainlessUserAgent = "Stainless" - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Set("User-Agent", stainlessUserAgent) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, stainlessUserAgent, req.Header.Get("User-Agent")) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), "User-Agent: "+stainlessUserAgent) - }) - - const secretToken = "secret-token" - - t.Run("RedactsAuthorizationHeader", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Set("Authorization", secretToken) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, secretToken, req.Header.Get("Authorization")) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), "Authorization: "+redactedPlaceholder) - }) - - t.Run("RedactsOnlySecretInAuthorizationHeader", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Set("Authorization", "Bearer "+secretToken) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), "Authorization: Bearer "+redactedPlaceholder) - }) - - t.Run("RedactsMultipleAuthorizationHeaders", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Add("Authorization", secretToken+"1") - req.Header.Add("Authorization", secretToken+"2") - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, []string{secretToken + "1", secretToken + "2"}, req.Header.Values("Authorization")) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - - if strings.Count(logBuf.String(), "Authorization: "+redactedPlaceholder) != 2 { - t.Error("expected exactly two redacted placeholders in authorization headers") - } - }) - - const customAPIKeyHeader = "X-My-Api-Key" - - t.Run("RedactsSensitiveHeaders", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - middleware.sensitiveHeaders = []string{customAPIKeyHeader} - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Set(customAPIKeyHeader, secretToken) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, secretToken, req.Header.Get(customAPIKeyHeader)) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), customAPIKeyHeader+": "+redactedPlaceholder) - }) - - t.Run("RedactsMultipleSensitiveHeaders", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - middleware.sensitiveHeaders = []string{customAPIKeyHeader} - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Add(customAPIKeyHeader, secretToken+"1") - req.Header.Add(customAPIKeyHeader, secretToken+"2") - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, []string{secretToken + "1", secretToken + "2"}, req.Header.Values(customAPIKeyHeader)) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Equal(t, 2, strings.Count(logBuf.String(), customAPIKeyHeader+": "+redactedPlaceholder)) - }) - - t.Run("DoesNotConsumeRequestBodyWhenIoReader", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - middleware.sensitiveHeaders = []string{customAPIKeyHeader} - - const bodyContent = "test request body content" - bodyReader := strings.NewReader(bodyContent) - - req := httptest.NewRequest("POST", "https://example.com", bodyReader) - req.Header.Set("Authorization", secretToken) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request body should still be fully readable after the middleware runs - body, err := io.ReadAll(req.Body) - require.NoError(t, err) - require.Equal(t, bodyContent, string(body)) - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, secretToken, req.Header.Get("Authorization")) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), "Authorization: "+redactedPlaceholder) - }) -} diff --git a/internal/jsonview/explorer.go b/internal/jsonview/explorer.go deleted file mode 100644 index 836bb2c..0000000 --- a/internal/jsonview/explorer.go +++ /dev/null @@ -1,807 +0,0 @@ -package jsonview - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "math" - "os" - "strings" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/term" - "github.com/muesli/reflow/truncate" - "github.com/muesli/reflow/wordwrap" - "github.com/tidwall/gjson" -) - -const ( - // UI layout constants - borderPadding = 2 - heightOffset = 5 - tableMinHeight = 2 - titlePaddingLeft = 2 - titlePaddingTop = 0 - footerPaddingLeft = 1 - - // Column width constants - defaultColumnWidth = 10 - keyColumnWidth = 3 - valueColumnWidth = 5 - - // String formatting constants - maxStringLength = 100 - maxPreviewLength = 24 - - arrayColor = lipgloss.Color("1") - stringColor = lipgloss.Color("5") - objectColor = lipgloss.Color("4") -) - -type keyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Back key.Binding - PrintValue key.Binding - Raw key.Binding - Quit key.Binding -} - -func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Quit, k.Up, k.Down, k.Back, k.Enter, k.PrintValue, k.Raw} -} - -func (k keyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{k.ShortHelp()} -} - -var keys = keyMap{ - Up: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "up"), - ), - Down: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "down"), - ), - Back: key.NewBinding( - key.WithKeys("left", "h", "backspace"), - key.WithHelp("←/h", "go back"), - ), - Enter: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("→/l", "expand"), - ), - PrintValue: key.NewBinding( - key.WithKeys("p"), - key.WithHelp("p", "print and exit"), - ), - Raw: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("r", "toggle raw JSON"), - ), - Quit: key.NewBinding( - key.WithKeys("q", "esc", "ctrl+c", "enter"), - key.WithHelp("q/enter", "quit"), - ), -} - -var ( - titleStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(titlePaddingLeft).PaddingTop(titlePaddingTop) - arrayStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(arrayColor) - stringStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(stringColor) - objectStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(objectColor) - stringLiteralStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2")) -) - -type JSONView interface { - GetPath() string - GetData() gjson.Result - Update(tea.Msg, bool) tea.Cmd - View() string - Resize(width, height int) -} - -type TableView struct { - width int - height int - path string - data gjson.Result - table table.Model - rowData []gjson.Result - iterator AnyIterator - isLoading bool - columns []table.Column -} - -func (tv *TableView) GetPath() string { return tv.path } -func (tv *TableView) GetData() gjson.Result { return tv.data } -func (tv *TableView) View() string { return tv.table.View() } - -func (tv *TableView) Update(msg tea.Msg, raw bool) tea.Cmd { - var cmd tea.Cmd - tv.table, cmd = tv.table.Update(msg) - - // Check if we need to load more data - if tv.iterator != nil && !tv.isLoading && tv.data.IsArray() { - cursor := tv.table.Cursor() - totalRows := len(tv.table.Rows()) - - // Load more when we're at the last row - if cursor == totalRows-1 { - tv.isLoading = true - return tv.loadMoreData(raw) - } - } - - return cmd -} - -func (tv *TableView) loadMoreData(raw bool) tea.Cmd { - return func() tea.Msg { - if tv.iterator == nil { - return nil - } - - if !tv.iterator.Next() { - tv.isLoading = false - return tv.iterator.Err() - } - - obj := tv.iterator.Current() - var result gjson.Result - if jsonBytes, err := json.Marshal(obj); err != nil { - return err - } else { - result = gjson.ParseBytes(jsonBytes) - } - - if !result.Exists() { - tv.isLoading = false - return nil - } - - // Add the new item to our data - tv.rowData = append(tv.rowData, result) - - // Add new row to the table - newRow := table.Row{formatValue(result, raw)} - - // For array of objects, we need to format according to columns - if len(tv.columns) > 1 && result.IsObject() { - newRow = make(table.Row, len(tv.columns)) - for i, col := range tv.columns { - newRow[i] = formatValue(result.Get(col.Title), raw) - } - } - - rows := tv.table.Rows() - rows = append(rows, newRow) - tv.table.SetRows(rows) - - // Resize columns to accommodate the new data - tv.Resize(tv.width, tv.height) - - tv.isLoading = false - return nil - } -} - -func (tv *TableView) Resize(width, height int) { - tv.width = width - tv.height = height - tv.updateColumnWidths(width) - tv.table.SetHeight(min(height-heightOffset, tableMinHeight+len(tv.table.Rows()))) -} - -func (tv *TableView) updateColumnWidths(width int) { - columns := tv.table.Columns() - widths := make([]int, len(columns)) - - // Calculate required widths from headers and content - for i, col := range columns { - widths[i] = lipgloss.Width(col.Title) - } - - for _, row := range tv.table.Rows() { - for i, cell := range row { - if i < len(widths) { - widths[i] = max(widths[i], lipgloss.Width(cell)) - } - } - } - - totalWidth := sum(widths) - available := width - borderPadding*len(columns) - - if totalWidth <= available { - for i, w := range widths { - columns[i].Width = w - } - return - } - - fairShare := float64(available) / float64(len(columns)) - shrinkable := 0.0 - - for _, w := range widths { - if float64(w) > fairShare { - shrinkable += float64(w) - fairShare - } - } - - if shrinkable > 0 { - excess := float64(totalWidth - available) - for i, w := range widths { - if float64(w) > fairShare { - reduction := (float64(w) - fairShare) * (excess / shrinkable) - widths[i] = int(math.Round(float64(w) - reduction)) - } - } - } - - for i, w := range widths { - columns[i].Width = w - } - - tv.table.SetColumns(columns) -} - -type TextView struct { - path string - data gjson.Result - viewport viewport.Model - ready bool -} - -func (tv *TextView) GetPath() string { return tv.path } -func (tv *TextView) GetData() gjson.Result { return tv.data } -func (tv *TextView) View() string { return tv.viewport.View() } - -func (tv *TextView) Update(msg tea.Msg, raw bool) tea.Cmd { - var cmd tea.Cmd - tv.viewport, cmd = tv.viewport.Update(msg) - return cmd -} - -func (tv *TextView) Resize(width, height int) { - h := height - heightOffset - if !tv.ready { - tv.viewport = viewport.New(width, h) - tv.viewport.SetContent(wordwrap.String(tv.data.String(), width)) - tv.ready = true - return - } - tv.viewport.Width = width - tv.viewport.Height = h -} - -type JSONViewer struct { - stack []JSONView - root string - width int - height int - rawMode bool - message string - help help.Model -} - -// ExploreJSON explores a single JSON value known ahead of time -func ExploreJSON(title string, json gjson.Result) error { - view, err := newView("", json, false) - if err != nil { - return err - } - - viewer := &JSONViewer{stack: []JSONView{view}, root: title, rawMode: false, help: help.New()} - - _, err = tea.NewProgram(viewer).Run() - if viewer.message != "" { - _, msgErr := fmt.Println("\n" + viewer.message) - err = errors.Join(err, msgErr) - } - return err -} - -type hasRawJSON interface { - RawJSON() string -} - -// ExploreJSONStream explores JSON data loaded incrementally via an iterator -func ExploreJSONStream[T any](title string, it Iterator[T]) error { - anyIt := genericToAnyIterator(it) - - preloadCount := 20 - if termHeight, _, err := term.GetSize(os.Stdout.Fd()); err == nil { - preloadCount = termHeight - } - - items := make([]any, 0, preloadCount) - for i := 0; i < preloadCount && anyIt.Next(); i++ { - items = append(items, anyIt.Current()) - } - - if err := anyIt.Err(); err != nil { - return err - } - - arrayJSONBytes, err := marshalItemsToJSONArray(items) - if err != nil { - return err - } - - arrayJSON := gjson.ParseBytes(arrayJSONBytes) - view, err := newTableView("", arrayJSON, false) - if err != nil { - return err - } - - // Set iterator if there might be more data - if len(items) == preloadCount { - view.iterator = anyIt - } - - viewer := &JSONViewer{stack: []JSONView{view}, root: title, rawMode: false, help: help.New()} - _, err = tea.NewProgram(viewer).Run() - if viewer.message != "" { - _, msgErr := fmt.Println("\n" + viewer.message) - err = errors.Join(err, msgErr) - } - return err -} - -func marshalItemsToJSONArray(items []any) ([]byte, error) { - var buf bytes.Buffer - buf.WriteByte('[') - - for i, item := range items { - if i > 0 { - buf.WriteByte(',') - } - if hasRaw, ok := item.(hasRawJSON); ok { - buf.WriteString(hasRaw.RawJSON()) - } else { - jsonData, err := json.Marshal(item) - if err != nil { - return nil, err - } - buf.Write(jsonData) - } - } - - buf.WriteByte(']') - return buf.Bytes(), nil -} - -func (v *JSONViewer) current() JSONView { return v.stack[len(v.stack)-1] } -func (v *JSONViewer) Init() tea.Cmd { return nil } - -func (v *JSONViewer) resize(width, height int) { - v.width, v.height = width, height - v.help.Width = width - for i := range v.stack { - v.stack[i].Resize(width, height) - } -} - -func (v *JSONViewer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.resize(msg.Width-borderPadding, msg.Height) - return v, nil - case tea.KeyMsg: - switch { - case key.Matches(msg, keys.Quit): - return v, tea.Quit - case key.Matches(msg, keys.Enter): - return v.navigateForward() - case key.Matches(msg, keys.Back): - return v.navigateBack() - case key.Matches(msg, keys.Raw): - return v.toggleRaw() - case key.Matches(msg, keys.PrintValue): - v.message = v.getSelectedContent() - return v, tea.Quit - } - } - - return v, v.current().Update(msg, v.rawMode) -} - -func (v *JSONViewer) getSelectedContent() string { - tableView, ok := v.current().(*TableView) - if !ok { - return v.current().GetData().Raw - } - - selected := tableView.rowData[tableView.table.Cursor()] - if selected.Type == gjson.String { - return selected.String() - } - return selected.Raw -} - -func (v *JSONViewer) navigateForward() (tea.Model, tea.Cmd) { - tableView, ok := v.current().(*TableView) - if !ok { - return v, nil - } - - if len(tableView.rowData) < 1 { - return v, nil - } - - cursor := tableView.table.Cursor() - selected := tableView.rowData[cursor] - if !v.canNavigateInto(selected) { - return v, nil - } - - path := v.buildNavigationPath(tableView, cursor) - forwardView, err := newView(path, selected, v.rawMode) - if err != nil { - return v, nil - } - - v.stack = append(v.stack, forwardView) - v.resize(v.width, v.height) - return v, nil -} - -func (v *JSONViewer) buildNavigationPath(tableView *TableView, cursor int) string { - if tableView.data.IsArray() { - return fmt.Sprintf("%s[%d]", tableView.path, cursor) - } - key := tableView.data.Get("@keys").Array()[cursor].Str - return fmt.Sprintf("%s[%s]", tableView.path, quoteString(key)) -} - -func quoteString(s string) string { - // Replace backslashes and quotes with escaped versions - s = strings.ReplaceAll(s, "\\", "\\\\") - s = strings.ReplaceAll(s, "\"", "\\\"") - return stringLiteralStyle.Render("\"" + s + "\"") -} - -func (v *JSONViewer) canNavigateInto(data gjson.Result) bool { - switch { - case data.IsArray(): - return len(data.Array()) > 0 - case data.IsObject(): - return len(data.Map()) > 0 - case data.Type == gjson.String: - str := data.String() - return strings.Contains(str, "\n") || lipgloss.Width(str) >= maxStringLength - } - return false -} - -func (v *JSONViewer) navigateBack() (tea.Model, tea.Cmd) { - if len(v.stack) > 1 { - v.stack = v.stack[:len(v.stack)-1] - } - return v, nil -} - -func (v *JSONViewer) toggleRaw() (tea.Model, tea.Cmd) { - v.rawMode = !v.rawMode - - for i, view := range v.stack { - viewWithRaw, err := newView(view.GetPath(), view.GetData(), v.rawMode) - if err != nil { - return v, tea.Printf("Error: %s", err) - } - if newTV, ok := viewWithRaw.(*TableView); ok { - if tv, ok := view.(*TableView); ok && tv.iterator != nil { - newTV.iterator = tv.iterator - } - } - v.stack[i] = viewWithRaw - } - - v.resize(v.width, v.height) - return v, nil -} - -func (v *JSONViewer) View() string { - view := v.current() - title := v.buildTitle(view) - content := titleStyle.Render(title) - style := v.getStyleForData(view.GetData()) - content += "\n" + style.Render(view.View()) - content += "\n" + v.help.View(keys) - return content -} - -func (v *JSONViewer) buildTitle(view JSONView) string { - title := v.root - if len(view.GetPath()) > 0 { - title += " → " + view.GetPath() - } - if v.rawMode { - title += " (JSON)" - } - return title -} - -func (v *JSONViewer) getStyleForData(data gjson.Result) lipgloss.Style { - switch { - case data.Type == gjson.String: - return stringStyle - case data.IsArray(): - return arrayStyle - default: - return objectStyle - } -} - -func newView(path string, data gjson.Result, raw bool) (JSONView, error) { - if data.Type == gjson.String { - return newTextView(path, data) - } - return newTableView(path, data, raw) -} - -func newTextView(path string, data gjson.Result) (*TextView, error) { - if !data.Exists() || data.Type != gjson.String { - return nil, fmt.Errorf("invalid text JSON") - } - return &TextView{path: path, data: data}, nil -} - -func newTableView(path string, data gjson.Result, raw bool) (*TableView, error) { - if !data.Exists() || data.Type != gjson.JSON { - return nil, fmt.Errorf("invalid table JSON") - } - - switch { - case data.IsArray(): - array := data.Array() - if isArrayOfObjects(array) { - return newArrayOfObjectsTableView(path, data, array, raw), nil - } else { - return newArrayTableView(path, data, array, raw), nil - } - case data.IsObject(): - return newObjectTableView(path, data, raw), nil - default: - return nil, fmt.Errorf("unsupported JSON type") - } -} - -func newArrayTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView { - columns := []table.Column{{Title: "Items", Width: defaultColumnWidth}} - rows := make([]table.Row, 0, len(array)) - rowData := make([]gjson.Result, 0, len(array)) - - for _, item := range array { - rows = append(rows, table.Row{formatValue(item, raw)}) - rowData = append(rowData, item) - } - - t := createTable(columns, rows, arrayColor) - return &TableView{ - path: path, - data: data, - table: t, - rowData: rowData, - columns: columns, - } -} - -func newArrayOfObjectsTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView { - // Collect unique keys - keySet := make(map[string]struct{}) - var columns []table.Column - - for _, item := range array { - for _, key := range item.Get("@keys").Array() { - if _, exists := keySet[key.Str]; !exists { - keySet[key.Str] = struct{}{} - title := key.Str - columns = append(columns, table.Column{Title: title, Width: defaultColumnWidth}) - } - } - } - - rows := make([]table.Row, 0, len(array)) - rowData := make([]gjson.Result, 0, len(array)) - - for _, item := range array { - row := make(table.Row, len(columns)) - for i, col := range columns { - row[i] = formatValue(item.Get(col.Title), raw) - } - rows = append(rows, row) - rowData = append(rowData, item) - } - - t := createTable(columns, rows, arrayColor) - return &TableView{ - path: path, - data: data, - table: t, - rowData: rowData, - columns: columns, - } -} - -func newObjectTableView(path string, data gjson.Result, raw bool) *TableView { - columns := []table.Column{{Title: "Object"}, {}} - - keys := data.Get("@keys").Array() - rows := make([]table.Row, 0, len(keys)) - rowData := make([]gjson.Result, 0, len(keys)) - - for _, key := range keys { - value := data.Get(key.Str) - title := key.Str - rows = append(rows, table.Row{title, formatValue(value, raw)}) - rowData = append(rowData, value) - } - - // Adjust column widths based on content - for _, row := range rows { - for i, cell := range row { - if i < len(columns) { - columns[i].Width = max(columns[i].Width, lipgloss.Width(cell)) - } - } - } - - t := createTable(columns, rows, objectColor) - return &TableView{ - path: path, - data: data, - table: t, - rowData: rowData, - columns: columns, - } -} - -func createTable(columns []table.Column, rows []table.Row, bgColor lipgloss.Color) table.Model { - t := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - ) - - // Set common table styles - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(true) - s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(bgColor). - Bold(false) - t.SetStyles(s) - - return t -} - -func formatValue(value gjson.Result, raw bool) string { - if raw { - return value.Get("@ugly").Raw - } - - switch { - case value.IsObject(): - return formatObject(value) - case value.IsArray(): - return formatArray(value) - case value.Type == gjson.String: - return value.Str - default: - return value.Raw - } -} - -func formatObject(value gjson.Result) string { - keys := value.Get("@keys").Array() - keyStrs := make([]string, len(keys)) - - for i, key := range keys { - val := value.Get(key.Str) - keyStrs[i] = formatObjectKey(key.Str, val) - } - - return "{" + strings.Join(keyStrs, ", ") + "}" -} - -func formatObjectKey(key string, val gjson.Result) string { - switch { - case val.IsObject(): - return key + ":{…}" - case val.IsArray(): - return key + ":[…]" - case val.Type == gjson.String: - str := val.Str - if lipgloss.Width(str) <= maxPreviewLength { - return fmt.Sprintf(`%s:"%s"`, key, str) - } - return fmt.Sprintf(`%s:"%s…"`, key, truncate.String(str, uint(maxPreviewLength))) - default: - return key + ":" + val.Raw - } -} - -func formatArray(value gjson.Result) string { - switch count := len(value.Array()); count { - case 0: - return "[]" - case 1: - return "[...1 item...]" - default: - return fmt.Sprintf("[...%d items...]", count) - } -} - -func isArrayOfObjects(array []gjson.Result) bool { - for _, item := range array { - if !item.IsObject() { - return false - } - } - return len(array) > 0 -} - -func sum(ints []int) int { - total := 0 - for _, n := range ints { - total += n - } - return total -} - -// An iterator over `any` values -type AnyIterator interface { - Next() bool - Err() error - Current() any -} - -// A generic iterator interface that is used by the `genericIterator` struct -// below to convert iterators over specific types to an AnyIterator -type Iterator[T any] interface { - Next() bool - Err() error - Current() T -} - -// genericIterator adapts a generic Iterator[T] to an AnyIterator. -type genericIterator[T any] struct { - iterator Iterator[T] - current any -} - -func (g *genericIterator[T]) Next() bool { - if !g.iterator.Next() { - return false - } - g.current = g.iterator.Current() - return true -} - -func (g *genericIterator[T]) Err() error { - return g.iterator.Err() -} - -func (g *genericIterator[T]) Current() any { - return g.current -} - -func genericToAnyIterator[T any](it Iterator[T]) AnyIterator { - return &genericIterator[T]{ - iterator: it, - } -} diff --git a/internal/jsonview/explorer_test.go b/internal/jsonview/explorer_test.go deleted file mode 100644 index 67ee730..0000000 --- a/internal/jsonview/explorer_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package jsonview - -import ( - "testing" - - "github.com/charmbracelet/bubbles/help" - "github.com/tidwall/gjson" - - "github.com/stretchr/testify/require" -) - -func TestNavigateForward_EmptyRowData(t *testing.T) { - t.Parallel() - - // An empty JSON array produces a TableView with no rows. - emptyArray := gjson.Parse("[]") - view, err := newTableView("", emptyArray, false) - require.NoError(t, err) - - viewer := &JSONViewer{ - stack: []JSONView{view}, - root: "test", - help: help.New(), - } - - // Should return without panicking despite the empty data set. - model, cmd := viewer.navigateForward() - require.Equal(t, model, viewer, "expected same viewer model returned") - require.Nil(t, cmd) - - // Stack should remain unchanged (no new view pushed). - require.Equal(t, 1, len(viewer.stack), "expected stack length 1, got %d", len(viewer.stack)) -} - -// rawJSONItem implements HasRawJSON, returning pre-built JSON. -type rawJSONItem struct { - raw string -} - -func (r rawJSONItem) RawJSON() string { return r.raw } - -func TestMarshalItemsToJSONArray_WithHasRawJSON(t *testing.T) { - t.Parallel() - - items := []any{ - rawJSONItem{raw: `{"id":1,"name":"alice"}`}, - rawJSONItem{raw: `{"id":2,"name":"bob"}`}, - } - - got, err := marshalItemsToJSONArray(items) - require.NoError(t, err) - require.JSONEq(t, `[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]`, string(got)) -} - -func TestMarshalItemsToJSONArray_WithoutHasRawJSON(t *testing.T) { - t.Parallel() - - items := []any{ - map[string]any{"id": 1, "name": "alice"}, - map[string]any{"id": 2, "name": "bob"}, - } - - got, err := marshalItemsToJSONArray(items) - require.NoError(t, err) - require.JSONEq(t, `[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]`, string(got)) -} diff --git a/internal/jsonview/staticdisplay.go b/internal/jsonview/staticdisplay.go deleted file mode 100644 index 768ea34..0000000 --- a/internal/jsonview/staticdisplay.go +++ /dev/null @@ -1,135 +0,0 @@ -package jsonview - -import ( - "fmt" - "os" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/term" - "github.com/muesli/reflow/truncate" - "github.com/tidwall/gjson" -) - -const ( - tabWidth = 2 -) - -var ( - keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("75")).Bold(false) - stringValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("113")) - numberValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("215")) - boolValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("207")) - nullValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Italic(true) - bulletStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("242")) - containerStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("63")). - Padding(0, 1) -) - -func formatJSON(json gjson.Result, width int) string { - if !json.Exists() { - return nullValueStyle.Render("Invalid JSON") - } - return formatResult(json, 0, width) -} - -func formatResult(result gjson.Result, indent, width int) string { - switch result.Type { - case gjson.String: - str := result.Str - if str == "" { - return nullValueStyle.Render("(empty)") - } - if lipgloss.Width(str) > width { - str = truncate.String(str, uint(width-1)) + "…" - } - return stringValueStyle.Render(str) - case gjson.Number: - return numberValueStyle.Render(result.Raw) - case gjson.True: - return boolValueStyle.Render("yes") - case gjson.False: - return boolValueStyle.Render("no") - case gjson.Null: - return nullValueStyle.Render("null") - case gjson.JSON: - if result.IsArray() { - return formatJSONArray(result, indent, width) - } - return formatJSONObject(result, indent, width) - default: - return stringValueStyle.Render(result.String()) - } -} - -func isSingleLine(result gjson.Result, indent int) bool { - return !(result.IsObject() || result.IsArray()) -} - -func formatJSONArray(result gjson.Result, indent, width int) string { - items := result.Array() - if len(items) == 0 { - return nullValueStyle.Render(" (none)") - } - - numberWidth := lipgloss.Width(fmt.Sprintf("%d. ", len(items))) - - var formattedItems []string - for i, item := range items { - number := fmt.Sprintf("%d.", i+1) - numbering := getIndent(indent) + bulletStyle.Render(number) - - // If the item will be a one-liner, put it inline after the numbering, - // otherwise it starts with a newline and goes below the numbering. - itemWidth := width - if isSingleLine(item, indent+1) { - // Add right-padding: - numbering += strings.Repeat(" ", numberWidth-lipgloss.Width(number)) - itemWidth = width - lipgloss.Width(numbering) - } - value := formatResult(item, indent+1, itemWidth) - formattedItems = append(formattedItems, numbering+value) - } - return "\n" + strings.Join(formattedItems, "\n") -} - -func formatJSONObject(result gjson.Result, indent, width int) string { - keys := result.Get("@keys").Array() - if len(keys) == 0 { - return nullValueStyle.Render("(empty)") - } - - var items []string - for _, key := range keys { - value := result.Get(key.String()) - keyStr := getIndent(indent) + keyStyle.Render(key.String()+":") - // If item will be a one-liner, put it inline after the key, otherwise - // it starts with a newline and goes below the key. - itemWidth := width - if isSingleLine(value, indent+1) { - keyStr += " " - itemWidth = width - lipgloss.Width(keyStr) - } - formattedValue := formatResult(value, indent+1, itemWidth) - items = append(items, keyStr+formattedValue) - } - - return "\n" + strings.Join(items, "\n") -} - -func getIndent(indent int) string { - return strings.Repeat(" ", indent*tabWidth) -} - -func RenderJSON(title string, json gjson.Result) string { - width, _, err := term.GetSize(os.Stdout.Fd()) - if err != nil { - width = 80 - } - width -= containerStyle.GetBorderLeftSize() + containerStyle.GetBorderRightSize() + - containerStyle.GetPaddingLeft() + containerStyle.GetPaddingRight() - content := strings.TrimLeft(formatJSON(json, width), "\n") - return titleStyle.Render(title) + "\n" + containerStyle.Render(content) -} diff --git a/internal/mocktest/mocktest.go b/internal/mocktest/mocktest.go deleted file mode 100644 index a897833..0000000 --- a/internal/mocktest/mocktest.go +++ /dev/null @@ -1,101 +0,0 @@ -package mocktest - -import ( - "bytes" - "context" - "fmt" - "net" - "net/http" - "net/url" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var mockServerURL *url.URL - -func init() { - mockServerURL, _ = url.Parse("http://localhost:4010") - if testURL := os.Getenv("TEST_API_BASE_URL"); testURL != "" { - if parsed, err := url.Parse(testURL); err == nil { - mockServerURL = parsed - } - } -} - -// OnlyMockServerDialer only allows network connections to the mock server -type OnlyMockServerDialer struct{} - -func (d *OnlyMockServerDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { - if address == mockServerURL.Host { - return (&net.Dialer{}).DialContext(ctx, network, address) - } - - return nil, fmt.Errorf("BLOCKED: connection to %s not allowed (only allowed: %s)", address, mockServerURL.Host) -} - -func blockNetworkExceptMockServer() (http.RoundTripper, http.RoundTripper) { - restricted := &http.Transport{ - DialContext: (&OnlyMockServerDialer{}).DialContext, - } - - origClient, origDefault := http.DefaultClient.Transport, http.DefaultTransport - http.DefaultClient.Transport, http.DefaultTransport = restricted, restricted - return origClient, origDefault -} - -func restoreNetwork(origClient, origDefault http.RoundTripper) { - http.DefaultClient.Transport, http.DefaultTransport = origClient, origDefault -} - -// TestRunMockTestWithFlags runs a test against a mock server with the provided -// CLI args and ensures it succeeds -func TestRunMockTestWithFlags(t *testing.T, args ...string) { - TestRunMockTestWithPipeAndFlags(t, nil, args...) -} - -// TestRunMockTestWithPipeAndFlags runs a test against a mock server with the provided -// data piped over stdin and CLI args and ensures it succeeds -func TestRunMockTestWithPipeAndFlags(t *testing.T, pipeData []byte, args ...string) { - origClient, origDefault := blockNetworkExceptMockServer() - defer restoreNetwork(origClient, origDefault) - - // Check if mock server is running - conn, err := net.DialTimeout("tcp", mockServerURL.Host, 2*time.Second) - if err != nil { - require.Fail(t, "Mock server is not running on "+mockServerURL.Host+". Please start the mock server before running tests.") - } else { - conn.Close() - } - - // Get the path to the main command - _, filename, _, ok := runtime.Caller(0) - require.True(t, ok, "Could not get current file path") - dirPath := filepath.Dir(filename) - project := filepath.Join(dirPath, "..", "..", "cmd", "dedalus") - - args = append([]string{"run", project, "--base-url", mockServerURL.String()}, args...) - - t.Logf("Testing command: go run ./cmd/dedalus %s", strings.Join(args[2:], " ")) - - cmd := exec.Command("go", args...) - cmd.Stdin = bytes.NewReader(pipeData) - output, err := cmd.CombinedOutput() - assert.NoError(t, err, "Test failed\nError: %v\nOutput: %s", err, output) - - t.Logf("Test passed successfully\nOutput:\n%s", string(output)) -} - -func TestFile(t *testing.T, contents string) string { - tmpDir := t.TempDir() - filename := filepath.Join(tmpDir, "file.txt") - require.NoError(t, os.WriteFile(filename, []byte(contents), 0644)) - return filename -} diff --git a/internal/requestflag/innerflag.go b/internal/requestflag/innerflag.go deleted file mode 100644 index 528915f..0000000 --- a/internal/requestflag/innerflag.go +++ /dev/null @@ -1,289 +0,0 @@ -package requestflag - -import ( - "fmt" - "reflect" - "strings" - - "github.com/urfave/cli/v3" -) - -// InnerFlag[T] represents a CLI flag for the urfave/cli package that allows setting -// nested fields within other flags. For example, using `--foo.baz` will set the "baz" -// field on a parent flag named `--foo`. -type InnerFlag[ - T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | - []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | - string | float64 | int64 | bool | - *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, -] struct { - Name string // name of the flag - DefaultText string // default text of the flag for usage purposes - Usage string // usage string for help output - Aliases []string // aliases that are allowed for this flag - Validator func(T) error // custom function to validate this flag value - - OuterFlag cli.Flag // The flag on which this inner flag will set values - InnerField string // The inner field which this flag will set - DataAliases []string // alternate names recognized in YAML values passed as the outer flag - - // OuterIsArrayOfObjects tells an untyped outer flag (Flag[any], used for nullable - // complex schemas) to seed its underlying value as []map[string]any rather than - // map[string]any before SetInnerField runs. The hint is ignored for typed outer - // flags whose zero value already carries a dispatchable reflect.Kind. - OuterIsArrayOfObjects bool -} - -// GetDataAliases returns the aliases recognized when parsing inner field keys from piped or flag YAML. -func (f *InnerFlag[T]) GetDataAliases() []string { - return f.DataAliases -} - -// GetInnerField returns the API field name that this inner flag sets on its outer flag's value. -// For example, the flag --parent.foo targeting a parameter whose OpenAPI property name is "foo" -// would return "foo". This is distinct from the flag's CLI name and from any DataAliases entries. -func (f *InnerFlag[T]) GetInnerField() string { - return f.InnerField -} - -type HasOuterFlag interface { - cli.Flag - SetOuterFlag(cli.Flag) - GetOuterFlag() cli.Flag - GetInnerField() string - GetDataAliases() []string -} - -func (f *InnerFlag[T]) SetOuterFlag(flag cli.Flag) { - f.OuterFlag = flag -} - -func (f *InnerFlag[T]) GetOuterFlag() cli.Flag { - return f.OuterFlag -} - -// Implementation of the cli.Flag interface -var _ cli.Flag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance - -func (f *InnerFlag[T]) PreParse() error { - return nil -} - -func (f *InnerFlag[T]) PostParse() error { - return nil -} - -func (f *InnerFlag[T]) Set(name string, rawVal string) error { - if parsedValue, err := parseCLIArg[T](rawVal); err != nil { - return err - } else { - if f.Validator != nil { - if err := f.Validator(parsedValue); err != nil { - return err - } - } - - if seeder, ok := f.OuterFlag.(InnerFieldSeeder); ok { - seeder.SeedInnerCollection(f.OuterIsArrayOfObjects) - } - - if settableInnerField, ok := f.OuterFlag.(SettableInnerField); ok { - settableInnerField.SetInnerField(f.InnerField, parsedValue) - } else { - return fmt.Errorf("Cannot set inner field on %v", f.OuterFlag) - } - return nil - } -} - -func (f *InnerFlag[T]) Get() any { - var zeroValue T - return zeroValue -} - -func (f *InnerFlag[T]) String() string { - return cli.FlagStringer(f) -} - -func (f *InnerFlag[T]) IsSet() bool { - return false -} - -func (f *InnerFlag[T]) Names() []string { - return cli.FlagNames(f.Name, f.Aliases) -} - -// Implementation for the cli.DocGenerationFlag interface -var _ cli.DocGenerationFlag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance - -func (f *InnerFlag[T]) TakesValue() bool { - var t T - return reflect.TypeOf(t) == nil || reflect.TypeOf(t).Kind() != reflect.Bool -} - -func (f *InnerFlag[T]) GetUsage() string { - return f.Usage -} - -func (f *InnerFlag[T]) GetValue() string { - return "" -} - -func (f *InnerFlag[T]) GetDefaultText() string { - return f.DefaultText -} - -func (f *InnerFlag[T]) GetEnvVars() []string { - return nil -} - -func (f *InnerFlag[T]) IsDefaultVisible() bool { - return false -} - -func (f *InnerFlag[T]) TypeName() string { - var zeroValue T - ty := reflect.TypeOf(zeroValue) - if ty == nil { - return "" - } - if ty.Kind() == reflect.Pointer { - ty = ty.Elem() - } - - // Get base type name with special handling for built-in types - getTypeName := func(t reflect.Type) string { - switch t.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return "int" - case reflect.Float32, reflect.Float64: - return "float" - case reflect.Bool: - return "boolean" - case reflect.String: - switch t.Name() { - case "DateTimeValue": - return "datetime" - case "DateValue": - return "date" - case "TimeValue": - return "time" - default: - return "string" - } - default: - if t.Name() == "" { - return "any" - } - return strings.ToLower(t.Name()) - } - } - - switch ty.Kind() { - case reflect.Slice: - elemType := ty.Elem() - return getTypeName(elemType) - case reflect.Map: - keyType := ty.Key() - valueType := ty.Elem() - return fmt.Sprintf("%s=%s", getTypeName(keyType), getTypeName(valueType)) - default: - return getTypeName(ty) - } -} - -// Implementation for the cli.DocGenerationMultiValueFlag interface -var _ cli.DocGenerationMultiValueFlag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance - -func (f *InnerFlag[T]) IsMultiValueFlag() bool { - return false -} - -func (f *InnerFlag[T]) IsBoolFlag() bool { - var zeroValue T - _, isBool := any(zeroValue).(bool) - return isBool -} - -// WithInnerFlags takes a command and a map of flag names to inner flags, -// and returns a modified command with the appropriate inner flags set. -func WithInnerFlags(cmd cli.Command, innerFlagMap map[string][]HasOuterFlag) cli.Command { - if len(innerFlagMap) == 0 { - return cmd - } - - // If any keys are unused by the end, we know that they were not valid - unusedInnerFlagKeys := make(map[string]struct{}) - for name := range innerFlagMap { - unusedInnerFlagKeys[name] = struct{}{} - } - - updatedFlags := make([]cli.Flag, 0, len(cmd.Flags)) - for _, flag := range cmd.Flags { - updatedFlags = append(updatedFlags, flag) - for _, name := range flag.Names() { - // Check if this flag has inner flags in our map - innerFlags, hasInnerFlags := innerFlagMap[name] - if !hasInnerFlags { - continue - } - - // Mark this inner flag key as used - delete(unusedInnerFlagKeys, name) - - for _, innerFlag := range innerFlags { - innerFlag.SetOuterFlag(flag) - updatedFlags = append(updatedFlags, innerFlag) - } - } - } - - // If there are inner flags that don't correspond to any valid outer flag - // names, then panic because the user probably made a typo or forgot to - // delete inner flags that correspond to missing outer flags. - if len(unusedInnerFlagKeys) > 0 { - unusedKeys := make([]string, 0, len(unusedInnerFlagKeys)) - for key := range unusedInnerFlagKeys { - unusedKeys = append(unusedKeys, key) - } - panic(fmt.Sprintf("Missing outer flags to use with inner flags: %v", unusedKeys)) - } - - result := cmd - result.Flags = updatedFlags - return result -} - -// Helper function to verify that all inner flags have an outer flag set and -// follow the --foo.baz prefix format -func CheckInnerFlags(cmd cli.Command) error { - var errors []string - for _, flag := range cmd.Flags { - if innerFlag, ok := flag.(HasOuterFlag); ok { - outerFlag := innerFlag.GetOuterFlag() - if outerFlag == nil { - errors = append(errors, fmt.Sprintf("inner flag %s is missing an outer flag", flag.Names())) - continue - } - - innerFlagName := flag.Names()[0] - valid := false - for _, outerName := range outerFlag.Names() { - if strings.HasPrefix(innerFlagName, outerName+".") { - valid = true - break - } - } - - if !valid { - errors = append(errors, fmt.Sprintf("inner flag %s must start with one of its outer flag's names followed by a dot", innerFlagName)) - } - } - } - - if len(errors) > 0 { - return fmt.Errorf("%s", strings.Join(errors, "; ")) - } - return nil -} diff --git a/internal/requestflag/innerflag_test.go b/internal/requestflag/innerflag_test.go deleted file mode 100644 index 133e8b4..0000000 --- a/internal/requestflag/innerflag_test.go +++ /dev/null @@ -1,347 +0,0 @@ -package requestflag - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/urfave/cli/v3" -) - -func TestInnerFlagSet(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - flagType string - inputVal string - expected any - expectErr bool - }{ - {"string", "string", "hello", "hello", false}, - {"int64", "int64", "42", int64(42), false}, - {"float64", "float64", "3.14", float64(3.14), false}, - {"bool", "bool", "true", true, false}, - {"invalid int", "int64", "not-a-number", nil, true}, - {"invalid float", "float64", "not-a-float", nil, true}, - {"invalid bool", "bool", "not-a-bool", nil, true}, - {"yaml map", "map", "key: value", map[string]any{"key": "value"}, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{ - Name: "test-flag", - } - - var innerFlag cli.Flag - switch tt.flagType { - case "string": - innerFlag = &InnerFlag[string]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - case "int64": - innerFlag = &InnerFlag[int64]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - case "float64": - innerFlag = &InnerFlag[float64]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - case "bool": - innerFlag = &InnerFlag[bool]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - case "map": - innerFlag = &InnerFlag[map[string]any]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - } - - err := innerFlag.Set(innerFlag.Names()[0], tt.inputVal) - - if tt.expectErr { - assert.Error(t, err) - return - } - - assert.NoError(t, err) - actual, ok := outerFlag.Get().(map[string]any)["test_field"] - assert.True(t, ok, "Field 'test_field' should exist in the map") - assert.Equal(t, tt.expected, actual, "Expected %v (%T), got %v (%T)", tt.expected, tt.expected, actual, actual) - }) - } -} - -func TestInnerFlagValidator(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{Name: "test-flag"} - - innerFlag := &InnerFlag[int64]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - Validator: func(val int64) error { - if val < 0 { - return cli.Exit("Value must be non-negative", 1) - } - return nil - }, - } - - // Valid case - err := innerFlag.Set(innerFlag.Name, "42") - assert.NoError(t, err, "Expected no error for valid value, got: %v", err) - - // Should trigger validator error - err = innerFlag.Set(innerFlag.Name, "-5") - assert.Error(t, err, "Expected error for invalid value, got none") -} - -func TestWithInnerFlags(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{Name: "outer"} - innerFlag := &InnerFlag[string]{ - Name: "outer.baz", - InnerField: "baz", - } - - cmd := WithInnerFlags(cli.Command{ - Name: "test-command", - Flags: []cli.Flag{outerFlag}, - }, map[string][]HasOuterFlag{ - "outer": {innerFlag}, - }) - - // Verify that the command now has both the original flag and inner flag - assert.Len(t, cmd.Flags, 2, "Expected 2 flags, got %d", len(cmd.Flags)) - assert.Equal(t, outerFlag, cmd.Flags[0], "First flag should be outerFlag") - assert.Equal(t, innerFlag, cmd.Flags[1], "Second flag should be innerFlag") - assert.Same(t, outerFlag, innerFlag.OuterFlag, "innerFlag.OuterFlag should point to outerFlag") -} - -func TestInnerFlagTypeNames(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - flag cli.DocGenerationFlag - expected string - }{ - {"string", &InnerFlag[string]{}, "string"}, - {"int64", &InnerFlag[int64]{}, "int"}, - {"float64", &InnerFlag[float64]{}, "float"}, - {"bool", &InnerFlag[bool]{}, "boolean"}, - {"string slice", &InnerFlag[[]string]{}, "string"}, - {"date", &InnerFlag[DateValue]{}, "date"}, - {"datetime", &InnerFlag[DateTimeValue]{}, "datetime"}, - {"time", &InnerFlag[TimeValue]{}, "time"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - typeName := tt.flag.TypeName() - assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) - }) - } -} - -func TestInnerYamlHandling(t *testing.T) { - t.Parallel() - - // Test with map value - t.Run("Parse YAML to map", func(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{Name: "outer"} - innerFlag := &InnerFlag[map[string]any]{ - Name: "outer.baz", - OuterFlag: outerFlag, - InnerField: "baz", - } - - err := innerFlag.Set(innerFlag.Name, "{name: test, value: 42}") - assert.NoError(t, err) - - // Retrieve and check the parsed YAML map - result, ok := outerFlag.Get().(map[string]any) - assert.True(t, ok, "Expected map[string]any from outerFlag.Get()") - yamlField, ok := result["baz"].(map[string]any) - assert.True(t, ok, "Expected map[string]any, got %T", result["baz"]) - val := yamlField - - if ok { - assert.Equal(t, map[string]any{"name": "test", "value": uint64(42)}, val) - } - }) - - // Test with invalid YAML - t.Run("Parse invalid YAML", func(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{Name: "outer"} - innerFlag := &InnerFlag[map[string]any]{ - Name: "outer.baz", - OuterFlag: outerFlag, - InnerField: "baz", - } - - invalidYaml := `[not closed` - err := innerFlag.Set(innerFlag.Name, invalidYaml) - assert.Error(t, err) - }) - - // Test setting inner flags on a map multiple times - t.Run("Set inner flags on map multiple times", func(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{Name: "outer"} - - // Set first inner flag - firstInnerFlag := &InnerFlag[string]{ - Name: "outer.first-flag", - OuterFlag: outerFlag, - InnerField: "first_field", - } - - err := firstInnerFlag.Set(firstInnerFlag.Name, "first-value") - assert.NoError(t, err) - - // Set second inner flag - secondInnerFlag := &InnerFlag[int64]{ - Name: "outer.second-flag", - OuterFlag: outerFlag, - InnerField: "second_field", - } - - err = secondInnerFlag.Set(secondInnerFlag.Name, "42") - assert.NoError(t, err) - - // Verify both fields are set correctly - result := outerFlag.Get().(map[string]any) - assert.Equal(t, map[string]any{"first_field": "first-value", "second_field": int64(42)}, result) - }) - - // Test setting YAML and then an inner flag - t.Run("Set YAML and then inner flag", func(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{Name: "outer"} - - // First set the outer flag with YAML - err := outerFlag.Set(outerFlag.Name, `{existing: value, another: field}`) - assert.NoError(t, err) - - // Then set an inner flag - innerFlag := &InnerFlag[string]{ - Name: "outer.inner-flag", - OuterFlag: outerFlag, - InnerField: "new_field", - } - - err = innerFlag.Set(innerFlag.Name, "inner-value") - assert.NoError(t, err) - - // Verify both the YAML content and inner flag value - result := outerFlag.Get().(map[string]any) - assert.Equal(t, map[string]any{ - "existing": "value", - "another": "field", - "new_field": "inner-value", - }, result) - }) -} - -func TestInnerFlagWithSliceType(t *testing.T) { - t.Parallel() - - t.Run("Setting inner flags on slice of maps", func(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[[]map[string]any]{Name: "outer"} - - // Set first inner flag (should create first item) - firstInnerFlag := &InnerFlag[string]{ - Name: "outer.name-flag", - OuterFlag: outerFlag, - InnerField: "name", - } - - err := firstInnerFlag.Set(firstInnerFlag.Name, "item1") - assert.NoError(t, err) - - // Set second inner flag (should modify first item) - secondInnerFlag := &InnerFlag[int64]{ - Name: "outer.count-flag", - OuterFlag: outerFlag, - InnerField: "count", - } - - err = secondInnerFlag.Set(secondInnerFlag.Name, "42") - assert.NoError(t, err) - - // Set name flag again (should create second item) - err = firstInnerFlag.Set(firstInnerFlag.Name, "item2") - assert.NoError(t, err) - - // Verify the slice has two items with correct values - result := outerFlag.Get().([]map[string]any) - - assert.Equal(t, []map[string]any{ - {"name": "item1", "count": int64(42)}, - {"name": "item2"}, - }, result) - assert.Nil(t, result[1]["count"], "Second item should not have count field") - }) - - t.Run("Appending to existing slice", func(t *testing.T) { - t.Parallel() - - // Initialize with existing items - outerFlag := &Flag[[]map[string]any]{Name: "outer"} - err := outerFlag.Set(outerFlag.Name, `{name: initial}`) - assert.NoError(t, err) - - // Set inner flag to modify existing item - modifyFlag := &InnerFlag[string]{ - Name: "outer.value-flag", - OuterFlag: outerFlag, - InnerField: "value", - } - - err = modifyFlag.Set(modifyFlag.Name, "updated") - assert.NoError(t, err) - - // Set inner flag to create new item - newItemFlag := &InnerFlag[string]{ - Name: "outer.name-flag", - OuterFlag: outerFlag, - InnerField: "name", - } - - err = newItemFlag.Set(newItemFlag.Name, "second") - assert.NoError(t, err) - - // Verify both items - result := outerFlag.Get().([]map[string]any) - assert.Equal(t, []map[string]any{ - {"name": "initial", "value": "updated"}, - {"name": "second"}, - }, result) - }) -} diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go deleted file mode 100644 index 77c4f1f..0000000 --- a/internal/requestflag/requestflag.go +++ /dev/null @@ -1,992 +0,0 @@ -package requestflag - -import ( - "encoding/json" - "fmt" - "reflect" - "strconv" - "strings" - "time" - "unicode" - - "github.com/goccy/go-yaml" - "github.com/urfave/cli/v3" -) - -// formatForFlagSet converts a Go value parsed from YAML/JSON stdin data into a string -// that flag.Set (and thus parseCLIArg) can parse correctly for each flag type. -// Strings are returned as-is (parseCLIArg[string] assigns the raw value directly, so -// JSON-quoting must be avoided). Scalars use %v. Complex types (maps, slices) are -// JSON-encoded, which the yaml.Unmarshal default branch in parseCLIArg can parse. -func formatForFlagSet(val any) (string, error) { - switch v := val.(type) { - case string: - return v, nil - case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: - return fmt.Sprintf("%v", val), nil - default: - b, err := json.Marshal(val) - if err != nil { - return "", fmt.Errorf("cannot format value %T for flag.Set: %w", val, err) - } - return string(b), nil - } -} - -// Flag [T] is a generic flag base which can be used to implement the most -// common interfaces used by urfave/cli. Additionally, it allows specifying -// where in an HTTP request the flag values should be placed (e.g. query, body, etc.). -// -// Pointer-to-primitive type parameters (e.g. *string) are used for flags whose underlying -// schema is nullable. They give flags a tri-state: unset (excluded from the request), -// set to the literal "null" (nil pointer → JSON null), or set to a value (*v → JSON value). -type Flag[ - T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | - []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | - string | float64 | int64 | bool | - *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, -] struct { - Name string // name of the flag - Category string // category of the flag, if any - DefaultText string // default text of the flag for usage purposes - HideDefault bool // whether to hide the default value in output - Usage string // usage string for help output - Sources cli.ValueSourceChain // sources to load flag value from - Required bool // whether the flag is required or not - Hidden bool // whether to hide the flag in help output - Default T // default value for this flag if not set by from any source - Aliases []string // aliases that are allowed for this flag - Validator func(T) error // custom function to validate this flag value - - QueryPath string // location in the request query string to put this flag's value - HeaderPath string // location in the request header to put this flag's value - BodyPath string // location in the request body to put this flag's value - BodyRoot bool // if true, then use this value as the entire request body - PathParam string // name of the URL path parameter this flag's value maps to - - // Const, when true, marks this flag as a constant. The flag's Default value is used as the fixed value - // and always included in the request (IsSet returns true). The user can still see and override the flag, - // but isn't required to provide it. This is used for single-value enums and `x-stainless-const` - // parameters. - Const bool - - // FileInput, when true, indicates that the flag value is always treated as a file path. The file is read - // automatically without requiring the "@" prefix. This is used for parameters with `type: string, format: - // binary` in the OpenAPI spec. - FileInput bool - - // DataAliases is a list of alternate names for this parameter recognized when parsing piped YAML/JSON - // input. Values keyed by any alias are translated to the canonical API name before being sent. - DataAliases []string - - // unexported fields for internal use - count int // number of times the flag has been set - hasBeenSet bool // whether the flag has been set from env or file - applied bool // whether the flag has been applied to a flag set already - value cli.Value // value representing this flag's value -} - -// Type assertions to verify we implement the relevant urfave/cli interfaces -var _ cli.CategorizableFlag = (*Flag[any])(nil) - -// InRequest interface for flags that should be included in HTTP requests -type InRequest interface { - GetQueryPath() string - GetHeaderPath() string - GetBodyPath() string - GetPathParam() string - IsBodyRoot() bool - IsFileInput() bool - GetDataAliases() []string -} - -func (f Flag[T]) GetQueryPath() string { - return f.QueryPath -} - -func (f Flag[T]) GetHeaderPath() string { - return f.HeaderPath -} - -func (f Flag[T]) GetBodyPath() string { - return f.BodyPath -} - -func (f Flag[T]) GetPathParam() string { - return f.PathParam -} - -func (f Flag[T]) IsBodyRoot() bool { - return f.BodyRoot -} - -func (f Flag[T]) IsFileInput() bool { - return f.FileInput -} - -func (f Flag[T]) GetDataAliases() []string { - return f.DataAliases -} - -// The values that will be sent in different parts of a request. -type RequestContents struct { - Queries map[string]any - Headers map[string]any - Body any -} - -// ApplyStdinDataToFlags sets flag values from a parsed stdin data map for flags that have not already been -// set via the command line. This allows piped YAML/JSON data to satisfy path, query, and header parameters. -// Body parameters are excluded: they are already handled by the maps.Copy merge in flagOptions. -// For each unset flag, if the parsed data map contains a key matching the flag's QueryPath, HeaderPath, or -// PathParam (or any of its DataAliases), the flag is set to that value via flag.Set. -// -// Inner flags (those with an outer flag) are also handled: if the outer flag's body path key exists in the -// data map and contains a nested map with a key matching the inner flag's field (or aliases), the inner -// flag is set from that nested value. -func ApplyStdinDataToFlags(cmd *cli.Command, data map[string]any) error { - for _, flag := range cmd.Flags { - if flag.IsSet() { - continue - } - - // Handle inner flags: look for their value nested under the outer flag's body path. - if inner, ok := flag.(HasOuterFlag); ok { - outer, outerOk := inner.GetOuterFlag().(InRequest) - if !outerOk || outer.GetBodyPath() == "" { - continue - } - nested, ok := data[outer.GetBodyPath()].(map[string]any) - if !ok { - continue - } - innerField := inner.GetInnerField() - val, found := nested[innerField] - if !found { - for _, alias := range inner.GetDataAliases() { - if alias != "" && alias != innerField { - if v, ok := nested[alias]; ok { - val, found = v, true - break - } - } - } - } - if !found { - continue - } - setVal, err := formatForFlagSet(val) - if err != nil { - return fmt.Errorf("cannot format piped value for flag %q: %w", flag.Names()[0], err) - } - if err := flag.Set(flag.Names()[0], setVal); err != nil { - return fmt.Errorf("cannot set flag %q from piped data: %w", flag.Names()[0], err) - } - continue - } - - inReq, ok := flag.(InRequest) - if !ok { - continue - } - - // Try each request location in turn, checking the canonical path key and all aliases. - // Body params are excluded: they are already handled by the maps.Copy merge in flagOptions. - for _, path := range []string{inReq.GetQueryPath(), inReq.GetHeaderPath(), inReq.GetPathParam()} { - if path == "" { - continue - } - var val any - var found bool - for _, key := range append([]string{path}, inReq.GetDataAliases()...) { - if v, ok := data[key]; ok { - val, found = v, true - break - } - } - if !found { - continue - } - setVal, err := formatForFlagSet(val) - if err != nil { - return fmt.Errorf("cannot format piped value for flag %q: %w", flag.Names()[0], err) - } - if err := flag.Set(flag.Names()[0], setVal); err != nil { - return fmt.Errorf("cannot set flag %q from piped data: %w", flag.Names()[0], err) - } - break - } - } - return nil -} - -func ExtractRequestContents(cmd *cli.Command) RequestContents { - bodyMap := make(map[string]any) - res := RequestContents{ - Queries: make(map[string]any), - Headers: make(map[string]any), - Body: bodyMap, - } - - for _, flag := range cmd.Flags { - if !flag.IsSet() { - continue - } - - value := flag.Get() - if toSend, ok := flag.(InRequest); ok { - if queryPath := toSend.GetQueryPath(); queryPath != "" { - res.Queries[queryPath] = value - } - if headerPath := toSend.GetHeaderPath(); headerPath != "" { - res.Headers[headerPath] = value - } - if toSend.IsBodyRoot() { - res.Body = value - } else if bodyPath := toSend.GetBodyPath(); bodyPath != "" { - bodyMap[bodyPath] = value - } - } - } - return res -} - -func GetMissingRequiredFlags(cmd *cli.Command, body any) []cli.Flag { - missing := []cli.Flag{} - for _, flag := range cmd.Flags { - if flag.IsSet() { - continue - } - - if required, ok := flag.(cli.RequiredFlag); ok && required.IsRequired() { - missing = append(missing, flag) - continue - } - - if r, ok := flag.(RequiredFlagOrStdin); !ok || !r.IsRequiredAsFlagOrStdin() { - continue - } - - if toSend, ok := flag.(InRequest); ok { - if toSend.IsBodyRoot() { - if body != nil { - continue - } - } else if bodyPath := toSend.GetBodyPath(); bodyPath != "" { - if bodyMap, ok := body.(map[string]any); ok { - if _, found := bodyMap[bodyPath]; found { - continue - } - } - } - } - missing = append(missing, flag) - } - return missing -} - -// Implementation of the cli.Flag interface -var _ cli.Flag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) PreParse() error { - newVal := f.Default - f.value = &cliValue[T]{newVal} - - // Validate the given default or values set from external sources as well - if f.Validator != nil { - if err := f.Validator(f.value.Get().(T)); err != nil { - return err - } - } - f.applied = true - return nil -} - -func (f *Flag[T]) PostParse() error { - if !f.hasBeenSet { - if val, source, found := f.Sources.LookupWithSource(); found { - if val != "" || reflect.TypeOf(f.value).Kind() == reflect.String { - if err := f.Set(f.Name, val); err != nil { - return fmt.Errorf( - "could not parse %[1]q as %[2]T value from %[3]s for flag %[4]s: %[5]s", - val, f.value, source, f.Name, err, - ) - } - } else if val == "" && reflect.TypeOf(f.value).Kind() == reflect.Bool { - _ = f.Set(f.Name, "false") - } - - f.hasBeenSet = true - } - } - return nil -} - -func (f *Flag[T]) Set(name string, val string) error { - // Initialize flag if needed - if !f.applied { - if err := f.PreParse(); err != nil { - return err - } - f.applied = true - } - - f.count++ - - // If this is the first time setting a slice type, reset it to empty - // to avoid appending to the default value - if f.count == 1 && f.value != nil { - typ := reflect.TypeOf(f.Default) - if typ != nil && typ.Kind() == reflect.Slice { - // Create a new empty slice of the same type and set it - emptySlice := reflect.MakeSlice(typ, 0, 0).Interface() - f.value = &cliValue[T]{emptySlice.(T)} - } - } - - if err := f.value.Set(val); err != nil { - return err - } - - f.hasBeenSet = true - - if f.Validator != nil { - if err := f.Validator(f.value.Get().(T)); err != nil { - return err - } - } - return nil -} - -func (f *Flag[T]) Get() any { - if f.value != nil { - return f.value.Get() - } - return f.Default -} - -func (f *Flag[T]) String() string { - return cli.FlagStringer(f) -} - -func (f *Flag[T]) IsSet() bool { - return f.hasBeenSet || f.Const -} - -func (f *Flag[T]) Names() []string { - return cli.FlagNames(f.Name, f.Aliases) -} - -// Implementation for the cli.VisibleFlag interface -var _ cli.VisibleFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) IsVisible() bool { - return !f.Hidden -} - -func (f *Flag[T]) GetCategory() string { - return f.Category -} - -func (f *Flag[T]) SetCategory(c string) { - f.Category = c -} - -// Implementation for the cli.RequiredFlag interface -var _ cli.RequiredFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) IsRequired() bool { - // Const flags are always auto-set, so never required from the user. - if f.Const { - return false - } - // Intentionally don't use `f.Required`, because request flags may be passed - // over stdin as well as by flag. - if f.BodyPath != "" || f.BodyRoot || f.PathParam != "" || f.QueryPath != "" || f.HeaderPath != "" { - return false - } - return f.Required -} - -type RequiredFlagOrStdin interface { - IsRequiredAsFlagOrStdin() bool -} - -func (f *Flag[T]) IsRequiredAsFlagOrStdin() bool { - // Const flags are always auto-set, so never required from the user. - if f.Const { - return false - } - return f.Required -} - -// Implementation for the cli.DocGenerationFlag interface -var _ cli.DocGenerationFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) TakesValue() bool { - var t T - return reflect.TypeOf(t) == nil || reflect.TypeOf(t).Kind() != reflect.Bool -} - -func (f *Flag[T]) GetUsage() string { - return f.Usage -} - -func (f *Flag[T]) GetValue() string { - if f.value == nil { - return "" - } - return f.value.String() -} - -func (f *Flag[T]) GetDefaultText() string { - return f.DefaultText -} - -// GetEnvVars returns the env vars for this flag -func (f *Flag[T]) GetEnvVars() []string { - return f.Sources.EnvKeys() -} - -func (f *Flag[T]) IsDefaultVisible() bool { - return !f.HideDefault -} - -func (f *Flag[T]) TypeName() string { - ty := reflect.TypeOf(f.Default) - if ty == nil { - return "" - } - // Deref pointer-typed flags so --help surfaces the pointee kind (e.g. "string"), not - // Go's pointer syntax. - if ty.Kind() == reflect.Pointer { - ty = ty.Elem() - } - - // Get base type name with special handling for built-in types - getTypeName := func(t reflect.Type) string { - switch t.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return "int" - case reflect.Float32, reflect.Float64: - return "float" - case reflect.Bool: - return "boolean" - case reflect.String: - switch t.Name() { - case "DateTimeValue": - return "datetime" - case "DateValue": - return "date" - case "TimeValue": - return "time" - default: - return "string" - } - default: - if t.Name() == "" { - return "any" - } - return strings.ToLower(t.Name()) - } - } - - switch ty.Kind() { - case reflect.Slice: - elemType := ty.Elem() - return getTypeName(elemType) - case reflect.Map: - keyType := ty.Key() - valueType := ty.Elem() - return fmt.Sprintf("%s=%s", getTypeName(keyType), getTypeName(valueType)) - default: - return getTypeName(ty) - } -} - -// Implementation for the cli.DocGenerationMultiValueFlag interface -var _ cli.DocGenerationMultiValueFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) IsMultiValueFlag() bool { - if reflect.TypeOf(f.Default) == nil { - return false - } - kind := reflect.TypeOf(f.Default).Kind() - return kind == reflect.Slice || kind == reflect.Map -} - -func (f *Flag[T]) IsBoolFlag() bool { - // Flag[*bool] is deliberately not treated as a bool flag — the pointer form needs an - // explicit value (`--foo true`, `--foo null`) to disambiguate the tri-state. - _, isBool := any(f.Default).(bool) - return isBool -} - -// Implementation for the cli.Countable interface -var _ cli.Countable = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) Count() int { - return f.count -} - -// Implementation for the cli.LocalFlag interface -var _ cli.LocalFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f Flag[T]) IsLocal() bool { - // By default, all request flags are local, i.e. can be provided at any part of the CLI command. - return true -} - -// cliValue is a generic implementation of cli.Value for common types -type cliValue[ - T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | - []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | string | - float64 | int64 | bool | - *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, -] struct { - value T -} - -// Take an argument string for a single argument and convert it into a typed -// value for one of the supported CLI argument types -func parseCLIArg[ - T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | - []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | string | - float64 | int64 | bool | - *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, -](value string) (T, error) { - var parsedValue any - var err error - - var empty T - - if value == "null" { - switch any(empty).(type) { - // Pointer-to-primitive: explicit nil gives the tri-state its "null" state - // (unset / null / value). Without this, numeric flags would fail to parse - // "null" and string flags would accept the literal word as a raw value. - case *string, *int64, *float64, *bool, *DateValue, *DateTimeValue, *TimeValue: - return empty, nil - // Maps marshal nil as JSON null natively; short-circuit avoids a YAML round-trip. - case map[string]any: - return empty, nil - } - } - - switch any(empty).(type) { - case string: - parsedValue = value - case int64: - parsedValue, err = strconv.ParseInt(value, 0, 64) - case float64: - parsedValue, err = strconv.ParseFloat(value, 64) - case bool: - parsedValue, err = strconv.ParseBool(value) - case DateTimeValue: - var dt DateTimeValue - err = (&dt).Parse(value) - if err == nil { - parsedValue = dt - } - - case DateValue: - var d DateValue - err = (&d).Parse(value) - if err == nil { - parsedValue = d - } - - case TimeValue: - var t TimeValue - err = (&t).Parse(value) - if err == nil { - parsedValue = t - } - - // Pointer-to-primitive flags reach here only when `value != "null"`; we parse the - // pointee type and return its address so JSON marshaling emits the underlying value. - case *string: - v := value - parsedValue = &v - case *int64: - var v int64 - v, err = strconv.ParseInt(value, 0, 64) - if err == nil { - parsedValue = &v - } - case *float64: - var v float64 - v, err = strconv.ParseFloat(value, 64) - if err == nil { - parsedValue = &v - } - case *bool: - var v bool - v, err = strconv.ParseBool(value) - if err == nil { - parsedValue = &v - } - case *DateTimeValue: - var dt DateTimeValue - err = (&dt).Parse(value) - if err == nil { - parsedValue = &dt - } - case *DateValue: - var d DateValue - err = (&d).Parse(value) - if err == nil { - parsedValue = &d - } - case *TimeValue: - var t TimeValue - err = (&t).Parse(value) - if err == nil { - parsedValue = &t - } - - default: - if strings.HasPrefix(value, "@") { - // File literals like @file.txt should work here - parsedValue = value - } else { - var yamlValue T - err = yaml.Unmarshal([]byte(value), &yamlValue) - if err == nil { - parsedValue = yamlValue - } else if allowAsLiteralString(value) { - parsedValue = value - } else { - parsedValue = nil - err = fmt.Errorf("failed to parse as YAML: %w", err) - } - } - } - - // Nil needs to be handled specially because unmarshalling a YAML `null` - // causes problems when doing type assertions. - if parsedValue == nil { - parsedValue = (*struct{})(nil) - } - - if err == nil { - if typedValue, ok := parsedValue.(T); ok { - return typedValue, nil - } else { - expectedType := reflect.TypeFor[T]() - err = fmt.Errorf("Couldn't convert %q (%v) to expected type %v", value, parsedValue, expectedType) - } - } - return empty, err - -} - -// Ptr returns a pointer to its argument. It is used to initialize `Default` on pointer-typed -// Flag values, since Go does not allow taking the address of a composite literal's element -// or of an untyped constant. -func Ptr[T any](v T) *T { - return &v -} - -// Assuming this string failed to parse as valid YAML, this function will -// return true for strings that can reasonably be interpreted as a string literal, -// like identifiers (`foo_bar`), UUIDs (`945b2f0c-8e89-487a-b02c-f851c69ea459`), -// base64 (`aGVsbG8=`), and qualified identifiers (`color.Red`). This should -// not include strings that look like mistyped YAML (e.g. `{key:`) -func allowAsLiteralString(s string) bool { - for _, c := range s { - if !unicode.IsLetter(c) && !unicode.IsDigit(c) && - c != '_' && c != '-' && c != '.' && c != '=' { - return false - } - } - return true -} - -// Parse the input string and set result as the cliValue's value -func (c *cliValue[T]) Set(value string) error { - valueType := reflect.TypeOf(c.value) - // When setting slice values, we append to the existing values - // e.g. --foo 10 --foo 20 --foo 30 => [10, 20, 30] - if valueType != nil && valueType.Kind() == reflect.Slice { - elemType := valueType.Elem() - - var singleElem any - var err error - switch elemType.Kind() { - case reflect.String: - singleElem, err = parseCLIArg[string](value) - case reflect.Int64: - singleElem, err = parseCLIArg[int64](value) - case reflect.Float64: - singleElem, err = parseCLIArg[float64](value) - case reflect.Bool: - singleElem, err = parseCLIArg[bool](value) - default: - // Check for special types by name - switch elemType.Name() { - case "DateTimeValue": - singleElem, err = parseCLIArg[DateTimeValue](value) - case "DateValue": - singleElem, err = parseCLIArg[DateValue](value) - case "TimeValue": - singleElem, err = parseCLIArg[TimeValue](value) - default: - // This handles []map[string]any - if elemType.Kind() == reflect.Map && elemType.Key().Kind() == reflect.String { - singleElem, err = parseCLIArg[map[string]any](value) - } else { - singleElem, err = parseCLIArg[any](value) - } - } - } - - if err != nil { - return err - } - - // Append the new element to the slice - sliceValue := reflect.ValueOf(c.value) - if !sliceValue.IsValid() || sliceValue.IsNil() { - // Create a new slice if the current one is nil - sliceValue = reflect.MakeSlice(valueType, 0, 1) - } - - // Append the new element - newElem := reflect.ValueOf(singleElem) - sliceValue = reflect.Append(sliceValue, newElem) - - // Set the updated slice back to c.value - c.value = sliceValue.Interface().(T) - } else { - // For non-slice types, simply parse and set the value - if parsedValue, err := parseCLIArg[T](value); err != nil { - return err - } else { - c.value = parsedValue - } - } - - return nil -} - -func (c *cliValue[T]) Get() any { - return c.value -} - -func (c *cliValue[T]) String() string { - switch v := any(c.value).(type) { - case string, int, int64, float64, bool, DateTimeValue, DateValue, TimeValue, - []string, []int, []int64, []float64, []bool, []DateTimeValue, []DateValue, []TimeValue: - // For basic types, use standard string representation - return fmt.Sprintf("%v", v) - - case *string, *int64, *float64, *bool, *DateTimeValue, *DateValue, *TimeValue: - // Pointer-to-primitive: nil renders as "null" (the CLI literal that produces it); - // non-nil derefs to the pointee's standard representation. - rv := reflect.ValueOf(v) - if rv.IsNil() { - return "null" - } - return fmt.Sprintf("%v", rv.Elem().Interface()) - - default: - // For complex types, convert to YAML - yamlBytes, err := yaml.MarshalWithOptions(c.value, yaml.Flow(true)) - if err != nil { - // Fall back to standard format if YAML conversion fails - return fmt.Sprintf("%v", c.value) - } - return string(yamlBytes) - } -} - -func (c *cliValue[T]) IsBoolFlag() bool { - _, ok := any(c.value).(bool) - return ok -} - -// Time-related value types -type DateValue string -type DateTimeValue string -type TimeValue string - -// String methods for time-related types -func (d DateValue) String() string { - return string(d) -} - -func (d DateTimeValue) String() string { - return string(d) -} - -func (t TimeValue) String() string { - return string(t) -} - -// parseTimeWithFormats attempts to parse a string using multiple formats -func parseTimeWithFormats(s string, formats []string) (time.Time, error) { - var lastErr error - for _, format := range formats { - t, err := time.Parse(format, s) - if err == nil { - return t, nil - } - lastErr = err - } - return time.Time{}, lastErr -} - -// Parse methods for time-related types -func (d *DateValue) Parse(s string) error { - formats := []string{ - "2006-01-02", - "01/02/2006", - "Jan 2, 2006", - "January 2, 2006", - "2-Jan-2006", - } - - t, err := parseTimeWithFormats(s, formats) - if err != nil { - return fmt.Errorf("unable to parse date: %v", err) - } - - *d = DateValue(t.Format("2006-01-02")) - return nil -} - -func (d *DateTimeValue) Parse(s string) error { - formats := []string{ - time.RFC3339, - time.RFC3339Nano, - "2006-01-02T15:04:05", - "2006-01-02 15:04:05", - time.RFC1123, - time.RFC822, - time.ANSIC, - } - - t, err := parseTimeWithFormats(s, formats) - if err != nil { - return fmt.Errorf("unable to parse datetime: %v", err) - } - - *d = DateTimeValue(t.Format(time.RFC3339)) - return nil -} - -func (t *TimeValue) Parse(s string) error { - formats := []string{ - "15:04:05", - "15:04:05.999999999Z07:00", - "3:04:05PM", - "3:04 PM", - "15:04", - time.Kitchen, - } - - parsedTime, err := parseTimeWithFormats(s, formats) - if err != nil { - return fmt.Errorf("unable to parse time: %v", err) - } - - *t = TimeValue(parsedTime.Format("15:04:05")) - return nil -} - -// Allow setting inner fields on other flags (e.g. --foo.baz can set the "baz" -// field on the --foo flag) -type SettableInnerField interface { - SetInnerField(string, any) -} - -// InnerFieldSeeder lets an InnerFlag prepare its outer flag's underlying value -// before dispatching SetInnerField. This is only meaningful for Flag[any] — -// the codegen output for nullable complex schemas — whose untyped-nil zero -// value would otherwise have no reflect.Kind for the inner-field switch to -// dispatch on. -type InnerFieldSeeder interface { - SeedInnerCollection(isArrayOfObjects bool) -} - -func (f *Flag[T]) SetInnerField(field string, val any) { - if f.value == nil { - f.value = &cliValue[T]{} - } - - if settableInnerField, ok := f.value.(SettableInnerField); ok { - settableInnerField.SetInnerField(field, val) - f.hasBeenSet = true - } else { - panic(fmt.Sprintf("Cannot set inner field: %v", f.value)) - } -} - -// SeedInnerCollection initializes a Flag[any]'s underlying value as an empty -// map[string]any or []map[string]any so subsequent SetInnerField calls have a -// dispatchable reflect.Kind. For typed Flag[T] this is a no-op: the type -// assertion fails and the existing reflect.Kind on the typed-nil zero value -// already routes correctly. -func (f *Flag[T]) SeedInnerCollection(isArrayOfObjects bool) { - if f.value == nil { - f.value = &cliValue[T]{} - } - cv, ok := f.value.(*cliValue[T]) - if !ok { - return - } - if reflect.ValueOf(cv.value).Kind() != reflect.Invalid { - return - } - if isArrayOfObjects { - if seed, ok := any([]map[string]any{}).(T); ok { - cv.value = seed - } - return - } - if seed, ok := any(map[string]any{}).(T); ok { - cv.value = seed - } -} - -func (c *cliValue[T]) SetInnerField(field string, val any) { - flagVal := c.value - flagValReflect := reflect.ValueOf(flagVal) - switch flagValReflect.Kind() { - case reflect.Slice: - if flagValReflect.Type().Elem().Kind() != reflect.Map { - return - } - - sliceLen := flagValReflect.Len() - if sliceLen > 0 { - // Check if the last element already has the InnerField - lastElement := flagValReflect.Index(sliceLen - 1).Interface().(map[string]any) - if _, hasInnerField := lastElement[field]; !hasInnerField { - // Last element doesn't have the field, set it - lastElement[field] = val - return - } - } - - // Create a new map and append it to the slice - newMap := map[string]any{field: val} - switch sliceVal := any(c.value).(type) { - case []map[string]any: - c.value = any(append(sliceVal, newMap)).(T) - case []any: - c.value = any(append(sliceVal, newMap)).(T) - } - - case reflect.Map: - mapVal, ok := any(flagVal).(map[string]any) - if !ok || mapVal == nil { - mapVal = map[string]any{field: val} - c.value = any(mapVal).(T) - } else { - mapVal[field] = val - } - } -} diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go deleted file mode 100644 index 779bd57..0000000 --- a/internal/requestflag/requestflag_test.go +++ /dev/null @@ -1,1227 +0,0 @@ -package requestflag - -import ( - "encoding/json" - "fmt" - "testing" - "time" - - "github.com/goccy/go-yaml" - "github.com/stretchr/testify/assert" - "github.com/urfave/cli/v3" -) - -func TestDateValueParse(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - want string - wantErr bool - }{ - { - name: "ISO format", - input: "2023-05-15", - want: "2023-05-15", - wantErr: false, - }, - { - name: "US format", - input: "05/15/2023", - want: "2023-05-15", - wantErr: false, - }, - { - name: "Short month format", - input: "May 15, 2023", - want: "2023-05-15", - wantErr: false, - }, - { - name: "Long month format", - input: "January 15, 2023", - want: "2023-01-15", - wantErr: false, - }, - { - name: "British format", - input: "15-Jan-2023", - want: "2023-01-15", - wantErr: false, - }, - { - name: "Invalid format", - input: "not a date", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - var d DateValue - err := d.Parse(tt.input) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.want, d.String()) - } - }) - } -} - -func TestDateTimeValueParse(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - wantErr bool - }{ - { - name: "RFC3339", - input: "2023-05-15T14:30:45Z", - wantErr: false, - }, - { - name: "ISO with timezone", - input: "2023-05-15T14:30:45+02:00", - wantErr: false, - }, - { - name: "ISO without timezone", - input: "2023-05-15T14:30:45", - wantErr: false, - }, - { - name: "Space separated", - input: "2023-05-15 14:30:45", - wantErr: false, - }, - { - name: "RFC1123", - input: "Mon, 15 May 2023 14:30:45 GMT", - wantErr: false, - }, - { - name: "RFC822", - input: "15 May 23 14:30 GMT", - wantErr: false, - }, - { - name: "ANSIC", - input: "Mon Jan 2 15:04:05 2006", - wantErr: false, - }, - { - name: "Invalid format", - input: "not a datetime", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - var d DateTimeValue - err := d.Parse(tt.input) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - - // Parse the string back to ensure it's valid RFC3339 - _, parseErr := time.Parse(time.RFC3339, d.String()) - assert.NoError(t, parseErr) - } - }) - } -} - -func TestTimeValueParse(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - want string - wantErr bool - }{ - { - name: "24-hour format", - input: "14:30:45", - want: "14:30:45", - wantErr: false, - }, - { - name: "12-hour format with seconds", - input: "2:30:45PM", - want: "14:30:45", - wantErr: false, - }, - { - name: "12-hour format without seconds", - input: "2:30 PM", - want: "14:30:00", - wantErr: false, - }, - { - name: "24-hour without seconds", - input: "14:30", - want: "14:30:00", - wantErr: false, - }, - { - name: "Kitchen format", - input: "2:30PM", - want: "14:30:00", - wantErr: false, - }, - { - name: "Invalid format", - input: "not a time", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - var tv TimeValue - err := tv.Parse(tt.input) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.want, tv.String()) - } - }) - } -} - -func TestRequestParams(t *testing.T) { - t.Parallel() - - t.Run("map body type", func(t *testing.T) { - t.Parallel() - - // Create a mock command with flags - cmd := &cli.Command{ - Name: "test", - } - - // Create string flag with body path - stringFlag := &Flag[string]{ - Name: "string-flag", - Default: "default-string", - BodyPath: "string_field", - value: &cliValue[string]{value: "test-value"}, - hasBeenSet: true, - } - - // Create int flag with header path - intFlag := &Flag[int64]{ - Name: "int-flag", - Default: 42, - HeaderPath: "X-Int-Value", - value: &cliValue[int64]{value: 99}, - hasBeenSet: true, - } - - // Create bool flag with query path - boolFlag := &Flag[bool]{ - Name: "bool-flag", - Default: false, - QueryPath: "include_details", - value: &cliValue[bool]{value: true}, - hasBeenSet: true, - } - - // Create date flag with multiple paths - dateFlag := &Flag[DateValue]{ - Name: "date-flag", - Default: DateValue("2023-01-01"), - BodyPath: "effective_date", - HeaderPath: "X-Effective-Date", - QueryPath: "as_of_date", - value: &cliValue[DateValue]{value: DateValue("2023-05-15")}, - hasBeenSet: true, - } - - // Create flag with no path - noPathFlag := &Flag[string]{ - Name: "no-path-flag", - Default: "no-path", - value: &cliValue[string]{value: "no-path-value"}, - hasBeenSet: true, - } - - // Create unset flag - unsetFlag := &Flag[string]{ - Name: "unset-flag", - Default: "unset", - BodyPath: "should_not_appear", - value: &cliValue[string]{value: "unset-value"}, - hasBeenSet: false, - } - - cmd.Flags = []cli.Flag{stringFlag, intFlag, boolFlag, dateFlag, noPathFlag, unsetFlag} - - // Test the RequestParams function - contents := ExtractRequestContents(cmd) - - // Verify query parameters - assert.Equal(t, true, contents.Queries["include_details"]) - assert.Equal(t, DateValue("2023-05-15"), contents.Queries["as_of_date"]) - assert.Len(t, contents.Queries, 2) - - // Verify headers - assert.Equal(t, int64(99), contents.Headers["X-Int-Value"]) - assert.Equal(t, DateValue("2023-05-15"), contents.Headers["X-Effective-Date"]) - assert.Len(t, contents.Headers, 2) - - // Verify body - bodyMap, ok := contents.Body.(map[string]any) - assert.True(t, ok, "Expected body to be map[string]any, got %T", contents.Body) - assert.Equal(t, "test-value", bodyMap["string_field"]) - assert.Equal(t, DateValue("2023-05-15"), bodyMap["effective_date"]) - assert.Len(t, bodyMap, 2) - - // Verify the unset flag didn't make it into the maps - assert.NotContains(t, contents.Body, "should_not_appear") - }) - - t.Run("non-map body type", func(t *testing.T) { - t.Parallel() - - // Create a mock command with flags - cmd := &cli.Command{ - Name: "test", - Flags: []cli.Flag{ - &Flag[int64]{ - Name: "int-body-flag", - Default: 0, - BodyRoot: true, - }, - }, - } - cmd.Set("int-body-flag", "42") - - contents := ExtractRequestContents(cmd) - intBody, ok := contents.Body.(int64) - assert.True(t, ok, "Expected body to be int64, got %T", contents.Body) - assert.Equal(t, int64(42), intBody) - }) -} - -func TestFlagSet(t *testing.T) { - t.Parallel() - - strFlag := &Flag[string]{ - Name: "string-flag", - Default: "default-string", - } - - superstitiousIntFlag := &Flag[int64]{ - Name: "int-flag", - Default: 42, - Validator: func(val int64) error { - if val == 13 { - return fmt.Errorf("Unlucky number!") - } - return nil - }, - } - - boolFlag := &Flag[bool]{ - Name: "bool-flag", - Default: false, - } - - // Test initialization and setting - t.Run("PreParse initialization", func(t *testing.T) { - t.Parallel() - - assert.NoError(t, strFlag.PreParse()) - assert.True(t, strFlag.applied) - assert.Equal(t, "default-string", strFlag.Get()) - }) - - t.Run("Set string flag", func(t *testing.T) { - t.Parallel() - - assert.NoError(t, strFlag.Set("string-flag", "new-value")) - assert.Equal(t, "new-value", strFlag.Get()) - assert.True(t, strFlag.IsSet()) - }) - - t.Run("Set int flag with valid value", func(t *testing.T) { - t.Parallel() - - assert.NoError(t, superstitiousIntFlag.Set("int-flag", "100")) - assert.Equal(t, int64(100), superstitiousIntFlag.Get()) - assert.True(t, superstitiousIntFlag.IsSet()) - }) - - t.Run("Set int flag with invalid value", func(t *testing.T) { - t.Parallel() - - assert.Error(t, superstitiousIntFlag.Set("int-flag", "not-an-int")) - }) - - t.Run("Set int flag with validator failing", func(t *testing.T) { - t.Parallel() - - assert.Error(t, superstitiousIntFlag.Set("int-flag", "13")) - }) - - t.Run("Set bool flag", func(t *testing.T) { - t.Parallel() - - assert.NoError(t, boolFlag.Set("bool-flag", "true")) - assert.Equal(t, true, boolFlag.Get()) - assert.True(t, boolFlag.IsSet()) - }) - - t.Run("Set slice flag with multiple values", func(t *testing.T) { - t.Parallel() - - sliceFlag := &Flag[[]int64]{ - Name: "slice-flag", - Default: []int64{}, - } - - // Initialize the flag - assert.NoError(t, sliceFlag.PreParse()) - - // First set - assert.NoError(t, sliceFlag.Set("slice-flag", "10")) - - // Subsequent setting should append, not replace - assert.NoError(t, sliceFlag.Set("slice-flag", "20")) - assert.NoError(t, sliceFlag.Set("slice-flag", "30")) - - // Verify that we have both values in the slice - result := sliceFlag.Get() - assert.Equal(t, []int64{10, 20, 30}, result) - assert.True(t, sliceFlag.IsSet()) - }) - - t.Run("Set slice flag with a nonempty default", func(t *testing.T) { - t.Parallel() - - sliceFlag := &Flag[[]int64]{ - Name: "slice-flag", - Default: []int64{99, 100}, - } - - assert.NoError(t, sliceFlag.PreParse()) - assert.NoError(t, sliceFlag.Set("slice-flag", "10")) - assert.NoError(t, sliceFlag.Set("slice-flag", "20")) - assert.NoError(t, sliceFlag.Set("slice-flag", "30")) - - // Verify that we have clobbered the default value instead of appending - // to it. - result := sliceFlag.Get() - assert.Equal(t, []int64{10, 20, 30}, result) - assert.True(t, sliceFlag.IsSet()) - }) -} - -func TestParseTimeWithFormats(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - formats []string - wantTime time.Time - wantErr bool - }{ - { - name: "RFC3339 format", - input: "2023-05-15T14:30:45Z", - formats: []string{time.RFC3339}, - wantTime: time.Date(2023, 5, 15, 14, 30, 45, 0, time.UTC), - wantErr: false, - }, - { - name: "Multiple formats - first matches", - input: "2023-05-15", - formats: []string{"2006-01-02", time.RFC3339}, - wantTime: time.Date(2023, 5, 15, 0, 0, 0, 0, time.UTC), - wantErr: false, - }, - { - name: "Multiple formats - second matches", - input: "15/05/2023", - formats: []string{"2006-01-02", "02/01/2006"}, - wantTime: time.Date(2023, 5, 15, 0, 0, 0, 0, time.UTC), - wantErr: false, - }, - { - name: "No matching format", - input: "not a date", - formats: []string{"2006-01-02", time.RFC3339}, - wantTime: time.Time{}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got, err := parseTimeWithFormats(tt.input, tt.formats) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.True(t, tt.wantTime.Equal(got), "Expected %v, got %v", tt.wantTime, got) - } - }) - } -} - -func TestYamlHandling(t *testing.T) { - t.Parallel() - - // Test with any value - t.Run("Parse YAML to any", func(t *testing.T) { - t.Parallel() - - cv := &cliValue[any]{} - err := cv.Set("name: test\nvalue: 42\n") - assert.NoError(t, err) - - // The value should be a map - val, ok := cv.Get().(map[string]any) - assert.True(t, ok, "Expected map[string]any, got %T", cv.Get()) - - if ok { - assert.Equal(t, "test", val["name"]) - assert.Equal(t, uint64(42), val["value"]) - } - - // The string representation should be valid YAML - strVal := cv.String() - var parsed map[string]any - err = yaml.Unmarshal([]byte(strVal), &parsed) - assert.NoError(t, err) - assert.Equal(t, "test", parsed["name"]) - assert.Equal(t, uint64(42), parsed["value"]) - }) - - // Test with array - t.Run("Parse YAML array", func(t *testing.T) { - t.Parallel() - - cv := &cliValue[any]{} - err := cv.Set("- item1\n- item2\n- item3\n") - assert.NoError(t, err) - - // The value should be a slice - val, ok := cv.Get().([]any) - assert.True(t, ok, "Expected []any, got %T", cv.Get()) - - if ok { - assert.Len(t, val, 3) - assert.Equal(t, "item1", val[0]) - assert.Equal(t, "item2", val[1]) - assert.Equal(t, "item3", val[2]) - } - }) - - t.Run("Parse @file.txt as YAML", func(t *testing.T) { - t.Parallel() - - flag := &Flag[any]{ - Name: "file-flag", - Default: nil, - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("file-flag", "@file.txt")) - - val := flag.Get() - assert.Equal(t, "@file.txt", val) - }) - - t.Run("Parse @file.txt list as YAML", func(t *testing.T) { - t.Parallel() - - flag := &Flag[[]any]{ - Name: "file-flag", - Default: nil, - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("file-flag", "@file1.txt")) - assert.NoError(t, flag.Set("file-flag", "@file2.txt")) - - val := flag.Get() - assert.Equal(t, []any{"@file1.txt", "@file2.txt"}, val) - }) - - t.Run("Parse identifiers as YAML", func(t *testing.T) { - t.Parallel() - - tests := []string{ - "hello", - "e4e355fa-b03b-4c57-a73d-25c9733eec79", - "foo_bar", - "Color.Red", - "aGVsbG8=", - } - for _, test := range tests { - flag := &Flag[any]{ - Name: "flag", - Default: nil, - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("flag", test)) - - val := flag.Get() - assert.Equal(t, test, val) - } - - for _, test := range tests { - flag := &Flag[[]any]{ - Name: "identifier", - Default: nil, - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("identifier", test)) - assert.NoError(t, flag.Set("identifier", test)) - - val := flag.Get() - assert.Equal(t, []any{test, test}, val) - } - }) - - // Test with invalid YAML - t.Run("Parse invalid YAML", func(t *testing.T) { - t.Parallel() - - invalidYaml := `[not closed` - cv := &cliValue[any]{} - err := cv.Set(invalidYaml) - assert.Error(t, err) - }) -} - -// TestNullLiteralHandling pins how each Flag[T] type handles the literal value "null" -// when passed via the CLI. Pointer-typed flags serialize nil as JSON null, which is how -// nullable body fields (`anyOf: [T, null]` / `{nullable: true}`) let users clear a field -// via `--foo null`. Non-pointer primitive flags treat "null" as a raw value — these are -// non-nullable schemas where explicit null has no API semantics anyway. -func TestNullLiteralHandling(t *testing.T) { - t.Parallel() - - assertJSONBody := func(t *testing.T, value any, expected string) { - t.Helper() - body, err := json.Marshal(map[string]any{"foo": value}) - assert.NoError(t, err) - assert.JSONEq(t, expected, string(body)) - } - - t.Run("Flag[any] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[any]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[string] null is the raw string \"null\"", func(t *testing.T) { - t.Parallel() - cv := &cliValue[string]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":"null"}`) - }) - - t.Run("Flag[int64] null errors", func(t *testing.T) { - t.Parallel() - cv := &cliValue[int64]{} - assert.Error(t, cv.Set("null")) - }) - - t.Run("Flag[*string] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*string]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*string] value sends the string", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*string]{} - assert.NoError(t, cv.Set("1.1")) - assertJSONBody(t, cv.Get(), `{"foo":"1.1"}`) - }) - - t.Run("Flag[*int64] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*int64]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*int64] value sends the integer", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*int64]{} - assert.NoError(t, cv.Set("42")) - assertJSONBody(t, cv.Get(), `{"foo":42}`) - }) - - t.Run("Flag[*int64] invalid value errors", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*int64]{} - assert.Error(t, cv.Set("not-an-int")) - }) - - t.Run("Flag[*bool] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*bool]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*bool] value sends the boolean", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*bool]{} - assert.NoError(t, cv.Set("true")) - assertJSONBody(t, cv.Get(), `{"foo":true}`) - }) - - t.Run("Flag[*float64] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*float64]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*float64] value sends the float", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*float64]{} - assert.NoError(t, cv.Set("1.5")) - assertJSONBody(t, cv.Get(), `{"foo":1.5}`) - }) - - t.Run("Flag[*float64] invalid value errors", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*float64]{} - assert.Error(t, cv.Set("not-a-float")) - }) - - t.Run("Flag[*DateValue] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*DateValue]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*DateValue] value sends the date", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*DateValue]{} - assert.NoError(t, cv.Set("2023-05-15")) - assertJSONBody(t, cv.Get(), `{"foo":"2023-05-15"}`) - }) - - t.Run("Flag[*DateValue] invalid value errors", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*DateValue]{} - assert.Error(t, cv.Set("not-a-date")) - }) - - t.Run("Flag[*DateTimeValue] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*DateTimeValue]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*DateTimeValue] value sends the datetime", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*DateTimeValue]{} - assert.NoError(t, cv.Set("2023-05-15T14:30:45Z")) - assertJSONBody(t, cv.Get(), `{"foo":"2023-05-15T14:30:45Z"}`) - }) - - t.Run("Flag[*DateTimeValue] invalid value errors", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*DateTimeValue]{} - assert.Error(t, cv.Set("not-a-datetime")) - }) - - t.Run("Flag[*TimeValue] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*TimeValue]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*TimeValue] value sends the time", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*TimeValue]{} - assert.NoError(t, cv.Set("14:30:45")) - assertJSONBody(t, cv.Get(), `{"foo":"14:30:45"}`) - }) - - t.Run("Flag[*TimeValue] invalid value errors", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*TimeValue]{} - assert.Error(t, cv.Set("not-a-time")) - }) - - // Nullable maps don't need pointer wrapping — a nil map already marshals as JSON null. - t.Run("Flag[map[string]any] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[map[string]any]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) -} - -func TestFlagTypeNames(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - flag cli.DocGenerationFlag - expected string - }{ - {"string", &Flag[string]{}, "string"}, - {"int64", &Flag[int64]{}, "int"}, - {"float64", &Flag[float64]{}, "float"}, - {"bool", &Flag[bool]{}, "boolean"}, - {"string slice", &Flag[[]string]{}, "string"}, - {"date", &Flag[DateValue]{}, "date"}, - {"datetime", &Flag[DateTimeValue]{}, "datetime"}, - {"time", &Flag[TimeValue]{}, "time"}, - {"date slice", &Flag[[]DateValue]{}, "date"}, - {"datetime slice", &Flag[[]DateTimeValue]{}, "datetime"}, - {"time slice", &Flag[[]TimeValue]{}, "time"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - typeName := tt.flag.TypeName() - assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) - }) - } -} - -// TestInnerFlagDispatchOnUntypedFlag pins inner-flag behavior for `Flag[any]`, -// which is the codegen output for nullable complex schemas (`anyOf: [T, null]` -// or `{nullable: true}`). The untyped-nil zero value carries no reflect.Kind, -// so SetInnerField has nowhere to dispatch the assignment — without explicit -// help the inner-field value silently drops. -func TestInnerFlagDispatchOnUntypedFlag(t *testing.T) { - t.Parallel() - - t.Run("nullable array of objects appends element from inner flag", func(t *testing.T) { - t.Parallel() - outer := &Flag[any]{Name: "mcp-server"} - assert.NoError(t, outer.PreParse()) - - nameFlag := &InnerFlag[string]{ - Name: "mcp-server.name", InnerField: "name", - OuterFlag: outer, OuterIsArrayOfObjects: true, - } - assert.NoError(t, nameFlag.Set("mcp-server.name", "first")) - - body, err := json.Marshal(map[string]any{"foo": outer.Get()}) - assert.NoError(t, err) - assert.JSONEq(t, `{"foo":[{"name":"first"}]}`, string(body)) - }) - - t.Run("nullable object sets field from inner flag", func(t *testing.T) { - t.Parallel() - outer := &Flag[any]{Name: "metadata"} - assert.NoError(t, outer.PreParse()) - - keyFlag := &InnerFlag[string]{ - Name: "metadata.key", InnerField: "key", OuterFlag: outer, - } - assert.NoError(t, keyFlag.Set("metadata.key", "value")) - - body, err := json.Marshal(map[string]any{"foo": outer.Get()}) - assert.NoError(t, err) - assert.JSONEq(t, `{"foo":{"key":"value"}}`, string(body)) - }) - - t.Run("multiple inner flags merge into the trailing element", func(t *testing.T) { - t.Parallel() - outer := &Flag[any]{Name: "mcp-server"} - assert.NoError(t, outer.PreParse()) - - nameFlag := &InnerFlag[string]{ - Name: "mcp-server.name", InnerField: "name", - OuterFlag: outer, OuterIsArrayOfObjects: true, - } - urlFlag := &InnerFlag[string]{ - Name: "mcp-server.url", InnerField: "url", - OuterFlag: outer, OuterIsArrayOfObjects: true, - } - assert.NoError(t, nameFlag.Set("mcp-server.name", "first")) - assert.NoError(t, urlFlag.Set("mcp-server.url", "https://example.com")) - - body, err := json.Marshal(map[string]any{"foo": outer.Get()}) - assert.NoError(t, err) - assert.JSONEq(t, `{"foo":[{"name":"first","url":"https://example.com"}]}`, string(body)) - }) -} - -func TestApplyStdinDataToFlags(t *testing.T) { - t.Parallel() - - t.Run("sets query path flag from piped data", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"account_id": "acct_123"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, flag.IsSet()) - assert.Equal(t, "acct_123", flag.Get()) - }) - - t.Run("sets header path flag from piped data", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "idempotency-key", - HeaderPath: "Idempotency-Key", - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"Idempotency-Key": "key-xyz"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, flag.IsSet()) - assert.Equal(t, "key-xyz", flag.Get()) - }) - - t.Run("does not set body path flag from piped data", func(t *testing.T) { - t.Parallel() - - // Body params are handled by the maps.Copy merge in flagOptions, not by ApplyStdinDataToFlags. - flag := &Flag[string]{ - Name: "message", - BodyPath: "message", - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"message": "hello world"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.False(t, flag.IsSet()) - }) - - t.Run("does not override flag already set via CLI", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("account-id", "explicit_value")) - - data := map[string]any{"account_id": "piped_value"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - // The explicitly-set value should win. - assert.Equal(t, "explicit_value", flag.Get()) - }) - - t.Run("sets integer query flag from piped data", func(t *testing.T) { - t.Parallel() - - flag := &Flag[int64]{ - Name: "page-size", - QueryPath: "page_size", - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"page_size": int64(50)} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, flag.IsSet()) - assert.Equal(t, int64(50), flag.Get()) - }) - - t.Run("sets boolean query flag from piped data", func(t *testing.T) { - t.Parallel() - - flag := &Flag[bool]{ - Name: "include-deleted", - QueryPath: "include_deleted", - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"include_deleted": true} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, flag.IsSet()) - assert.Equal(t, true, flag.Get()) - }) - - t.Run("resolves query path flag via data alias", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - DataAliases: []string{"accountId", "account"}, - } - assert.NoError(t, flag.PreParse()) - - // Use one of the aliases as the key in piped data. - data := map[string]any{"accountId": "acct_alias"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, flag.IsSet()) - assert.Equal(t, "acct_alias", flag.Get()) - }) - - t.Run("does not set body path flag via data alias", func(t *testing.T) { - t.Parallel() - - // Body params are handled by the maps.Copy merge in flagOptions, not by ApplyStdinDataToFlags. - flag := &Flag[string]{ - Name: "user-name", - BodyPath: "user_name", - DataAliases: []string{"userName", "username"}, - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"userName": "alice"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.False(t, flag.IsSet()) - }) - - t.Run("ignores flags with no matching key in piped data", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"other_key": "value"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.False(t, flag.IsSet()) - }) - - t.Run("ignores flags with no path set", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "some-flag", - // No QueryPath, HeaderPath, or BodyPath - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"some-flag": "value"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.False(t, flag.IsSet()) - }) - - t.Run("handles multiple flags from piped data", func(t *testing.T) { - t.Parallel() - - accountFlag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - } - limitFlag := &Flag[int64]{ - Name: "limit", - QueryPath: "limit", - } - assert.NoError(t, accountFlag.PreParse()) - assert.NoError(t, limitFlag.PreParse()) - - data := map[string]any{ - "account_id": "acct_abc", - "limit": int64(25), - } - cmd := &cli.Command{Flags: []cli.Flag{accountFlag, limitFlag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, accountFlag.IsSet()) - assert.Equal(t, "acct_abc", accountFlag.Get()) - assert.True(t, limitFlag.IsSet()) - assert.Equal(t, int64(25), limitFlag.Get()) - }) - - t.Run("sets inner flag from nested piped data under outer body path", func(t *testing.T) { - t.Parallel() - - outer := &Flag[map[string]any]{ - Name: "address", - BodyPath: "address", - } - assert.NoError(t, outer.PreParse()) - - cityInner := &InnerFlag[string]{ - Name: "address.city", - InnerField: "city", - OuterFlag: outer, - } - - data := map[string]any{ - "address": map[string]any{"city": "San Francisco"}, - } - cmd := &cli.Command{Flags: []cli.Flag{outer, cityInner}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - // InnerFlag.IsSet() is always false by design; verify the value was written - // into the outer flag's underlying map instead. - outerVal, ok := outer.Get().(map[string]any) - assert.True(t, ok, "expected outer flag value to be map[string]any, got %T", outer.Get()) - assert.Equal(t, "San Francisco", outerVal["city"]) - }) - - t.Run("sets inner flag via data alias in nested piped data", func(t *testing.T) { - t.Parallel() - - outer := &Flag[map[string]any]{ - Name: "address", - BodyPath: "address", - } - assert.NoError(t, outer.PreParse()) - - cityInner := &InnerFlag[string]{ - Name: "address.city", - InnerField: "city", - DataAliases: []string{"cityName"}, - OuterFlag: outer, - } - - // Use the alias in piped data. - data := map[string]any{ - "address": map[string]any{"cityName": "Portland"}, - } - cmd := &cli.Command{Flags: []cli.Flag{outer, cityInner}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - // InnerFlag.IsSet() is always false by design; verify the value was written - // into the outer flag's underlying map instead. - outerVal, ok := outer.Get().(map[string]any) - assert.True(t, ok, "expected outer flag value to be map[string]any, got %T", outer.Get()) - assert.Equal(t, "Portland", outerVal["city"]) - }) - - t.Run("does not set inner flag when outer flag has no body path", func(t *testing.T) { - t.Parallel() - - outer := &Flag[map[string]any]{ - Name: "options", - // No BodyPath set - } - assert.NoError(t, outer.PreParse()) - - inner := &InnerFlag[string]{ - Name: "options.key", - InnerField: "key", - OuterFlag: outer, - } - - data := map[string]any{ - "options": map[string]any{"key": "value"}, - } - cmd := &cli.Command{Flags: []cli.Flag{outer, inner}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.False(t, inner.IsSet()) - }) - - t.Run("does not set inner flag when piped data has no nested map for outer path", func(t *testing.T) { - t.Parallel() - - outer := &Flag[map[string]any]{ - Name: "address", - BodyPath: "address", - } - assert.NoError(t, outer.PreParse()) - - inner := &InnerFlag[string]{ - Name: "address.city", - InnerField: "city", - OuterFlag: outer, - } - - // The outer body path key is missing from the piped data. - data := map[string]any{"other": "value"} - cmd := &cli.Command{Flags: []cli.Flag{outer, inner}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.False(t, inner.IsSet()) - }) - - t.Run("canonical path key takes precedence over alias when both are present", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - DataAliases: []string{"accountId"}, - } - assert.NoError(t, flag.PreParse()) - - // Both canonical and alias present — canonical should win because it's checked first. - data := map[string]any{ - "account_id": "canonical_value", - "accountId": "alias_value", - } - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, flag.IsSet()) - assert.Equal(t, "canonical_value", flag.Get()) - }) - - t.Run("empty data map does not set any flags", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - } - assert.NoError(t, flag.PreParse()) - - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, map[string]any{})) - - assert.False(t, flag.IsSet()) - }) -} diff --git a/man/dedalus-completion.1 b/man/dedalus-completion.1 new file mode 100644 index 0000000..03e21ef --- /dev/null +++ b/man/dedalus-completion.1 @@ -0,0 +1,53 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-COMPLETION" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-completion \- print a shell completion script +.SH "SYNOPSIS" +.B dedalus completion +\fIshell\fR +.SH "DESCRIPTION" +Prints a completion script for the named shell to standard output. The script is generated from the same command table dedalus itself is built from, so it always describes this version's commands and flags. Supported shells are bash, zsh, and fish. +.SH "ARGUMENTS" +.TP +\fIshell\fR +The shell to print a script for: bash, zsh, and fish. Required. +.SH "EXAMPLES" +Load completions for the current shell by adding one of these to its startup file: +.PP +.RS 4 +.nf +eval "$(dedalus completion bash)" +eval "$(dedalus completion zsh)" +dedalus completion fish | source +.fi +.RE +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-login.1 b/man/dedalus-login.1 new file mode 100644 index 0000000..1858106 --- /dev/null +++ b/man/dedalus-login.1 @@ -0,0 +1,44 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-LOGIN" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-login \- sign in and save credentials +.SH "SYNOPSIS" +.B dedalus login +[\fB\-\-flow\fR \fIname\fR] [\fB\-\-base\-url\fR \fIurl\fR] +.SH "DESCRIPTION" +Obtains a credential and saves it so later commands do not have to carry one. It goes into your operating system's credential store \- the system keyring on Linux, Credential Manager on Windows \- and falls back to a file in your state directory, readable only by you, when no such store is available. On macOS it is always that file, because the system's own tool accepts a password only on its command line, where other processes could read it. Either way it is filed under the base URL it was captured for, so a credential saved for one host is never sent to another. A credential passed with a flag, or set in the environment, still takes precedence over a saved one. +.SH "OPTIONS" +.TP +\fB\-\-flow\fR \fIname\fR +Sign\-in flow to use, skipping the prompt. One of: api\-key, x\-api\-key. +.TP +\fB\-\-base\-url\fR \fIurl\fR +Sign in against this base URL instead of the default. +.SH "SIGN\-IN FLOWS" +.TP +\fBapi\-key\fR +Enter your access token. +.TP +\fBx\-api\-key\fR +Enter your API key. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus login +dedalus login \-\-flow api\-key +.fi +.RE +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Sign\-in or sign\-out did not complete. The message on standard error says why. +.SH "SEE ALSO" +.BR dedalus (1), +.BR dedalus\-logout (1) diff --git a/man/dedalus-logout.1 b/man/dedalus-logout.1 new file mode 100644 index 0000000..f299333 --- /dev/null +++ b/man/dedalus-logout.1 @@ -0,0 +1,28 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-LOGOUT" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-logout \- forget saved credentials +.SH "SYNOPSIS" +.B dedalus logout +[\fB\-\-all\fR] [\fB\-\-base\-url\fR \fIurl\fR] +.SH "DESCRIPTION" +Removes the credentials saved for one base URL, or for every base URL. Nothing else is affected: a credential supplied through a flag or an environment variable is not stored here and keeps working. +.SH "OPTIONS" +.TP +\fB\-\-all\fR +Forget every saved credential, for every base URL. +.TP +\fB\-\-base\-url\fR \fIurl\fR +Forget the credentials saved for this base URL. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Sign\-in or sign\-out did not complete. The message on standard error says why. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-autoresizing-retrieve.1 b/man/dedalus-machines-autoresizing-retrieve.1 new file mode 100644 index 0000000..6f26c10 --- /dev/null +++ b/man/dedalus-machines-autoresizing-retrieve.1 @@ -0,0 +1,98 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-AUTORESIZING\-RETRIEVE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-autoresizing\-retrieve \- Read this machine's RAM autoresizing settings +.SH "SYNOPSIS" +.B dedalus machines:autoresizing retrieve +\fB\-\-machine\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Read this machine's RAM autoresizing settings +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:autoresizing retrieve \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-autoresizing-update.1 b/man/dedalus-machines-autoresizing-update.1 new file mode 100644 index 0000000..25f34a6 --- /dev/null +++ b/man/dedalus-machines-autoresizing-update.1 @@ -0,0 +1,102 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-AUTORESIZING\-UPDATE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-autoresizing\-update \- Set this machine's RAM autoresizing settings +.SH "SYNOPSIS" +.B dedalus machines:autoresizing update +\fB\-\-machine\-id\fR \fI\fR \fB\-\-enabled\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Set this machine's RAM autoresizing settings +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-enabled\fR +The enabled body parameter. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:autoresizing update \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-enabled +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-create.1 b/man/dedalus-machines-create.1 new file mode 100644 index 0000000..47a86d4 --- /dev/null +++ b/man/dedalus-machines-create.1 @@ -0,0 +1,110 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-CREATE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-create \- Create machine +.SH "SYNOPSIS" +.B dedalus machines create +[\fIoptions\fR] +.SH "DESCRIPTION" +Create machine +.SH "OPTIONS" +.TP +\fB\-\-autosleep\fR \fI\fR +Idle window before autosleep. Accepts fixed duration units like 30s, 30m, 2h, 7d3h4s, or 1w3d, raw seconds ("1800"), or never to disable. +.TP +\fB\-\-memory\-mib\fR \fI\fR +Memory in MiB. +.TP +\fB\-\-storage\-gib\fR \fI\fR +Storage in GiB. +.TP +\fB\-\-vcpu\fR \fI\fR +CPU in vCPUs. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines create \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-autosleep '300s' \e + \-\-memory\-mib '4096' \e + \-\-storage\-gib '10' \e + \-\-vcpu '1' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-delete.1 b/man/dedalus-machines-delete.1 new file mode 100644 index 0000000..0e2d23a --- /dev/null +++ b/man/dedalus-machines-delete.1 @@ -0,0 +1,98 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-DELETE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-delete \- Destroy machine +.SH "SYNOPSIS" +.B dedalus machines delete +\fB\-\-machine\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Destroy machine +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines delete \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-executions-create.1 b/man/dedalus-machines-executions-create.1 new file mode 100644 index 0000000..c489ada --- /dev/null +++ b/man/dedalus-machines-executions-create.1 @@ -0,0 +1,114 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-EXECUTIONS\-CREATE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-executions\-create \- Create execution +.SH "SYNOPSIS" +.B dedalus machines:executions create +\fB\-\-machine\-id\fR \fI\fR \fB\-\-command\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Create execution +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-command\fR \fI\fR +The command body parameter. Required. +.TP +\fB\-\-cwd\fR \fI\fR +The cwd body parameter. +.TP +\fB\-\-env\fR \fI\fR +The env body parameter. +.TP +\fB\-\-stdin\fR \fI\fR +The stdin body parameter. +.TP +\fB\-\-timeout\-ms\fR \fI\fR +The timeout\-ms body parameter. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:executions create \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-command '[""]' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-executions-delete.1 b/man/dedalus-machines-executions-delete.1 new file mode 100644 index 0000000..750f0cf --- /dev/null +++ b/man/dedalus-machines-executions-delete.1 @@ -0,0 +1,102 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-EXECUTIONS\-DELETE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-executions\-delete \- Delete execution +.SH "SYNOPSIS" +.B dedalus machines:executions delete +\fB\-\-machine\-id\fR \fI\fR \fB\-\-execution\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Delete execution +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-execution\-id\fR \fI\fR +The execution\-id path parameter. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:executions delete \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-execution\-id 'execution_id' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-executions-events.1 b/man/dedalus-machines-executions-events.1 new file mode 100644 index 0000000..6fd107a --- /dev/null +++ b/man/dedalus-machines-executions-events.1 @@ -0,0 +1,112 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-EXECUTIONS\-EVENTS" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-executions\-events \- List execution events +.SH "SYNOPSIS" +.B dedalus machines:executions events +\fB\-\-machine\-id\fR \fI\fR \fB\-\-execution\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +List execution events +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-execution\-id\fR \fI\fR +The execution\-id path parameter. Required. +.TP +\fB\-\-limit\fR \fI\fR +The limit query parameter. +.TP +\fB\-\-cursor\fR \fI\fR +The cursor query parameter. +.TP +\fB\-\-max\-items\fR \fI\fR +Maximum number of items to print; use \-1 for unlimited. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:executions events \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-execution\-id 'execution_id' \e + \-\-max\-items 10 +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-executions-list.1 b/man/dedalus-machines-executions-list.1 new file mode 100644 index 0000000..e4f693f --- /dev/null +++ b/man/dedalus-machines-executions-list.1 @@ -0,0 +1,108 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-EXECUTIONS\-LIST" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-executions\-list \- List executions +.SH "SYNOPSIS" +.B dedalus machines:executions list +\fB\-\-machine\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +List executions +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-limit\fR \fI\fR +The limit query parameter. +.TP +\fB\-\-cursor\fR \fI\fR +The cursor query parameter. +.TP +\fB\-\-max\-items\fR \fI\fR +Maximum number of items to print; use \-1 for unlimited. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:executions list \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-max\-items 10 +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-executions-logs-create-token.1 b/man/dedalus-machines-executions-logs-create-token.1 new file mode 100644 index 0000000..46ebb72 --- /dev/null +++ b/man/dedalus-machines-executions-logs-create-token.1 @@ -0,0 +1,102 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-EXECUTIONS\-LOGS\-CREATE\-TOKEN" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-executions\-logs\-create\-token \- Create execution log read token +.SH "SYNOPSIS" +.B dedalus machines:executions:logs create\-token +\fB\-\-machine\-id\fR \fI\fR \fB\-\-execution\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Create execution log read token +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-execution\-id\fR \fI\fR +The execution\-id path parameter. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:executions:logs create\-token \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-execution\-id 'execution_id' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-executions-logs-reauthorize.1 b/man/dedalus-machines-executions-logs-reauthorize.1 new file mode 100644 index 0000000..d854293 --- /dev/null +++ b/man/dedalus-machines-executions-logs-reauthorize.1 @@ -0,0 +1,102 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-EXECUTIONS\-LOGS\-REAUTHORIZE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-executions\-logs\-reauthorize \- Reauthorize execution log publication +.SH "SYNOPSIS" +.B dedalus machines:executions:logs reauthorize +\fB\-\-machine\-id\fR \fI\fR \fB\-\-execution\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Reauthorize execution log publication +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-execution\-id\fR \fI\fR +The execution\-id path parameter. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:executions:logs reauthorize \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-execution\-id 'execution_id' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-executions-logs-retrieve.1 b/man/dedalus-machines-executions-logs-retrieve.1 new file mode 100644 index 0000000..7581589 --- /dev/null +++ b/man/dedalus-machines-executions-logs-retrieve.1 @@ -0,0 +1,102 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-EXECUTIONS\-LOGS\-RETRIEVE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-executions\-logs\-retrieve \- Get execution log status +.SH "SYNOPSIS" +.B dedalus machines:executions:logs retrieve +\fB\-\-machine\-id\fR \fI\fR \fB\-\-execution\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Get execution log status +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-execution\-id\fR \fI\fR +The execution\-id path parameter. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:executions:logs retrieve \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-execution\-id 'execution_id' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-executions-output.1 b/man/dedalus-machines-executions-output.1 new file mode 100644 index 0000000..7c184a8 --- /dev/null +++ b/man/dedalus-machines-executions-output.1 @@ -0,0 +1,102 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-EXECUTIONS\-OUTPUT" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-executions\-output \- Get execution output +.SH "SYNOPSIS" +.B dedalus machines:executions output +\fB\-\-machine\-id\fR \fI\fR \fB\-\-execution\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Get execution output +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-execution\-id\fR \fI\fR +The execution\-id path parameter. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:executions output \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-execution\-id 'execution_id' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-executions-retrieve.1 b/man/dedalus-machines-executions-retrieve.1 new file mode 100644 index 0000000..3c7c650 --- /dev/null +++ b/man/dedalus-machines-executions-retrieve.1 @@ -0,0 +1,102 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-EXECUTIONS\-RETRIEVE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-executions\-retrieve \- Get execution +.SH "SYNOPSIS" +.B dedalus machines:executions retrieve +\fB\-\-machine\-id\fR \fI\fR \fB\-\-execution\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Get execution +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-execution\-id\fR \fI\fR +The execution\-id path parameter. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:executions retrieve \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-execution\-id 'execution_id' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-list.1 b/man/dedalus-machines-list.1 new file mode 100644 index 0000000..80eeb41 --- /dev/null +++ b/man/dedalus-machines-list.1 @@ -0,0 +1,102 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-LIST" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-list \- List machines +.SH "SYNOPSIS" +.B dedalus machines list +[\fIoptions\fR] +.SH "DESCRIPTION" +List machines +.SH "OPTIONS" +.TP +\fB\-\-limit\fR \fI\fR +The limit query parameter. +.TP +\fB\-\-cursor\fR \fI\fR +The cursor query parameter. +.TP +\fB\-\-max\-items\fR \fI\fR +Maximum number of items to print; use \-1 for unlimited. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines list \-\-x\-api\-key "$DEDALUS_X_API_KEY" \-\-max\-items 10 +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-reboot.1 b/man/dedalus-machines-reboot.1 new file mode 100644 index 0000000..00af3f9 --- /dev/null +++ b/man/dedalus-machines-reboot.1 @@ -0,0 +1,101 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-REBOOT" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-reboot \- Reboot a machine with fresh memory +.SH "SYNOPSIS" +.B dedalus machines reboot +\fB\-\-machine\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Checkpoints files and replaces the runtime. The machine ID and filesystem are preserved. RAM, processes, and temporary mounts are cleared. Poll the machine until its phase is running. Retry the same Idempotency\-Key after a lost response. +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-force\fR +Recover from the last committed filesystem checkpoint without guest cooperation. Unpublished file writes are lost. The default checkpoints files before rebooting. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines reboot \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-retrieve.1 b/man/dedalus-machines-retrieve.1 new file mode 100644 index 0000000..8b84b2b --- /dev/null +++ b/man/dedalus-machines-retrieve.1 @@ -0,0 +1,98 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-RETRIEVE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-retrieve \- Get machine +.SH "SYNOPSIS" +.B dedalus machines retrieve +\fB\-\-machine\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Get machine +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines retrieve \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-sleep.1 b/man/dedalus-machines-sleep.1 new file mode 100644 index 0000000..1107870 --- /dev/null +++ b/man/dedalus-machines-sleep.1 @@ -0,0 +1,98 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-SLEEP" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-sleep \- Sleep a running machine +.SH "SYNOPSIS" +.B dedalus machines sleep +\fB\-\-machine\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Sleep a running machine +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines sleep \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-ssh-create.1 b/man/dedalus-machines-ssh-create.1 new file mode 100644 index 0000000..a545353 --- /dev/null +++ b/man/dedalus-machines-ssh-create.1 @@ -0,0 +1,102 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-SSH\-CREATE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-ssh\-create \- Create SSH session +.SH "SYNOPSIS" +.B dedalus machines:ssh create +\fB\-\-machine\-id\fR \fI\fR \fB\-\-public\-key\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Create SSH session +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-public\-key\fR \fI\fR +The public\-key body parameter. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:ssh create \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-public\-key "@file://$HOME/.ssh/id_ed25519.pub" +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-ssh-delete.1 b/man/dedalus-machines-ssh-delete.1 new file mode 100644 index 0000000..f9f4c12 --- /dev/null +++ b/man/dedalus-machines-ssh-delete.1 @@ -0,0 +1,102 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-SSH\-DELETE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-ssh\-delete \- Delete SSH session +.SH "SYNOPSIS" +.B dedalus machines:ssh delete +\fB\-\-machine\-id\fR \fI\fR \fB\-\-session\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Delete SSH session +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-session\-id\fR \fI\fR +The session\-id path parameter. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:ssh delete \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-session\-id 'session_id' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-ssh-list.1 b/man/dedalus-machines-ssh-list.1 new file mode 100644 index 0000000..f8674a2 --- /dev/null +++ b/man/dedalus-machines-ssh-list.1 @@ -0,0 +1,108 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-SSH\-LIST" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-ssh\-list \- List SSH sessions +.SH "SYNOPSIS" +.B dedalus machines:ssh list +\fB\-\-machine\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +List SSH sessions +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-limit\fR \fI\fR +The limit query parameter. +.TP +\fB\-\-cursor\fR \fI\fR +The cursor query parameter. +.TP +\fB\-\-max\-items\fR \fI\fR +Maximum number of items to print; use \-1 for unlimited. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:ssh list \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-max\-items 10 +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-ssh-retrieve.1 b/man/dedalus-machines-ssh-retrieve.1 new file mode 100644 index 0000000..dbbaaec --- /dev/null +++ b/man/dedalus-machines-ssh-retrieve.1 @@ -0,0 +1,102 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-SSH\-RETRIEVE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-ssh\-retrieve \- Get SSH session +.SH "SYNOPSIS" +.B dedalus machines:ssh retrieve +\fB\-\-machine\-id\fR \fI\fR \fB\-\-session\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Get SSH session +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-session\-id\fR \fI\fR +The session\-id path parameter. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines:ssh retrieve \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' \e + \-\-session\-id 'session_id' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-update.1 b/man/dedalus-machines-update.1 new file mode 100644 index 0000000..5d5fc86 --- /dev/null +++ b/man/dedalus-machines-update.1 @@ -0,0 +1,110 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-UPDATE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-update \- Update machine +.SH "SYNOPSIS" +.B dedalus machines update +\fB\-\-machine\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Update machine +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-autosleep\fR \fI\fR +Idle window before autosleep. Accepts fixed duration units like 30s, 30m, 2h, 7d3h4s, or 1w3d, raw seconds ("1800"), or never to disable. +.TP +\fB\-\-memory\-mib\fR \fI\fR +Memory in MiB. +.TP +\fB\-\-storage\-gib\fR \fI\fR +Storage in GiB. +.TP +\fB\-\-vcpu\fR \fI\fR +CPU in vCPUs. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines update \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-machines-wake.1 b/man/dedalus-machines-wake.1 new file mode 100644 index 0000000..45ad842 --- /dev/null +++ b/man/dedalus-machines-wake.1 @@ -0,0 +1,98 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-MACHINES\-WAKE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-machines\-wake \- Wake a sleeping machine +.SH "SYNOPSIS" +.B dedalus machines wake +\fB\-\-machine\-id\fR \fI\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Wake a sleeping machine +.SH "OPTIONS" +.TP +\fB\-\-machine\-id\fR \fI\fR +Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus machines wake \e + \-\-x\-api\-key "$DEDALUS_X_API_KEY" \e + \-\-machine\-id '017f22e2\-79b0\-7cc3\-98c4\-dc0c0c07398f' +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-organization-autoresizing-retrieve.1 b/man/dedalus-organization-autoresizing-retrieve.1 new file mode 100644 index 0000000..c555dec --- /dev/null +++ b/man/dedalus-organization-autoresizing-retrieve.1 @@ -0,0 +1,93 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-ORGANIZATION\-AUTORESIZING\-RETRIEVE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-organization\-autoresizing\-retrieve \- Read organization RAM autoresizing policy +.SH "SYNOPSIS" +.B dedalus organization:autoresizing retrieve +[\fIoptions\fR] +.SH "DESCRIPTION" +Read organization RAM autoresizing policy +.SH "OPTIONS" +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus organization:autoresizing retrieve \-\-x\-api\-key "$DEDALUS_X_API_KEY" +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus-organization-autoresizing-update.1 b/man/dedalus-organization-autoresizing-update.1 new file mode 100644 index 0000000..0e2c6b6 --- /dev/null +++ b/man/dedalus-organization-autoresizing-update.1 @@ -0,0 +1,96 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS\-ORGANIZATION\-AUTORESIZING\-UPDATE" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus\-organization\-autoresizing\-update \- Set organization RAM autoresizing policy +.SH "SYNOPSIS" +.B dedalus organization:autoresizing update +\fB\-\-enabled\fR [\fIoptions\fR] +.SH "DESCRIPTION" +Set organization RAM autoresizing policy +.SH "OPTIONS" +.TP +\fB\-\-enabled\fR +Allow automatic RAM increases for all organization machines. Disabling preserves applied RAM and already admitted resizes. Required. +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "EXAMPLES" +.PP +.RS 4 +.nf +dedalus organization:autoresizing update \-\-x\-api\-key "$DEDALUS_X_API_KEY" \-\-enabled +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. +.SH "SEE ALSO" +.BR dedalus (1) diff --git a/man/dedalus.1 b/man/dedalus.1 new file mode 100644 index 0000000..389e5db --- /dev/null +++ b/man/dedalus.1 @@ -0,0 +1,192 @@ +.\" File generated from our OpenAPI spec by Scalar. Do not edit. +.TH "DEDALUS" "1" "" "dedalus" "Dedalus Manual" +.SH "NAME" +dedalus \- command line interface for the Dedalus REST API +.SH "SYNOPSIS" +.B dedalus +[\fIglobal options\fR] \fIcommand\fR [\fIsubcommand\fR] [\fIarguments\fR] [\fIoptions\fR] +.SH "DESCRIPTION" +dedalus calls the Dedalus REST API from the command line. Commands are grouped by resource, and every command prints its result to standard output in the configured format. +.PP +Controlplane API for Dedalus Cloud Services (DCS). +.SH "GLOBAL OPTIONS" +.TP +\fB\-\-base\-url\fR \fI\fR +Override the base URL for API requests. +.TP +\fB\-\-timeout\fR \fI\fR +Request timeout in milliseconds. +.TP +\fB\-\-max\-retries\fR \fI\fR +Number of retries for retryable failures. +.TP +\fB\-\-format\fR \fI\fR +Output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-format\-error\fR \fI\fR +Error output format: auto, json, jsonl, pretty, raw, toon, yaml. +.TP +\fB\-\-transform\fR \fI\fR +Dot\-path transform for data output. +.TP +\fB\-\-transform\-error\fR \fI\fR +Dot\-path transform for error output. +.TP +\fB\-r\fR, \fB\-\-raw\-output\fR +Print transformed string values without JSON quotes. +.TP +\fB\-\-debug\fR +Enable SDK debug logging on standard error. +.TP +\fB\-\-api\-key\fR \fI\fR +Dedalus API key or short\-lived delegated access token in Authorization: Bearer . Can also be set with the DEDALUS_API_KEY environment variable. +.TP +\fB\-\-x\-api\-key\fR \fI\fR +Dedalus API key. Alternative to Bearer token. Can also be set with the DEDALUS_X_API_KEY environment variable. +.TP +\fB\-\-dedalus\-org\-id\fR \fI\fR +Optional organization assertion. Must match the organization identified by the API credential. Can also be set with the DEDALUS_ORG_ID environment variable. +.SH "COMMANDS" +.TP +\fBdedalus machines list\fR +List machines. See dedalus\-machines\-list(1). +.TP +\fBdedalus machines create\fR +Create machine. See dedalus\-machines\-create(1). +.TP +\fBdedalus machines retrieve\fR +Get machine. See dedalus\-machines\-retrieve(1). +.TP +\fBdedalus machines update\fR +Update machine. See dedalus\-machines\-update(1). +.TP +\fBdedalus machines delete\fR +Destroy machine. See dedalus\-machines\-delete(1). +.TP +\fBdedalus machines sleep\fR +Sleep a running machine. See dedalus\-machines\-sleep(1). +.TP +\fBdedalus machines wake\fR +Wake a sleeping machine. See dedalus\-machines\-wake(1). +.TP +\fBdedalus machines reboot\fR +Reboot a machine with fresh memory. See dedalus\-machines\-reboot(1). +.TP +\fBdedalus machines:ssh list\fR +List SSH sessions. See dedalus\-machines\-ssh\-list(1). +.TP +\fBdedalus machines:ssh create\fR +Create SSH session. See dedalus\-machines\-ssh\-create(1). +.TP +\fBdedalus machines:ssh retrieve\fR +Get SSH session. See dedalus\-machines\-ssh\-retrieve(1). +.TP +\fBdedalus machines:ssh delete\fR +Delete SSH session. See dedalus\-machines\-ssh\-delete(1). +.TP +\fBdedalus machines:executions list\fR +List executions. See dedalus\-machines\-executions\-list(1). +.TP +\fBdedalus machines:executions create\fR +Create execution. See dedalus\-machines\-executions\-create(1). +.TP +\fBdedalus machines:executions retrieve\fR +Get execution. See dedalus\-machines\-executions\-retrieve(1). +.TP +\fBdedalus machines:executions delete\fR +Delete execution. See dedalus\-machines\-executions\-delete(1). +.TP +\fBdedalus machines:executions output\fR +Get execution output. See dedalus\-machines\-executions\-output(1). +.TP +\fBdedalus machines:executions events\fR +List execution events. See dedalus\-machines\-executions\-events(1). +.TP +\fBdedalus machines:executions:logs retrieve\fR +Get execution log status. See dedalus\-machines\-executions\-logs\-retrieve(1). +.TP +\fBdedalus machines:executions:logs reauthorize\fR +Reauthorize execution log publication. See dedalus\-machines\-executions\-logs\-reauthorize(1). +.TP +\fBdedalus machines:executions:logs create\-token\fR +Create execution log read token. See dedalus\-machines\-executions\-logs\-create\-token(1). +.TP +\fBdedalus machines:autoresizing retrieve\fR +Read this machine's RAM autoresizing settings. See dedalus\-machines\-autoresizing\-retrieve(1). +.TP +\fBdedalus machines:autoresizing update\fR +Set this machine's RAM autoresizing settings. See dedalus\-machines\-autoresizing\-update(1). +.TP +\fBdedalus organization:autoresizing retrieve\fR +Read organization RAM autoresizing policy. See dedalus\-organization\-autoresizing\-retrieve(1). +.TP +\fBdedalus organization:autoresizing update\fR +Set organization RAM autoresizing policy. See dedalus\-organization\-autoresizing\-update(1). +.TP +\fBdedalus completion\fR +Print a shell completion script. See dedalus\-completion(1). +.TP +\fBdedalus login\fR +Sign in and save credentials for later commands. See dedalus\-login(1). +.TP +\fBdedalus logout\fR +Forget saved credentials. See dedalus\-logout(1). +.SH "FILE ARGUMENTS" +Any command flag or credential reads its value from a file when the value begins with @. A flag shown with a value uploads a file and takes its path with or without the @. The global options above are read exactly as written. +.PP +.RS 4 +.nf +dedalus COMMAND \-\-FLAG @./body.json # contents of the file +dedalus COMMAND \-\-FLAG @file://./n.txt # always sent as text +dedalus COMMAND \-\-FLAG @data://./l.png # always base64\-encoded +dedalus COMMAND \-\-FLAG '\e@literal' # a value that begins with @ +.fi +.RE +.SH "SHELL COMPLETION" +dedalus completion prints a completion script for bash, zsh, and fish. Load it from your shell startup file: +.PP +.RS 4 +.nf +eval "$(dedalus completion bash)" +eval "$(dedalus completion zsh)" +dedalus completion fish | source +.fi +.RE +.SH "ENVIRONMENT" +.TP +\fBDEDALUS_API_KEY\fR +Fallback for \-\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_X_API_KEY\fR +Fallback for \-\-x\-api\-key when the flag is not passed. +.TP +\fBDEDALUS_ORG_ID\fR +Fallback for \-\-dedalus\-org\-id when the flag is not passed. +.SH "EXIT STATUS" +.TP +\fB0\fR +The command completed successfully. +.TP +\fB1\fR +The request failed for an unclassified reason. Reported as error. +.TP +\fB2\fR +The command was invoked incorrectly (missing or invalid arguments). +.TP +\fB10\fR +Authentication or authorization failed (HTTP 401 or 403). Reported as auth\-failed. +.TP +\fB11\fR +The requested resource does not exist (HTTP 404). Reported as not\-found. +.TP +\fB12\fR +The request was rate limited (HTTP 429). Reported as rate\-limited. +.TP +\fB13\fR +The request was rejected (any other HTTP 4xx). Reported as client\-error. +.TP +\fB14\fR +The API failed to handle the request (HTTP 5xx). Reported as server\-error. +.TP +\fB15\fR +The API could not be reached, or the request timed out. Reported as connection\-error. diff --git a/package.json b/package.json new file mode 100644 index 0000000..4d5c83d --- /dev/null +++ b/package.json @@ -0,0 +1,81 @@ +{ + "name": "dedalus-cli", + "version": "0.7.0", + "description": "Controlplane API for Dedalus Cloud Services (DCS).", + "author": "Dedalus", + "repository": { + "type": "git", + "url": "git+https://github.com/dedalus-labs/dedalus-cli.git" + }, + "type": "module", + "bin": { + "dedalus": "./dist/esm/bin.js" + }, + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "default": "./dist/esm/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "api.md", + "man", + "SKILL.md" + ], + "man": [ + "./man/dedalus.1", + "./man/dedalus-login.1", + "./man/dedalus-logout.1", + "./man/dedalus-machines-list.1", + "./man/dedalus-machines-create.1", + "./man/dedalus-machines-retrieve.1", + "./man/dedalus-machines-update.1", + "./man/dedalus-machines-delete.1", + "./man/dedalus-machines-sleep.1", + "./man/dedalus-machines-wake.1", + "./man/dedalus-machines-reboot.1", + "./man/dedalus-machines-ssh-list.1", + "./man/dedalus-machines-ssh-create.1", + "./man/dedalus-machines-ssh-retrieve.1", + "./man/dedalus-machines-ssh-delete.1", + "./man/dedalus-machines-executions-list.1", + "./man/dedalus-machines-executions-create.1", + "./man/dedalus-machines-executions-retrieve.1", + "./man/dedalus-machines-executions-delete.1", + "./man/dedalus-machines-executions-output.1", + "./man/dedalus-machines-executions-events.1", + "./man/dedalus-machines-executions-logs-retrieve.1", + "./man/dedalus-machines-executions-logs-reauthorize.1", + "./man/dedalus-machines-executions-logs-create-token.1", + "./man/dedalus-machines-autoresizing-retrieve.1", + "./man/dedalus-machines-autoresizing-update.1", + "./man/dedalus-organization-autoresizing-retrieve.1", + "./man/dedalus-organization-autoresizing-update.1", + "./man/dedalus-completion.1" + ], + "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/finalize-build.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.cjs.json --noEmit", + "test": "node --test tests/feedback-*.test.mjs tests/browser-login.test.mjs", + "ci:generate": "hollywood generate 'ci/**/*.ts' --source-root ci --output .", + "test:package": "node scripts/verify-staging-package.mjs" + }, + "dependencies": { + "ansis": "^4.3.0", + "commander": "^14.0.3", + "yaml": "^2.9.0" + }, + "devDependencies": { + "@types/node": "^20.17.0", + "typescript": "^6.0.0", + "@dedalus-labs/hollywood": "0.0.5" + }, + "license": "Apache-2.0" +} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go deleted file mode 100644 index bae36c8..0000000 --- a/pkg/cmd/cmd.go +++ /dev/null @@ -1,216 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "bytes" - "compress/gzip" - "context" - "fmt" - "os" - "path/filepath" - "slices" - "strings" - - "github.com/dedalus-labs/dedalus-cli/internal/autocomplete" - "github.com/dedalus-labs/dedalus-cli/internal/requestflag" - docs "github.com/urfave/cli-docs/v3" - "github.com/urfave/cli/v3" -) - -var ( - Command *cli.Command - CommandErrorBuffer bytes.Buffer -) - -func init() { - Command = &cli.Command{ - Name: "dedalus", - Usage: "CLI for the Dedalus API", - Suggest: true, - Version: Version, - ErrWriter: &CommandErrorBuffer, - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "debug", - Usage: "Enable debug logging", - }, - &cli.StringFlag{ - Name: "base-url", - DefaultText: "url", - Usage: "Override the base URL for API requests", - Validator: func(baseURL string) error { - return ValidateBaseURL(baseURL, "--base-url") - }, - }, - &cli.StringFlag{ - Name: "format", - Usage: "The format for displaying response data (one of: " + strings.Join(OutputFormats, ", ") + ")", - Value: "json", - Validator: func(format string) error { - if !slices.Contains(OutputFormats, strings.ToLower(format)) { - return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", ")) - } - return nil - }, - }, - &cli.StringFlag{ - Name: "format-error", - Usage: "The format for displaying error data (one of: " + strings.Join(OutputFormats, ", ") + ")", - Value: "json", - Validator: func(format string) error { - if !slices.Contains(OutputFormats, strings.ToLower(format)) { - return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", ")) - } - return nil - }, - }, - &cli.StringFlag{ - Name: "transform", - Usage: "The GJSON transformation for data output.", - }, - &cli.StringFlag{ - Name: "transform-error", - Usage: "The GJSON transformation for errors.", - }, - &cli.BoolFlag{ - Name: "raw-output", - Aliases: []string{"r"}, - Usage: "If the result is a string, print it without JSON quotes. This can be useful for making output transforms talk to non-JSON-based systems.", - }, - &requestflag.Flag[string]{ - Name: "api-key", - Usage: "Dedalus API key sent as Authorization Bearer.", - Sources: cli.EnvVars("DEDALUS_API_KEY"), - }, - &requestflag.Flag[string]{ - Name: "x-api-key", - Usage: "Dedalus API key sent as x-api-key header.", - Sources: cli.EnvVars("DEDALUS_X_API_KEY"), - }, - &requestflag.Flag[string]{ - Name: "dedalus-org-id", - Usage: "Organization ID header for all DCS requests.", - Sources: cli.EnvVars("DEDALUS_ORG_ID"), - }, - }, - Commands: []*cli.Command{ - { - Name: "machines", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &machinesCreate, - &machinesRetrieve, - &machinesUpdate, - &machinesList, - &machinesDelete, - &machinesSleep, - &machinesWake, - }, - }, - { - Name: "machines:ssh", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &machinesSSHCreate, - &machinesSSHRetrieve, - &machinesSSHList, - &machinesSSHDelete, - }, - }, - { - Name: "machines:executions", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &machinesExecutionsCreate, - &machinesExecutionsRetrieve, - &machinesExecutionsList, - &machinesExecutionsDelete, - &machinesExecutionsEvents, - &machinesExecutionsOutput, - }, - }, - { - Name: "@manpages", - Usage: "Generate documentation for 'man'", - UsageText: "dedalus @manpages [-o dedalus.1] [--gzip]", - Hidden: true, - Action: generateManpages, - HideHelpCommand: true, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "output", - Aliases: []string{"o"}, - Usage: "write manpages to the given folder", - Value: "man", - }, - &cli.BoolFlag{ - Name: "gzip", - Aliases: []string{"z"}, - Usage: "output gzipped manpage files to .gz", - Value: true, - }, - &cli.BoolFlag{ - Name: "text", - Aliases: []string{"z"}, - Usage: "output uncompressed text files", - Value: false, - }, - }, - }, - { - Name: "__complete", - Hidden: true, - HideHelpCommand: true, - Action: autocomplete.ExecuteShellCompletion, - }, - { - Name: "@completion", - Hidden: true, - HideHelpCommand: true, - Action: autocomplete.OutputCompletionScript, - }, - }, - HideHelpCommand: true, - } -} - -func generateManpages(ctx context.Context, c *cli.Command) error { - manpage, err := docs.ToManWithSection(Command, 1) - if err != nil { - return err - } - dir := c.String("output") - err = os.MkdirAll(filepath.Join(dir, "man1"), 0755) - if err != nil { - // handle error - } - if c.Bool("text") { - file, err := os.Create(filepath.Join(dir, "man1", "dedalus.1")) - if err != nil { - return err - } - defer file.Close() - if _, err := file.WriteString(manpage); err != nil { - return err - } - } - if c.Bool("gzip") { - file, err := os.Create(filepath.Join(dir, "man1", "dedalus.1.gz")) - if err != nil { - return err - } - defer file.Close() - gzWriter := gzip.NewWriter(file) - defer gzWriter.Close() - _, err = gzWriter.Write([]byte(manpage)) - if err != nil { - return err - } - } - fmt.Printf("Wrote manpages to %s\n", dir) - return nil -} diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go deleted file mode 100644 index d1bf75c..0000000 --- a/pkg/cmd/cmdutil.go +++ /dev/null @@ -1,531 +0,0 @@ -package cmd - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "log" - "mime" - "net/http" - "net/http/httputil" - "os" - "os/exec" - "os/signal" - "path/filepath" - "strings" - "syscall" - - "github.com/dedalus-labs/dedalus-cli/internal/jsonview" - "github.com/dedalus-labs/dedalus-go/option" - - "github.com/charmbracelet/x/term" - "github.com/itchyny/json2yaml" - "github.com/muesli/reflow/wrap" - "github.com/tidwall/gjson" - "github.com/tidwall/pretty" - "github.com/urfave/cli/v3" -) - -var OutputFormats = []string{"auto", "explore", "json", "jsonl", "pretty", "raw", "yaml"} - -// ValidateBaseURL checks that a base URL is correctly prefixed with a protocol scheme and produces a better -// error message than the person would see otherwise if it doesn't. -func ValidateBaseURL(value, source string) error { - if value != "" && !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { - return fmt.Errorf("%s %q is missing a scheme (expected http:// or https://)", source, value) - } - return nil -} - -func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { - opts := []option.RequestOption{ - option.WithHeader("User-Agent", fmt.Sprintf("Dedalus/CLI %s", Version)), - option.WithHeader("X-Stainless-Lang", "cli"), - option.WithHeader("X-Stainless-Package-Version", Version), - option.WithHeader("X-Stainless-Runtime", "cli"), - option.WithHeader("X-Stainless-CLI-Command", cmd.FullName()), - } - if cmd.IsSet("api-key") { - opts = append(opts, option.WithAPIKey(cmd.String("api-key"))) - } - if cmd.IsSet("x-api-key") { - opts = append(opts, option.WithXAPIKey(cmd.String("x-api-key"))) - } - if cmd.IsSet("dedalus-org-id") { - opts = append(opts, option.WithDedalusOrgID(cmd.String("dedalus-org-id"))) - } - - // Override base URL if the --base-url flag is provided - if baseURL := cmd.String("base-url"); baseURL != "" { - opts = append(opts, option.WithBaseURL(baseURL)) - } - - return opts -} - -var debugMiddlewareOption = option.WithMiddleware( - func(r *http.Request, mn option.MiddlewareNext) (*http.Response, error) { - logger := log.Default() - - if reqBytes, err := httputil.DumpRequest(r, true); err == nil { - logger.Printf("Request Content:\n%s\n", reqBytes) - } - - resp, err := mn(r) - if err != nil { - return resp, err - } - - if respBytes, err := httputil.DumpResponse(resp, true); err == nil { - logger.Printf("Response Content:\n%s\n", respBytes) - } - - return resp, err - }, -) - -// isInputPiped tries to check for input being piped into the CLI which tells us that we should try to read -// from stdin. This can be a bit tricky in some cases like when an stdin is connected to a pipe but nothing is -// being piped in (this may happen in some environments like Cursor's integration terminal or CI), which is -// why this function is a little more elaborate than it'd be otherwise. -func isInputPiped() bool { - stat, err := os.Stdin.Stat() - if err != nil { - return false - } - - mode := stat.Mode() - - // Regular file (redirect like < file.txt) — only if non-empty. - // - // Notably, on Unix the case like `< /dev/null` is handled below because `/dev/null` is not a regular - // file. On Windows, NUL appears as a regular file with size 0, so it's also handled correctly. - if mode.IsRegular() && stat.Size() > 0 { - return true - } - - // For pipes/sockets (e.g. `echo foo | stainlesscli`), use an OS-specific check to determine whether - // data is actually available. Some environments like Cursor's integrated terminal connect stdin as a - // pipe even when nothing is being piped. - if mode&(os.ModeNamedPipe|os.ModeSocket) != 0 { - // Defined in either cmdutil_unix.go or cmdutil_windows.go. - return isPipedDataAvailableOSSpecific() - } - - return false -} - -func isTerminal(w io.Writer) bool { - switch v := w.(type) { - case *os.File: - return term.IsTerminal(v.Fd()) - default: - return false - } -} - -func streamOutput(label string, generateOutput func(w *os.File) error) error { - // For non-tty output (probably a pipe), write directly to stdout - if !isTerminal(os.Stdout) { - return streamToStdout(generateOutput) - } - - // When streaming output on Unix-like systems, there's a special trick involving creating two socket pairs - // that we prefer because it supports small buffer sizes which results in less pagination per buffer. The - // constructs needed to run it don't exist on Windows builds, so we have this function broken up into - // OS-specific files with conditional build comments. Under Windows (and in case our fancy constructs fail - // on Unix), we fall back to using pipes (`streamToPagerWithPipe`), which are OS agnostic. - // - // Defined in either cmdutil_unix.go or cmdutil_windows.go. - return streamOutputOSSpecific(label, generateOutput) -} - -func streamToPagerWithPipe(label string, generateOutput func(w *os.File) error) error { - r, w, err := os.Pipe() - if err != nil { - return err - } - defer r.Close() - defer w.Close() - - pagerProgram := os.Getenv("PAGER") - if pagerProgram == "" { - pagerProgram = "less" - } - - if _, err := exec.LookPath(pagerProgram); err != nil { - return err - } - - cmd := exec.Command(pagerProgram) - cmd.Stdin = r - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = append(os.Environ(), - "LESS=-X -r -P "+label, - "MORE=-r -P "+label, - ) - - if err := cmd.Start(); err != nil { - return err - } - - if err := r.Close(); err != nil { - return err - } - - // If we would be streaming to a terminal and aren't forcing color one way - // or the other, we should configure things to use color so the pager gets - // colorized input. - if isTerminal(os.Stdout) && os.Getenv("FORCE_COLOR") == "" { - os.Setenv("FORCE_COLOR", "1") - } - - if err := generateOutput(w); err != nil && !strings.Contains(err.Error(), "broken pipe") { - return err - } - - w.Close() - return cmd.Wait() -} - -func streamToStdout(generateOutput func(w *os.File) error) error { - signal.Ignore(syscall.SIGPIPE) - err := generateOutput(os.Stdout) - if err != nil && strings.Contains(err.Error(), "broken pipe") { - return nil - } - return err -} - -// writeBinaryResponse writes a binary response to stdout or a file. -// -// Takes in a stdout reference so we can test this function without overriding os.Stdout in tests. -func writeBinaryResponse(response *http.Response, stdout io.Writer, outfile string) (string, error) { - defer response.Body.Close() - body, err := io.ReadAll(response.Body) - if err != nil { - return "", err - } - switch outfile { - case "-", "/dev/stdout": - _, err := stdout.Write(body) - return "", err - case "": - // If output file is unspecified, then print to stdout for plain text or - // if stdout is not a terminal: - if !isTerminal(os.Stdout) || isUTF8TextFile(body) { - _, err := stdout.Write(body) - return "", err - } - - // If response has a suggested filename in the content-disposition - // header, then use that (with an optional suffix to ensure uniqueness): - file, err := createDownloadFile(response, body) - if err != nil { - return "", err - } - defer file.Close() - if _, err := file.Write(body); err != nil { - return "", err - } - return fmt.Sprintf("Wrote output to: %s", file.Name()), nil - default: - if err := os.WriteFile(outfile, body, 0644); err != nil { - return "", err - } - return fmt.Sprintf("Wrote output to: %s", outfile), nil - } -} - -// Return a writable file handle to a new file, which attempts to choose a good filename -// based on the Content-Disposition header or sniffing the MIME filetype of the response. -func createDownloadFile(response *http.Response, data []byte) (*os.File, error) { - filename := "file" - // If the header provided an output filename, use that - disp := response.Header.Get("Content-Disposition") - _, params, err := mime.ParseMediaType(disp) - if err == nil { - if dispFilename, ok := params["filename"]; ok { - // Only use the last path component to prevent directory traversal - filename = filepath.Base(dispFilename) - // Try to create the file with exclusive flag to avoid race conditions - file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) - if err == nil { - return file, nil - } - } - } - - // If file already exists, create a unique filename using CreateTemp - ext := filepath.Ext(filename) - if ext == "" { - ext = guessExtension(data) - } - base := strings.TrimSuffix(filename, ext) - return os.CreateTemp(".", base+"-*"+ext) -} - -func guessExtension(data []byte) string { - ct := http.DetectContentType(data) - - // Prefer common extensions over obscure ones - switch ct { - case "application/gzip": - return ".gz" - case "application/pdf": - return ".pdf" - case "application/zip": - return ".zip" - case "audio/mpeg": - return ".mp3" - case "image/bmp": - return ".bmp" - case "image/gif": - return ".gif" - case "image/jpeg": - return ".jpg" - case "image/png": - return ".png" - case "image/webp": - return ".webp" - case "video/mp4": - return ".mp4" - } - - exts, err := mime.ExtensionsByType(ct) - if err == nil && len(exts) > 0 { - return exts[0] - } else if isUTF8TextFile(data) { - return ".txt" - } else { - return ".bin" - } -} - -func shouldUseColors(w io.Writer) bool { - force, ok := os.LookupEnv("FORCE_COLOR") - if ok { - if force == "1" { - return true - } - if force == "0" { - return false - } - } - return isTerminal(w) -} - -func formatJSON(res gjson.Result, opts ShowJSONOpts) ([]byte, error) { - if opts.Transform != "" { - transformed := res.Get(opts.Transform) - if transformed.Exists() { - res = transformed - } - } - // Modeled after `jq -r` (`--raw-output`): if the result is a string, print it without JSON quotes so that - // it's easier to pipe into other programs. - if opts.RawOutput && res.Type == gjson.String { - return []byte(res.Str + "\n"), nil - } - switch strings.ToLower(opts.Format) { - case "auto": - autoOpts := opts - autoOpts.Format = "json" - autoOpts.Transform = "" - return formatJSON(res, autoOpts) - case "pretty": - return []byte(jsonview.RenderJSON(opts.Title, res) + "\n"), nil - case "json": - prettyJSON := pretty.Pretty([]byte(res.Raw)) - if shouldUseColors(opts.Stdout) { - return pretty.Color(prettyJSON, pretty.TerminalStyle), nil - } else { - return prettyJSON, nil - } - case "jsonl": - // @ugly is gjson syntax for "no whitespace", so it fits on one line - oneLineJSON := res.Get("@ugly").Raw - if shouldUseColors(opts.Stdout) { - bytes := append(pretty.Color([]byte(oneLineJSON), pretty.TerminalStyle), '\n') - return bytes, nil - } else { - return []byte(oneLineJSON + "\n"), nil - } - case "raw": - return []byte(res.Raw + "\n"), nil - case "yaml": - input := strings.NewReader(res.Raw) - var yaml strings.Builder - if err := json2yaml.Convert(&yaml, input); err != nil { - return nil, err - } - _, err := opts.Stdout.Write([]byte(yaml.String())) - return nil, err - default: - return nil, fmt.Errorf("Invalid format: %s, valid formats are: %s", opts.Format, strings.Join(OutputFormats, ", ")) - } -} - -const warningExploreNotSupported = "Warning: Output format 'explore' not supported for non-terminal output; falling back to 'json'\n" - -// ShowJSONOpts configures how JSON output is displayed. -type ShowJSONOpts struct { - ExplicitFormat bool // true if the user explicitly passed --format - Format string // output format (auto, explore, json, jsonl, pretty, raw, yaml) - RawOutput bool // like jq -r: print strings without JSON quotes - Stderr io.Writer // stderr for warnings; injectable for testing; defaults to os.Stderr - Stdout *os.File // stdout (or pager); injectable for testing; defaults to os.Stdout - Title string // display title - Transform string // GJSON path to extract before displaying -} - -func (o *ShowJSONOpts) setDefaults() { - if o.Stderr == nil { - o.Stderr = os.Stderr - } - if o.Stdout == nil { - o.Stdout = os.Stdout - } -} - -// ShowJSON displays a single JSON result to the user. -func ShowJSON(res gjson.Result, opts ShowJSONOpts) error { - opts.setDefaults() - - switch strings.ToLower(opts.Format) { - case "auto": - autoOpts := opts - autoOpts.Format = "json" - return ShowJSON(res, autoOpts) - case "explore": - if !isTerminal(opts.Stdout) { - if opts.ExplicitFormat { - fmt.Fprint(opts.Stderr, warningExploreNotSupported) - } - jsonOpts := opts - jsonOpts.Format = "json" - return ShowJSON(res, jsonOpts) - } - if opts.Transform != "" { - transformed := res.Get(opts.Transform) - if transformed.Exists() { - res = transformed - } - } - return jsonview.ExploreJSON(opts.Title, res) - default: - bytes, err := formatJSON(res, opts) - if err != nil { - return err - } - - _, err = opts.Stdout.Write(bytes) - return err - } -} - -// Get the number of lines that would be output by writing the data to the terminal -func countTerminalLines(data []byte, terminalWidth int) int { - return bytes.Count([]byte(wrap.String(string(data), terminalWidth)), []byte("\n")) -} - -type hasRawJSON interface { - RawJSON() string -} - -// ShowJSONIterator displays an iterator of values to the user. Use itemsToDisplay = -1 for no limit. -func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, opts ShowJSONOpts) error { - opts.setDefaults() - - if opts.Format == "explore" { - if isTerminal(opts.Stdout) { - return jsonview.ExploreJSONStream(opts.Title, iter) - } - if opts.ExplicitFormat { - fmt.Fprint(opts.Stderr, warningExploreNotSupported) - } - opts.Format = "json" - } - - terminalWidth, terminalHeight, err := term.GetSize(os.Stdout.Fd()) - if err != nil { - terminalWidth = 100 - terminalHeight = 40 - } - - // Decide whether or not to use a pager based on whether it's a short output or a long output - usePager := false - output := []byte{} - numberOfNewlines := 0 - // -1 is used to signal no limit of items to display - for itemsToDisplay != 0 && iter.Next() { - item := iter.Current() - var obj gjson.Result - if hasRaw, ok := any(item).(hasRawJSON); ok { - obj = gjson.Parse(hasRaw.RawJSON()) - } else { - jsonData, err := json.Marshal(item) - if err != nil { - return err - } - obj = gjson.ParseBytes(jsonData) - } - json, err := formatJSON(obj, opts) - if err != nil { - return err - } - - output = append(output, json...) - itemsToDisplay -= 1 - numberOfNewlines += countTerminalLines(json, terminalWidth) - - // If the output won't fit in the terminal window, stream it to a pager - if numberOfNewlines >= terminalHeight-3 { - usePager = true - break - } - } - - if !usePager { - _, err := opts.Stdout.Write(output) - if err != nil { - return err - } - - return iter.Err() - } - - return streamOutput(opts.Title, func(pager *os.File) error { - _, err := pager.Write(output) - if err != nil { - return err - } - - pagerOpts := opts - pagerOpts.Stdout = pager - - for iter.Next() { - if itemsToDisplay == 0 { - break - } - item := iter.Current() - var obj gjson.Result - if hasRaw, ok := any(item).(hasRawJSON); ok { - obj = gjson.Parse(hasRaw.RawJSON()) - } else { - jsonData, err := json.Marshal(item) - if err != nil { - return err - } - obj = gjson.ParseBytes(jsonData) - } - if err := ShowJSON(obj, pagerOpts); err != nil { - return err - } - itemsToDisplay -= 1 - } - return iter.Err() - }) -} diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go deleted file mode 100644 index e91cb10..0000000 --- a/pkg/cmd/cmdutil_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package cmd - -import ( - "bytes" - "io" - "net/http" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" - - "github.com/dedalus-labs/dedalus-cli/internal/jsonview" -) - -func TestStreamOutput(t *testing.T) { - t.Setenv("PAGER", "cat") - err := streamOutput("stream test", func(w *os.File) error { - _, writeErr := w.WriteString("Hello world\n") - return writeErr - }) - if err != nil { - t.Errorf("streamOutput failed: %v", err) - } -} - -func TestWriteBinaryResponse(t *testing.T) { - t.Run("write to explicit file", func(t *testing.T) { - tmpDir := t.TempDir() - outfile := tmpDir + "/output.txt" - body := []byte("test content") - resp := &http.Response{ - Body: io.NopCloser(bytes.NewReader(body)), - } - - msg, err := writeBinaryResponse(resp, os.Stdout, outfile) - - require.NoError(t, err) - assert.Contains(t, msg, outfile) - - content, err := os.ReadFile(outfile) - require.NoError(t, err) - assert.Equal(t, body, content) - }) - - t.Run("write to stdout", func(t *testing.T) { - t.Parallel() - - var buf bytes.Buffer - body := []byte("stdout content") - resp := &http.Response{ - Body: io.NopCloser(bytes.NewReader(body)), - } - msg, err := writeBinaryResponse(resp, &buf, "-") - - require.NoError(t, err) - assert.Empty(t, msg) - assert.Equal(t, body, buf.Bytes()) - }) -} - -func TestCreateDownloadFile(t *testing.T) { - t.Run("creates file with filename from header", func(t *testing.T) { - t.Chdir(t.TempDir()) - - resp := &http.Response{ - Header: http.Header{ - "Content-Disposition": []string{`attachment; filename="test.txt"`}, - }, - } - file, err := createDownloadFile(resp, []byte("test content")) - require.NoError(t, err) - defer file.Close() - assert.Equal(t, "test.txt", filepath.Base(file.Name())) - - // Create a second file with the same name to ensure it doesn't clobber the first - resp2 := &http.Response{ - Header: http.Header{ - "Content-Disposition": []string{`attachment; filename="test.txt"`}, - }, - } - file2, err := createDownloadFile(resp2, []byte("second content")) - require.NoError(t, err) - defer file2.Close() - assert.NotEqual(t, file.Name(), file2.Name(), "second file should have a different name") - assert.Contains(t, filepath.Base(file2.Name()), "test") - }) - - t.Run("creates temp file when no header", func(t *testing.T) { - t.Chdir(t.TempDir()) - - resp := &http.Response{Header: http.Header{}} - file, err := createDownloadFile(resp, []byte("test content")) - require.NoError(t, err) - defer file.Close() - assert.Contains(t, filepath.Base(file.Name()), "file-") - }) - - t.Run("prevents directory traversal", func(t *testing.T) { - t.Chdir(t.TempDir()) - - resp := &http.Response{ - Header: http.Header{ - "Content-Disposition": []string{`attachment; filename="../../../etc/passwd"`}, - }, - } - file, err := createDownloadFile(resp, []byte("test content")) - require.NoError(t, err) - defer file.Close() - assert.Equal(t, "passwd", filepath.Base(file.Name())) - }) -} - -func TestValidateBaseURL(t *testing.T) { - t.Parallel() - - t.Run("ValidHTTPS", func(t *testing.T) { - t.Parallel() - - require.NoError(t, ValidateBaseURL("https://api.example.com", "--base-url")) - }) - - t.Run("ValidHTTP", func(t *testing.T) { - t.Parallel() - - require.NoError(t, ValidateBaseURL("http://localhost:8080", "--base-url")) - }) - - t.Run("Empty", func(t *testing.T) { - t.Parallel() - - require.NoError(t, ValidateBaseURL("", "MY_BASE_URL")) - }) - - t.Run("MissingScheme", func(t *testing.T) { - t.Parallel() - - err := ValidateBaseURL("localhost:8080", "MY_BASE_URL") - require.Error(t, err) - assert.Contains(t, err.Error(), "MY_BASE_URL") - assert.Contains(t, err.Error(), "missing a scheme") - }) - - t.Run("HostOnly", func(t *testing.T) { - t.Parallel() - - err := ValidateBaseURL("api.example.com", "--base-url") - require.Error(t, err) - assert.Contains(t, err.Error(), "--base-url") - }) -} - -func TestFormatJSON(t *testing.T) { - t.Parallel() - - t.Run("RawWithTransform", func(t *testing.T) { - t.Parallel() - - res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "id"}) - require.NoError(t, err) - require.Equal(t, `"abc123"`+"\n", string(formatted)) - }) - - t.Run("RawWithoutTransform", func(t *testing.T) { - t.Parallel() - - res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout}) - require.NoError(t, err) - require.Equal(t, `{"id":"abc123","name":"test"}`+"\n", string(formatted)) - }) - - t.Run("RawWithNestedTransform", func(t *testing.T) { - t.Parallel() - - res := gjson.Parse(`{"data":{"items":[1,2,3]}}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "data.items"}) - require.NoError(t, err) - require.Equal(t, "[1,2,3]\n", string(formatted)) - }) - - t.Run("RawWithNonexistentTransform", func(t *testing.T) { - t.Parallel() - - res := gjson.Parse(`{"id":"abc123"}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "missing"}) - require.NoError(t, err) - // Transform path doesn't exist, so original result is returned - require.Equal(t, `{"id":"abc123"}`+"\n", string(formatted)) - }) - - t.Run("RawOutputString", func(t *testing.T) { - t.Parallel() - - res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "json", Stdout: os.Stdout, Transform: "id", RawOutput: true}) - require.NoError(t, err) - require.Equal(t, "abc123\n", string(formatted)) - }) - - t.Run("RawOutputNonString", func(t *testing.T) { - t.Parallel() - - // --raw-output has no effect on non-string values - res := gjson.Parse(`{"count":42}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "count", RawOutput: true}) - require.NoError(t, err) - require.Equal(t, "42\n", string(formatted)) - }) - - t.Run("RawOutputObject", func(t *testing.T) { - t.Parallel() - - // --raw-output has no effect on objects - res := gjson.Parse(`{"nested":{"a":1}}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "nested", RawOutput: true}) - require.NoError(t, err) - require.Equal(t, `{"a":1}`+"\n", string(formatted)) - }) -} - -func TestShowJSONIterator(t *testing.T) { - t.Parallel() - - t.Run("RawMultipleItems", func(t *testing.T) { - t.Parallel() - - iter := &sliceIterator[map[string]any]{items: []map[string]any{ - {"id": "abc", "name": "first"}, - {"id": "def", "name": "second"}, - }} - captured := captureShowJSONIterator(t, iter, "raw", "", -1) - assert.Equal(t, `{"id":"abc","name":"first"}`+"\n"+`{"id":"def","name":"second"}`+"\n", captured) - }) - - t.Run("RawWithTransform", func(t *testing.T) { - t.Parallel() - - iter := &sliceIterator[map[string]any]{items: []map[string]any{ - {"id": "abc", "name": "first"}, - {"id": "def", "name": "second"}, - }} - captured := captureShowJSONIterator(t, iter, "raw", "id", -1) - assert.Equal(t, `"abc"`+"\n"+`"def"`+"\n", captured) - }) - - t.Run("LimitItems", func(t *testing.T) { - t.Parallel() - - iter := &sliceIterator[map[string]any]{items: []map[string]any{ - {"id": "abc"}, - {"id": "def"}, - {"id": "ghi"}, - }} - captured := captureShowJSONIterator(t, iter, "raw", "", 2) - assert.Equal(t, `{"id":"abc"}`+"\n"+`{"id":"def"}`+"\n", captured) - }) -} - -func TestExploreFallback(t *testing.T) { - t.Parallel() - - t.Run("ShowJSONFallsBackToJsonOnNonTTY", func(t *testing.T) { - t.Parallel() - - // os.Pipe() produces a *os.File that isn't a terminal, so explore should fall back. - r, w, err := os.Pipe() - require.NoError(t, err) - defer r.Close() - - var stderr bytes.Buffer - res := gjson.Parse(`{"id":"abc"}`) - err = ShowJSON(res, ShowJSONOpts{ - Format: "explore", - Stderr: &stderr, - Stdout: w, - Title: "test", - }) - w.Close() - require.NoError(t, err) - - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - assert.Contains(t, buf.String(), `"id"`) - assert.Contains(t, buf.String(), `"abc"`) - }) - - t.Run("ShowJSONIteratorFallsBackToJsonOnNonTTY", func(t *testing.T) { - t.Parallel() - - iter := &sliceIterator[map[string]any]{items: []map[string]any{ - {"id": "abc"}, - }} - captured := captureShowJSONIterator(t, iter, "explore", "", -1) - assert.Contains(t, captured, `"id"`) - assert.Contains(t, captured, `"abc"`) - }) - - t.Run("ShowJSONWarnsWhenExplicitFormatOnNonTTY", func(t *testing.T) { - t.Parallel() - - r, w, err := os.Pipe() - require.NoError(t, err) - defer r.Close() - - var stderr bytes.Buffer - res := gjson.Parse(`{"id":"abc"}`) - err = ShowJSON(res, ShowJSONOpts{ - ExplicitFormat: true, - Format: "explore", - Stderr: &stderr, - Stdout: w, - Title: "test", - }) - w.Close() - require.NoError(t, err) - - assert.Equal(t, warningExploreNotSupported, stderr.String()) - }) - - t.Run("ShowJSONSilentWhenDefaultFormatOnNonTTY", func(t *testing.T) { - t.Parallel() - - r, w, err := os.Pipe() - require.NoError(t, err) - defer r.Close() - - var stderr bytes.Buffer - res := gjson.Parse(`{"id":"abc"}`) - err = ShowJSON(res, ShowJSONOpts{ - Format: "explore", - Stderr: &stderr, - Stdout: w, - Title: "test", - }) - w.Close() - require.NoError(t, err) - - assert.Empty(t, stderr.String(), "no warning expected when format was not explicit") - }) -} - -// sliceIterator is a simple iterator over a slice for testing. -type sliceIterator[T any] struct { - index int - items []T -} - -func (it *sliceIterator[T]) Next() bool { - it.index++ - return it.index <= len(it.items) -} - -func (it *sliceIterator[T]) Current() T { - return it.items[it.index-1] -} - -func (it *sliceIterator[T]) Err() error { - return nil -} - -var _ jsonview.Iterator[any] = (*sliceIterator[any])(nil) - -// captureShowJSONIterator runs ShowJSONIterator and captures the output written to a file. -func captureShowJSONIterator[T any](t *testing.T, iter jsonview.Iterator[T], format, transform string, itemsToDisplay int64) string { - t.Helper() - - r, w, err := os.Pipe() - require.NoError(t, err) - defer r.Close() - - err = ShowJSONIterator(iter, itemsToDisplay, ShowJSONOpts{ - Format: format, - Stderr: io.Discard, - Stdout: w, - Title: "test", - Transform: transform, - }) - w.Close() - require.NoError(t, err) - - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - return buf.String() -} diff --git a/pkg/cmd/cmdutil_unix.go b/pkg/cmd/cmdutil_unix.go deleted file mode 100644 index edefcd7..0000000 --- a/pkg/cmd/cmdutil_unix.go +++ /dev/null @@ -1,127 +0,0 @@ -//go:build !windows - -package cmd - -import ( - "fmt" - "os" - "os/exec" - "strings" - "syscall" - - "golang.org/x/sys/unix" -) - -func isPipedDataAvailableOSSpecific() bool { - // Try to determine if there's non-empty data being piped into the command by polling for data for a short - // amount of time. This is necessary because some environments (e.g. Cursor's integrated terminal) connect - // stdin as a pipe even when nothing is being piped, which would cause the command to block indefinitely - // waiting for input that will never come. The 10 ms timeout is arbitrary -- designed to be long enough to - // allow data to be detected, but short enough that it shouldn't cause a noticeable delay in command runs. - fds := []unix.PollFd{{Fd: int32(os.Stdin.Fd()), Events: unix.POLLIN}} - n, _ := unix.Poll(fds, 10 /* ms */) - return n > 0 -} - -func streamOutputOSSpecific(label string, generateOutput func(w *os.File) error) error { - // Try to use socket pair for better buffer control - pagerInput, pid, err := openSocketPairPager(label) - if err != nil || pagerInput == nil { - // Fall back to pipe if socket setup fails - return streamToPagerWithPipe(label, generateOutput) - } - defer pagerInput.Close() - - // If we would be streaming to a terminal and aren't forcing color one way - // or the other, we should configure things to use color so the pager gets - // colorized input. - if isTerminal(os.Stdout) && os.Getenv("FORCE_COLOR") == "" { - os.Setenv("FORCE_COLOR", "1") - } - - // If the pager exits before reading all input, then generateOutput() will - // produce a broken pipe error, which is fine and we don't want to propagate it. - if err := generateOutput(pagerInput); err != nil && - !strings.Contains(err.Error(), "broken pipe") { - return err - } - - // Close the file NOW before we wait for the child process to terminate. - // This way, the child will receive the end-of-file signal and know that - // there is no more input. Otherwise the child process may block - // indefinitely waiting for another line (this can happen when streaming - // less than a screenful of data to a pager). - pagerInput.Close() - - // Wait for child process to exit - var wstatus syscall.WaitStatus - _, err = syscall.Wait4(pid, &wstatus, 0, nil) - if wstatus.ExitStatus() != 0 { - return fmt.Errorf("Pager exited with non-zero exit status: %d", wstatus.ExitStatus()) - } - return err -} - -func openSocketPairPager(label string) (*os.File, int, error) { - fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) - if err != nil { - return nil, 0, err - } - - // The child file descriptor will be sent to the child process through - // ProcAttr and ForkExec(), while the parent process will always close the - // child file descriptor. - // The parent file descriptor will be wrapped in an os.File wrapper and - // returned from this function, or closed if something goes wrong. - parentFd, childFd := fds[0], fds[1] - defer unix.Close(childFd) - - // Use small buffer sizes so we don't ask the server for more paginated - // values than we actually need. - if err := unix.SetsockoptInt(parentFd, unix.SOL_SOCKET, unix.SO_SNDBUF, 128); err != nil { - unix.Close(parentFd) - return nil, 0, err - } - if err := unix.SetsockoptInt(childFd, unix.SOL_SOCKET, unix.SO_RCVBUF, 128); err != nil { - unix.Close(parentFd) - return nil, 0, err - } - - // Set CLOEXEC on the parent file descriptor so it doesn't leak to child - syscall.CloseOnExec(parentFd) - - parentConn := os.NewFile(uintptr(parentFd), "parent-socket") - - pagerProgram := os.Getenv("PAGER") - if pagerProgram == "" { - pagerProgram = "less" - } - - pagerPath, err := exec.LookPath(pagerProgram) - if err != nil { - unix.Close(parentFd) - return nil, 0, err - } - - env := os.Environ() - env = append(env, "LESS=-r -P "+label) - env = append(env, "MORE=-r -P "+label) - - procAttr := &syscall.ProcAttr{ - Dir: "", - Env: env, - Files: []uintptr{ - uintptr(childFd), // stdin (fd 0) - uintptr(syscall.Stdout), // stdout (fd 1) - uintptr(syscall.Stderr), // stderr (fd 2) - }, - } - - pid, err := syscall.ForkExec(pagerPath, []string{pagerProgram}, procAttr) - if err != nil { - unix.Close(parentFd) - return nil, 0, err - } - - return parentConn, pid, nil -} diff --git a/pkg/cmd/cmdutil_windows.go b/pkg/cmd/cmdutil_windows.go deleted file mode 100644 index 49b025e..0000000 --- a/pkg/cmd/cmdutil_windows.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build windows - -package cmd - -import ( - "os" - "syscall" - "unsafe" -) - -var ( - kernel32 = syscall.NewLazyDLL("kernel32.dll") - procPeekNamedPipe = kernel32.NewProc("PeekNamedPipe") -) - -func isPipedDataAvailableOSSpecific() bool { - // On Windows, unix.Poll is not available. Use PeekNamedPipe to check if data is available - // on the pipe without consuming it. - var available uint32 - r, _, _ := procPeekNamedPipe.Call( - os.Stdin.Fd(), - 0, - 0, - 0, - uintptr(unsafe.Pointer(&available)), - 0, - ) - return r != 0 && available > 0 -} - -func streamOutputOSSpecific(label string, generateOutput func(w *os.File) error) error { - // We have a trick with sockets that we use when possible on Unix-like systems. Those APIs aren't - // available on Windows, so we fall back to using pipes. - return streamToPagerWithPipe(label, generateOutput) -} diff --git a/pkg/cmd/execution_argv.go b/pkg/cmd/execution_argv.go deleted file mode 100644 index 08394e1..0000000 --- a/pkg/cmd/execution_argv.go +++ /dev/null @@ -1,35 +0,0 @@ -// @custom start -// Adds literal argument forwarding to execution commands. - -package cmd - -import ( - "context" - "fmt" - "strings" - - "github.com/urfave/cli/v3" -) - -// configureExecutionArguments adds the exec alias and implicit create command. -// Existing execution subcommands remain available under both resource names. -// Flag parsing belongs to create so names after -- stay literal arguments. -// Call this after the execution resource is nested under machines. -func configureExecutionArguments(executions *cli.Command) { - executions.Aliases = append(executions.Aliases, "exec") - executions.Usage = "Run commands on a machine or manage executions" - executions.UsageText = "dedalus machines exec [create] --machine-id [flags] -- [args...]\n dedalus machines exec [flags]" - executions.SkipFlagParsing = true - executions.Action = func(ctx context.Context, command *cli.Command) error { - args := command.Args().Slice() - if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { - return cli.ShowSubcommandHelp(command) - } - if !strings.HasPrefix(args[0], "-") { - return fmt.Errorf("unknown execution subcommand %q; use -- before a remote command", args[0]) - } - return command.Command("create").Run(ctx, append([]string{"create"}, args...)) - } -} - -// @custom end diff --git a/pkg/cmd/execution_argv_test.go b/pkg/cmd/execution_argv_test.go deleted file mode 100644 index 1b83340..0000000 --- a/pkg/cmd/execution_argv_test.go +++ /dev/null @@ -1,175 +0,0 @@ -// @custom start -// Regression coverage for handwritten execution argument forwarding. - -package cmd - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "reflect" - "runtime" - "strconv" - "strings" - "sync/atomic" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestInvariantExecutionArgumentsReachAPIUnchanged checks the built CLI against -// a local HTTP server. It covers literal arguments, existing input formats and -// subcommands, help output, and rejection before any request is sent. -func TestInvariantExecutionArgumentsReachAPIUnchanged(t *testing.T) { - // A real binary exercises command routing and request encoding together. - binary := filepath.Join(t.TempDir(), "dedalus") - if runtime.GOOS == "windows" { - binary += ".exe" - } - output, err := exec.Command("go", "build", "-o", binary, "../../cmd/dedalus").CombinedOutput() - require.NoError(t, err, "%s", output) - - // An existing file makes accidental @file expansion observable in the body. - localFile := filepath.Join(t.TempDir(), "literal.txt") - require.NoError(t, os.WriteFile(localFile, []byte("must not be uploaded"), 0600)) - // These values must survive without shell interpretation or flag parsing. - argv := []string{"echo", "hello world", "", "--help", "--", "a\"b", "$HOME", "&&", "@" + localFile, `\@literal`} - - // Both resource names support explicit create and the shorthand form. - for _, resource := range []string{"executions", "exec"} { - for _, create := range []bool{false, true} { - t.Run(resource+"/create="+strconv.FormatBool(create), func(t *testing.T) { - // Buffer the body so the handler can reply before the CLI exits. - requests := make(chan map[string]any, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - assert.Equal(t, "/v1/machines/dm-test/executions", r.URL.Path) - var body map[string]any - assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - requests <- body - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"execution_id":"ex-test","status":"queued"}`)) - })) - defer server.Close() - args := []string{"--base-url", server.URL, "--api-key", "test", "machines", resource} - if create { - args = append(args, "create") - } - args = append(args, "--machine-id", "dm-test", "--cwd", "/tmp", "--env", `{"MODE":"test"}`, "--stdin", "input", "--timeout-ms", "1000", "--") - args = append(args, argv...) - output, err := exec.Command(binary, args...).CombinedOutput() - require.NoError(t, err, "%s", output) - select { - case body := <-requests: - // Decode the expected slice too: JSON arrays in map[string]any - // have type []any, so []string would fail a type-sensitive comparison. - encoded, err := json.Marshal(argv) - require.NoError(t, err) - var want any - require.NoError(t, json.Unmarshal(encoded, &want)) - require.Equal(t, want, body["command"]) - require.Equal(t, "/tmp", body["cwd"]) - require.Equal(t, map[string]any{"MODE": "test"}, body["env"]) - require.Equal(t, "input", body["stdin"]) - require.Equal(t, float64(1000), body["timeout_ms"]) - default: - t.Fatal("no execution request received") - } - }) - } - } - - // The alias preserves JSON and piped input plus every generated subcommand. - // A command named list after -- must still create a remote execution. - for _, test := range []struct { - name string - args []string - stdin string - command []string - method string - suffix string - }{ - {name: "subcommand name is literal", args: []string{"exec", "--machine-id", "dm-test", "--", "list"}, command: []string{"list"}}, - {name: "explicit remote shell", args: []string{"exec", "--machine-id", "dm-test", "--", "sh", "-c", `echo "hello world" && ls -la`}, command: []string{"sh", "-c", `echo "hello world" && ls -la`}}, - {name: "json command", args: []string{"exec", "create", "--machine-id", "dm-test", "--command", `["echo","hello world"]`}, command: []string{"echo", "hello world"}}, - {name: "piped command", args: []string{"exec", "create"}, stdin: `{"machine_id":"dm-test","command":["echo","hello world"]}`, command: []string{"echo", "hello world"}}, - {name: "retrieve alias", args: []string{"exec", "retrieve", "--machine-id", "dm-test", "--execution-id", "ex-test"}, method: "GET", suffix: "/ex-test"}, - {name: "output alias", args: []string{"exec", "output", "--machine-id", "dm-test", "--execution-id", "ex-test"}, method: "GET", suffix: "/ex-test/output"}, - {name: "events alias", args: []string{"exec", "events", "--machine-id", "dm-test", "--execution-id", "ex-test"}, method: "GET", suffix: "/ex-test/events"}, - {name: "list alias", args: []string{"exec", "list", "--machine-id", "dm-test"}, method: "GET"}, - {name: "delete alias", args: []string{"exec", "delete", "--machine-id", "dm-test", "--execution-id", "ex-test"}, method: "DELETE", suffix: "/ex-test"}, - } { - t.Run(test.name, func(t *testing.T) { - requests := make(chan *http.Request, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if test.command != nil { - var body struct { - Command []string `json:"command"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Error(err) - } - if !reflect.DeepEqual(test.command, body.Command) { - t.Errorf("command = %q, want %q", body.Command, test.command) - } - } - requests <- r - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"execution_id":"ex-test","status":"queued","items":[]}`)) - })) - defer server.Close() - args := append([]string{"--base-url", server.URL, "--api-key", "test", "machines"}, test.args...) - run := exec.Command(binary, args...) - run.Stdin = strings.NewReader(test.stdin) - output, err := run.CombinedOutput() - require.NoError(t, err, "%s", output) - select { - case request := <-requests: - method := test.method - if method == "" { - method = "POST" - } - require.Equal(t, method, request.Method) - require.Equal(t, "/v1/machines/dm-test/executions"+test.suffix, request.URL.Path) - default: - t.Fatal("no execution request received") - } - }) - } - - // Help must remain reachable at both the resource and create levels. - for _, args := range [][]string{{"exec", "--help"}, {"exec", "create", "--help"}} { - output, err := exec.Command(binary, append([]string{"machines"}, args...)...).CombinedOutput() - require.NoError(t, err, "%s", output) - require.Contains(t, string(output), "machine-id") - } - - // Invalid input must fail locally. A nonzero exit alone would also allow - // a server-side rejection, so the atomic counter checks for zero requests. - for _, args := range [][]string{ - {"exec", "--machine-id", "dm-test", "--"}, - {"exec", "--machine-id", "dm-test", "--", ""}, - {"exec", "--machine-id", "dm-test", "--command", `["echo"]`, "--", "pwd"}, - {"exec", "--", "echo"}, - {"exec", "typo"}, - } { - t.Run("reject/"+strings.Join(args, " "), func(t *testing.T) { - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests.Add(1) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"execution_id":"unexpected"}`)) - })) - defer server.Close() - output, err := exec.Command(binary, append([]string{"--base-url", server.URL, "--api-key", "test", "machines"}, args...)...).CombinedOutput() - require.Error(t, err, "%s", output) - require.Zero(t, requests.Load(), "invalid input must not send an API request") - }) - } -} - -// @custom end diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go deleted file mode 100644 index 73fd179..0000000 --- a/pkg/cmd/flagoptions.go +++ /dev/null @@ -1,692 +0,0 @@ -package cmd - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "maps" - "mime" - "mime/multipart" - "net/http" - "os" - "path/filepath" - "reflect" - "strings" - "unicode/utf8" - - "github.com/dedalus-labs/dedalus-cli/internal/apiform" - "github.com/dedalus-labs/dedalus-cli/internal/apiquery" - "github.com/dedalus-labs/dedalus-cli/internal/debugmiddleware" - "github.com/dedalus-labs/dedalus-cli/internal/requestflag" - "github.com/dedalus-labs/dedalus-go/option" - - "github.com/goccy/go-yaml" - "github.com/urfave/cli/v3" -) - -type BodyContentType int - -const ( - EmptyBody BodyContentType = iota - MultipartFormEncoded - ApplicationJSON - ApplicationOctetStream -) - -type FileEmbedStyle int - -const ( - // EmbedText reads referenced files fully into memory and substitutes the file's contents back into the - // value as a string. Binary files are base64-encoded. Used for JSON request bodies and for headers and - // query parameters, where the file contents need to be serialized inline. - EmbedText FileEmbedStyle = iota - - // EmbedIOReader replaces file references with an io.Reader that streams the file's contents. Used for - // `multipart/form-data` and `application/octet-stream` request bodies, where files are uploaded as binary - // parts rather than embedded into a text value. - EmbedIOReader -) - -// onceStdinReader wraps an io.Reader that can only be consumed once, used to ensure stdin is read by at most -// one parameter (or only for a body root parameter or only for YAML parameter input). If reason is set, stdin -// is unavailable and read() returns an error explaining why. -type onceStdinReader struct { - stdinReader io.Reader - failureReason string -} - -func (o *onceStdinReader) read() (io.Reader, error) { - if o.failureReason != "" { - return nil, fmt.Errorf("cannot read from stdin: %s", o.failureReason) - } - if o.stdinReader == nil { - return nil, fmt.Errorf("stdin has already been read by another parameter; it can only be read once") - } - r := o.stdinReader - o.stdinReader = nil - return r, nil -} - -func (o *onceStdinReader) readAll() ([]byte, error) { - r, err := o.read() - if err != nil { - return nil, err - } - return io.ReadAll(r) -} - -func isStdinPath(s string) bool { - switch s { - case "-", "/dev/fd/0", "/dev/stdin": - return true - } - return false -} - -func embedFiles(obj any, embedStyle FileEmbedStyle, stdin *onceStdinReader) (any, error) { - if obj == nil { - return obj, nil - } - v := reflect.ValueOf(obj) - result, err := embedFilesValue(v, embedStyle, stdin) - if err != nil { - return nil, err - } - return result.Interface(), nil -} - -// Replace "@file.txt" with the file's contents inside a value -func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle, stdin *onceStdinReader) (reflect.Value, error) { - // Unwrap interface values to get the concrete type - if v.Kind() == reflect.Interface { - if v.IsNil() { - return v, nil - } - v = v.Elem() - } - - switch v.Kind() { - case reflect.Map: - if v.Len() == 0 { - return v, nil - } - // Always create map[string]any to handle potential type changes when embedding files - result := reflect.MakeMap(reflect.TypeOf(map[string]any{})) - - iter := v.MapRange() - for iter.Next() { - key := iter.Key() - val := iter.Value() - newVal, err := embedFilesValue(val, embedStyle, stdin) - if err != nil { - return reflect.Value{}, err - } - result.SetMapIndex(key, newVal) - } - return result, nil - - case reflect.Slice, reflect.Array: - if v.Len() == 0 { - return v, nil - } - // Use `[]any` to allow for types to change when embedding files - result := reflect.MakeSlice(reflect.TypeOf([]any{}), v.Len(), v.Len()) - for i := 0; i < v.Len(); i++ { - newVal, err := embedFilesValue(v.Index(i), embedStyle, stdin) - if err != nil { - return reflect.Value{}, err - } - result.Index(i).Set(newVal) - } - return result, nil - - case reflect.String: - // FilePathValue is always treated as a file path without needing the "@" prefix. - // These only appear on binary upload parameters (multipart/octet-stream), which - // always use EmbedIOReader. - if v.Type() == reflect.TypeOf(FilePathValue("")) { - s := v.String() - if s == "" { - return v, nil - } - if embedStyle == EmbedIOReader { - if isStdinPath(s) { - r, err := stdin.read() - if err != nil { - return v, err - } - return reflect.ValueOf(io.NopCloser(r)), nil - } - upload, err := openFileUpload(s) - if err != nil { - return v, err - } - return reflect.ValueOf(upload), nil - } - if isStdinPath(s) { - content, err := stdin.readAll() - if err != nil { - return v, err - } - return reflect.ValueOf(string(content)), nil - } - content, err := os.ReadFile(s) - if err != nil { - return v, err - } - return reflect.ValueOf(string(content)), nil - } - - s := v.String() - if literal, ok := strings.CutPrefix(s, "\\@"); ok { - // Allow for escaped @ signs if you don't want them to be treated as files - return reflect.ValueOf("@" + literal), nil - } - - if embedStyle == EmbedText { - if filename, ok := strings.CutPrefix(s, "@data://"); ok { - // The "@data://" prefix is for files you explicitly want to upload - // as base64-encoded (even if the file itself is plain text) - if isStdinPath(filename) { - content, err := stdin.readAll() - if err != nil { - return v, err - } - return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil - } - content, err := os.ReadFile(filename) - if err != nil { - return v, err - } - return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil - } else if filename, ok := strings.CutPrefix(s, "@file://"); ok { - // The "@file://" prefix is for files that you explicitly want to - // upload as a string literal with backslash escapes (not base64 - // encoded) - if isStdinPath(filename) { - content, err := stdin.readAll() - if err != nil { - return v, err - } - return reflect.ValueOf(string(content)), nil - } - content, err := os.ReadFile(filename) - if err != nil { - return v, err - } - return reflect.ValueOf(string(content)), nil - } else if filename, ok := strings.CutPrefix(s, "@"); ok { - if isStdinPath(filename) { - content, err := stdin.readAll() - if err != nil { - return v, err - } - if isUTF8TextFile(content) { - return reflect.ValueOf(string(content)), nil - } - return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil - } - content, err := os.ReadFile(filename) - if err != nil { - // If the string is "@username", it's probably supposed to be a - // string literal and not a file reference. However, if the - // string looks like "@file.txt" or "@/tmp/file", then it's - // probably supposed to be a file. - probablyFile := strings.Contains(filename, ".") || strings.Contains(filename, "/") - if probablyFile { - // Give a useful error message if the user tried to upload a - // file, but the file couldn't be read (e.g. mistyped - // filename or permission error) - return v, err - } - // Fall back to the raw value if the user provided something - // like "@username" that's not intended to be a file. - return v, nil - } - // If the file looks like a plain text UTF8 file format, then use the contents directly. - if isUTF8TextFile(content) { - return reflect.ValueOf(string(content)), nil - } - // Otherwise it's a binary file, so encode it with base64 - return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil - } - } else { - if filename, ok := strings.CutPrefix(s, "@"); ok { - // Behavior is the same for @file, @data://file, and @file://file, except that - // @username will be treated as a literal string if no "username" file exists - expectsFile := true - if withoutPrefix, ok := strings.CutPrefix(filename, "data://"); ok { - filename = withoutPrefix - } else if withoutPrefix, ok := strings.CutPrefix(filename, "file://"); ok { - filename = withoutPrefix - } else { - expectsFile = strings.Contains(filename, ".") || strings.Contains(filename, "/") - } - - if isStdinPath(filename) { - r, err := stdin.read() - if err != nil { - return v, err - } - return reflect.ValueOf(io.NopCloser(r)), nil - } - - upload, err := openFileUpload(filename) - if err != nil { - if !expectsFile { - // For strings that start with "@" and don't look like a filename, return the string - return v, nil - } - return v, err - } - return reflect.ValueOf(upload), nil - } - } - return v, nil - - default: - return v, nil - } -} - -// Guess whether a file's contents are binary (e.g. a .jpg or .mp3), as opposed -// to plain text (e.g. .txt or .md). -func isUTF8TextFile(content []byte) bool { - // Go's DetectContentType follows https://mimesniff.spec.whatwg.org/ and - // these are the sniffable content types that are plain text: - textTypes := []string{ - "text/", - "application/json", - "application/xml", - "application/javascript", - "application/x-javascript", - "application/ecmascript", - "application/x-ecmascript", - } - - contentType := http.DetectContentType(content) - for _, prefix := range textTypes { - if strings.HasPrefix(contentType, prefix) { - return utf8.Valid(content) - } - } - return false -} - -func flagOptions( - cmd *cli.Command, - nestedFormat apiquery.NestedQueryFormat, - arrayFormat apiquery.ArrayQueryFormat, - bodyType BodyContentType, - - // This parameter is true if stdin is already in use to pass a binary parameter by using the special value - // "-". In this case, we won't attempt to read it as a JSON/YAML blob for options setting. - ignoreStdin bool, -) ([]option.RequestOption, error) { - var options []option.RequestOption - if cmd.Bool("debug") { - options = append(options, option.WithMiddleware(debugmiddleware.NewRequestLogger().Middleware())) - } - - requestContents := requestflag.ExtractRequestContents(cmd) - - // Translate inner-field aliases in YAML values that came from flags (e.g. - // `--parent '{"alias": val}'` resolving to the canonical inner field). - if bodyMap, ok := requestContents.Body.(map[string]any); ok { - applyDataAliases(cmd, bodyMap) - } - - stdinConsumedByPipe := false - if bodyType != ApplicationOctetStream && !ignoreStdin && isInputPiped() { - pipeData, err := io.ReadAll(os.Stdin) - if err != nil { - return nil, err - } - - if len(pipeData) > 0 { - stdinConsumedByPipe = true - var bodyData any - if err := yaml.Unmarshal(pipeData, &bodyData); err != nil { - return nil, fmt.Errorf("Failed to parse piped data as YAML/JSON:\n%w", err) - } - if bodyMap, ok := bodyData.(map[string]any); ok { - applyDataAliases(cmd, bodyMap) - // Apply any matching keys from the piped data to path, query, and header flags - // that have not already been set via the command line. - if err := requestflag.ApplyStdinDataToFlags(cmd, bodyMap); err != nil { - return nil, err - } - // Re-extract request contents now that flags may have been updated. - requestContents = requestflag.ExtractRequestContents(cmd) - // Remove keys that were consumed as query, header, or path params so they - // don't also leak into the request body via the maps.Copy merge below. - // We delete both the canonical key and any aliases since the user may have - // piped data using an alias name rather than the canonical API name. - for _, flag := range cmd.Flags { - inReq, ok := flag.(requestflag.InRequest) - if !ok || !flag.IsSet() { - continue - } - if inReq.GetQueryPath() != "" || inReq.GetHeaderPath() != "" || inReq.GetPathParam() != "" { - delete(bodyMap, inReq.GetQueryPath()) - delete(bodyMap, inReq.GetHeaderPath()) - delete(bodyMap, inReq.GetPathParam()) - for _, alias := range inReq.GetDataAliases() { - delete(bodyMap, alias) - } - } - } - if bodyType != EmptyBody { - if flagMap, ok := requestContents.Body.(map[string]any); ok { - maps.Copy(bodyMap, flagMap) - requestContents.Body = bodyMap - } else { - bodyData = requestContents.Body - } - } - } else if bodyType != EmptyBody { - if flagMap, ok := requestContents.Body.(map[string]any); ok && len(flagMap) > 0 { - return nil, fmt.Errorf("Cannot merge flags with a body that is not a map: %v", bodyData) - } else { - requestContents.Body = bodyData - } - } - } - } - - if missingFlags := requestflag.GetMissingRequiredFlags(cmd, requestContents.Body); len(missingFlags) > 0 { - if len(missingFlags) == 1 { - return nil, fmt.Errorf("Required flag %q not set\nRun '%s --help' for usage information", missingFlags[0].Names()[0], cmd.FullName()) - } else { - names := []string{} - for _, flag := range missingFlags { - names = append(names, flag.Names()[0]) - } - return nil, fmt.Errorf("Required flags %q not set\nRun '%s --help' for usage information", strings.Join(names, ", "), cmd.FullName()) - } - } - - // For flags marked as FileInput (type: string, format: binary), the value is always - // a file path. Wrap with FilePathValue so embedFiles reads the file automatically - // without requiring the user to type the "@" prefix. This handles both values set - // via explicit CLI flags and values that arrived via piped YAML/JSON data. - wrapFileInputValues(cmd, &requestContents) - - // Determine stdin availability for FileInput params that use "-". - var stdinReader onceStdinReader - if ignoreStdin { - stdinReader = onceStdinReader{failureReason: "stdin is already being used for the request body"} - } else if stdinConsumedByPipe { - stdinReader = onceStdinReader{failureReason: "stdin was already consumed by piped YAML/JSON input"} - } else { - stdinReader = onceStdinReader{stdinReader: os.Stdin} - } - - // Embed files passed as "@file.jpg" in the request body, headers, and query: - embedStyle := EmbedText - if bodyType == ApplicationOctetStream || bodyType == MultipartFormEncoded { - embedStyle = EmbedIOReader - } - - if embedded, err := embedFiles(requestContents.Body, embedStyle, &stdinReader); err != nil { - return nil, err - } else { - requestContents.Body = embedded - } - - if headersWithFiles, err := embedFiles(requestContents.Headers, EmbedText, &stdinReader); err != nil { - return nil, err - } else { - requestContents.Headers = headersWithFiles.(map[string]any) - } - if queriesWithFiles, err := embedFiles(requestContents.Queries, EmbedText, &stdinReader); err != nil { - return nil, err - } else { - requestContents.Queries = queriesWithFiles.(map[string]any) - } - - querySettings := apiquery.QuerySettings{ - NestedFormat: nestedFormat, - ArrayFormat: arrayFormat, - } - - // Add query parameters: - if values, err := apiquery.MarshalWithSettings(requestContents.Queries, querySettings); err != nil { - return nil, err - } else { - for k, vs := range values { - if len(vs) == 0 { - options = append(options, option.WithQueryDel(k)) - } else { - options = append(options, option.WithQuery(k, vs[0])) - for _, v := range vs[1:] { - options = append(options, option.WithQueryAdd(k, v)) - } - } - } - } - - // Add header parameters - headerSettings := apiquery.QuerySettings{ - NestedFormat: apiquery.NestedQueryFormatDots, - ArrayFormat: apiquery.ArrayQueryFormatRepeat, - } - if values, err := apiquery.MarshalWithSettings(requestContents.Headers, headerSettings); err != nil { - return nil, err - } else { - for k, vs := range values { - if len(vs) == 0 { - options = append(options, option.WithHeaderDel(k)) - } else { - options = append(options, option.WithHeader(k, vs[0])) - for _, v := range vs[1:] { - options = append(options, option.WithHeaderAdd(k, v)) - } - } - } - } - - switch bodyType { - case EmptyBody: - break - case MultipartFormEncoded: - buf := new(bytes.Buffer) - writer := multipart.NewWriter(buf) - - // For multipart/form-encoded, we need a map structure - bodyMap, ok := requestContents.Body.(map[string]any) - if !ok { - return nil, fmt.Errorf("Cannot send a non-map value to a form-encoded endpoint: %v\n", requestContents.Body) - } - encodingFormat := apiform.FormatRepeat - if err := apiform.MarshalWithSettings(bodyMap, writer, encodingFormat); err != nil { - return nil, err - } - if err := writer.Close(); err != nil { - return nil, err - } - options = append(options, option.WithRequestBody(writer.FormDataContentType(), buf)) - - case ApplicationJSON: - bodyBytes, err := json.Marshal(requestContents.Body) - if err != nil { - return nil, err - } - options = append(options, option.WithRequestBody("application/json", bodyBytes)) - - case ApplicationOctetStream: - // If there is a body root parameter, that will handle setting the request body, we don't need to do it here. - for _, flag := range cmd.Flags { - if toSend, ok := flag.(requestflag.InRequest); ok && toSend.IsBodyRoot() { - return options, nil - } - } - if bodyBytes, ok := requestContents.Body.([]byte); ok { - options = append(options, option.WithRequestBody("application/octet-stream", bodyBytes)) - } else if bodyStr, ok := requestContents.Body.(string); ok { - options = append(options, option.WithRequestBody("application/octet-stream", []byte(bodyStr))) - } else { - return nil, fmt.Errorf("Unsupported body for application/octet-stream: %v", requestContents.Body) - } - - default: - panic("Invalid body content type!") - } - - return options, nil -} - -// FilePathValue is a string wrapper that marks a value as a file path whose contents should be read -// and embedded in the request. Unlike a regular string, embedFilesValue always treats a FilePathValue -// as a file path without needing the "@" prefix. -type FilePathValue string - -// fileUpload wraps an io.Reader with filename and content-type metadata for -// use as a multipart form part. The apiform encoder detects the Filename and -// ContentType methods and uses them to populate the Content-Disposition -// filename and the Content-Type header on the part. -type fileUpload struct { - io.Reader // apiform checks for reader and reads its contents during encode - filename string - contentType string -} - -func (f fileUpload) Filename() string { return f.filename } -func (f fileUpload) ContentType() string { return f.contentType } -func (f fileUpload) Close() error { - if c, ok := f.Reader.(io.Closer); ok { - return c.Close() - } - return nil -} - -// openFileUpload opens the file at path and returns a fileUpload whose filename -// is the path's basename and whose content type is derived from the file -// extension (falling back to application/octet-stream when unknown). -func openFileUpload(path string) (fileUpload, error) { - file, err := os.Open(path) - if err != nil { - return fileUpload{}, err - } - contentType := mime.TypeByExtension(filepath.Ext(path)) - if contentType == "" { - contentType = "application/octet-stream" - } - return fileUpload{ - Reader: file, - filename: filepath.Base(path), - contentType: contentType, - }, nil -} - -// applyDataAliases rewrites keys in a body map based on flag `DataAliases` metadata. For top-level flags, -// `{alias: value}` becomes `{canonical: value}`. For inner flags (those registered under an outer flag -// via WithInnerFlags), the alias translation is also applied to the nested map under the outer flag's -// body path, so values like `--parent '{"alias": val}'` resolve to the canonical inner field name. -func applyDataAliases(cmd *cli.Command, bodyMap map[string]any) { - for _, flag := range cmd.Flags { - // Inner flags: rewrite aliases inside the nested map under the outer flag's body path. - if inner, ok := flag.(requestflag.HasOuterFlag); ok { - outer, outerOk := inner.GetOuterFlag().(requestflag.InRequest) - if !outerOk { - continue - } - if nested, ok := bodyMap[outer.GetBodyPath()].(map[string]any); ok && inner.GetInnerField() != "" { - rewriteAliases(nested, inner.GetInnerField(), inner.GetDataAliases()) - } - continue - } - // Top-level flags: rewrite aliases in the body map. - if inReq, ok := flag.(requestflag.InRequest); ok && inReq.GetBodyPath() != "" { - rewriteAliases(bodyMap, inReq.GetBodyPath(), inReq.GetDataAliases()) - } - } -} - -// rewriteAliases replaces each alias key in m with the canonical key, preserving the value. The -// "canonical" key is the name the API itself expects (the OpenAPI property/field name) — e.g. for -// a top-level flag, the parameter's BodyPath; for an inner flag, the inner field name. Aliases are -// the user-facing alternate names declared via x-stainless-cli-data-alias. -func rewriteAliases(m map[string]any, canonical string, aliases []string) { - for _, alias := range aliases { - if alias == "" || alias == canonical { - continue - } - if val, exists := m[alias]; exists { - m[canonical] = val - delete(m, alias) - } - } -} - -// wrapFileInputValues replaces string values for FileInput flags (type: string, format: binary) with -// FilePathValue sentinel values. embedFilesValue recognizes FilePathValue and reads the file contents -// directly, so the user doesn't need to type the "@" prefix. This handles both values set via explicit -// CLI flags and values that arrived via piped YAML/JSON data. -func wrapFileInputValues(cmd *cli.Command, contents *requestflag.RequestContents) { - bodyMap, _ := contents.Body.(map[string]any) - - for _, flag := range cmd.Flags { - inReq, ok := flag.(requestflag.InRequest) - if !ok || !inReq.IsFileInput() || inReq.IsBodyRoot() { - continue - } - - // Wrap values set via explicit CLI flags. - if flag.IsSet() { - if wrapped, changed := wrapFileInputValue(flag.Get()); changed { - if bodyPath := inReq.GetBodyPath(); bodyPath != "" { - if bodyMap != nil { - bodyMap[bodyPath] = wrapped - } - } else if queryPath := inReq.GetQueryPath(); queryPath != "" { - contents.Queries[queryPath] = wrapped - } else if headerPath := inReq.GetHeaderPath(); headerPath != "" { - contents.Headers[headerPath] = wrapped - } - } - } - - // Wrap values that arrived via piped YAML/JSON data in the body map. - if bodyPath := inReq.GetBodyPath(); bodyPath != "" && bodyMap != nil { - if value, exists := bodyMap[bodyPath]; exists { - if wrapped, changed := wrapFileInputValue(value); changed { - bodyMap[bodyPath] = wrapped - } - } - } - } -} - -func wrapFileInputValue(value any) (any, bool) { - switch v := value.(type) { - case string: - if v == "" { - return value, false - } - return FilePathValue(v), true - - case []string: - result := make([]any, len(v)) - for i, s := range v { - result[i] = FilePathValue(s) - } - return result, true - - case []any: - result := make([]any, len(v)) - for i, elem := range v { - if s, ok := elem.(string); ok { - result[i] = FilePathValue(s) - } else { - result[i] = elem - } - } - return result, true - - default: - return value, false - } -} diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go deleted file mode 100644 index 00734ca..0000000 --- a/pkg/cmd/flagoptions_test.go +++ /dev/null @@ -1,392 +0,0 @@ -package cmd - -import ( - "encoding/base64" - "io" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestIsUTF8TextFile(t *testing.T) { - t.Parallel() - - tests := []struct { - content []byte - expected bool - }{ - {[]byte("Hello, world!"), true}, - {[]byte(`{"key": "value"}`), true}, - {[]byte(``), true}, - {[]byte(`function test() {}`), true}, - {[]byte{0xFF, 0xD8, 0xFF, 0xE0}, false}, // JPEG header - {[]byte{0x00, 0x01, 0xFF, 0xFE}, false}, // binary - {[]byte("Hello \xFF\xFE"), false}, // invalid UTF-8 - {[]byte("Hello ☺️"), true}, // emoji - {[]byte{}, true}, // empty - } - - for _, tt := range tests { - require.Equal(t, tt.expected, isUTF8TextFile(tt.content)) - } -} - -func TestEmbedFiles(t *testing.T) { - t.Parallel() - - // Create temporary directory for test files - tmpDir := t.TempDir() - - // Create test files - configContent := "host=localhost\nport=8080" - templateContent := "Hello" - dataContent := `{"key": "value"}` - - writeTestFile(t, tmpDir, "config.txt", configContent) - writeTestFile(t, tmpDir, "template.html", templateContent) - writeTestFile(t, tmpDir, "data.json", dataContent) - jpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46} - writeTestFile(t, tmpDir, "image.jpg", string(jpegHeader)) - - tests := []struct { - name string - input any - want any - wantErr bool - }{ - { - name: "map[string]any with file references", - input: map[string]any{ - "config": "@" + filepath.Join(tmpDir, "config.txt"), - "template": "@file://" + filepath.Join(tmpDir, "template.html"), - "count": 42, - }, - want: map[string]any{ - "config": configContent, - "template": templateContent, - "count": 42, - }, - wantErr: false, - }, - { - name: "map[string]string with file references", - input: map[string]any{ - "config": "@" + filepath.Join(tmpDir, "config.txt"), - "name": "test", - }, - want: map[string]any{ - "config": configContent, - "name": "test", - }, - wantErr: false, - }, - { - name: "[]any with file references", - input: []any{ - "@" + filepath.Join(tmpDir, "config.txt"), - 42, - true, - "@file://" + filepath.Join(tmpDir, "data.json"), - }, - want: []any{ - configContent, - 42, - true, - dataContent, - }, - wantErr: false, - }, - { - name: "[]string with file references", - input: []any{ - "@" + filepath.Join(tmpDir, "config.txt"), - "normal string", - }, - want: []any{ - configContent, - "normal string", - }, - wantErr: false, - }, - { - name: "nested structures", - input: map[string]any{ - "outer": map[string]any{ - "inner": []any{ - "@" + filepath.Join(tmpDir, "config.txt"), - map[string]any{ - "data": "@" + filepath.Join(tmpDir, "data.json"), - }, - }, - }, - }, - want: map[string]any{ - "outer": map[string]any{ - "inner": []any{ - configContent, - map[string]any{ - "data": dataContent, - }, - }, - }, - }, - wantErr: false, - }, - { - name: "base64 encoding", - input: map[string]any{ - "encoded": "@data://" + filepath.Join(tmpDir, "config.txt"), - "image": "@" + filepath.Join(tmpDir, "image.jpg"), - }, - want: map[string]any{ - "encoded": base64.StdEncoding.EncodeToString([]byte(configContent)), - "image": base64.StdEncoding.EncodeToString(jpegHeader), - }, - wantErr: false, - }, - { - name: "non-existent file with @ prefix", - input: map[string]any{ - "missing": "@file.txt", - }, - want: nil, - wantErr: true, - }, - { - name: "non-file-like thing with @ prefix", - input: map[string]any{ - "username": "@user", - "favorite_symbol": "@", - }, - want: map[string]any{ - "username": "@user", - "favorite_symbol": "@", - }, - wantErr: false, - }, - { - name: "non-existent file with @file:// prefix (error)", - input: map[string]any{ - "missing": "@file:///nonexistent/file.txt", - }, - want: nil, - wantErr: true, - }, - { - name: "escaping", - input: map[string]any{ - "simple": "\\@file.txt", - "file": "\\@file://file.txt", - "data": "\\@data://file.txt", - "keep_escape": "user\\@example.com", - }, - want: map[string]any{ - "simple": "@file.txt", - "file": "@file://file.txt", - "data": "@data://file.txt", - "keep_escape": "user\\@example.com", - }, - wantErr: false, - }, - { - name: "primitive types", - input: map[string]any{ - "int": 123, - "float": 45.67, - "bool": true, - "null": nil, - "string": "no prefix", - "email": "user@example.com", - }, - want: map[string]any{ - "int": 123, - "float": 45.67, - "bool": true, - "null": nil, - "string": "no prefix", - "email": "user@example.com", - }, - wantErr: false, - }, - { - name: "[]int values unchanged", - input: []int{1, 2, 3, 4, 5}, - want: []any{1, 2, 3, 4, 5}, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name+" text", func(t *testing.T) { - t.Parallel() - - got, err := embedFiles(tt.input, EmbedText, nil) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - require.Equal(t, tt.want, got) - } - }) - - t.Run(tt.name+" io.Reader", func(t *testing.T) { - t.Parallel() - - _, err := embedFiles(tt.input, EmbedIOReader, nil) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestEmbedFilesStdin(t *testing.T) { - t.Parallel() - - t.Run("FilePathValueDash", func(t *testing.T) { - t.Parallel() - - stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} - - withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue("-")}, EmbedText, stdin) - require.NoError(t, err) - require.Equal(t, map[string]any{"file": "stdin content"}, withEmbedded) - }) - - t.Run("FilePathValueDevStdin", func(t *testing.T) { - t.Parallel() - - stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} - - withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue("/dev/stdin")}, EmbedText, stdin) - require.NoError(t, err) - require.Equal(t, map[string]any{"file": "stdin content"}, withEmbedded) - }) - - t.Run("MultipleFilePathValueDashesError", func(t *testing.T) { - t.Parallel() - - stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} - - _, err := embedFiles(map[string]any{ - "file1": FilePathValue("-"), - "file2": FilePathValue("-"), - }, EmbedText, stdin) - require.Error(t, err) - require.Contains(t, err.Error(), "already been read") - }) - - t.Run("FilePathValueDashUnavailableStdin", func(t *testing.T) { - t.Parallel() - - stdin := &onceStdinReader{failureReason: "stdin is already being used for the request body"} - - _, err := embedFiles(map[string]any{"file": FilePathValue("-")}, EmbedText, stdin) - require.Error(t, err) - require.Contains(t, err.Error(), "cannot read from stdin") - require.Contains(t, err.Error(), "request body") - }) - - t.Run("AtDashEmbedText", func(t *testing.T) { - t.Parallel() - - stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} - - withEmbedded, err := embedFiles(map[string]any{"data": "@-"}, EmbedText, stdin) - require.NoError(t, err) - require.Equal(t, map[string]any{"data": "piped content"}, withEmbedded) - }) - - t.Run("AtDashEmbedIOReader", func(t *testing.T) { - t.Parallel() - - stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} - - withEmbedded, err := embedFiles(map[string]any{"data": "@-"}, EmbedIOReader, stdin) - require.NoError(t, err) - - withEmbeddedMap := withEmbedded.(map[string]any) - r := withEmbeddedMap["data"].(io.ReadCloser) - - content, err := io.ReadAll(r) - require.NoError(t, err) - require.Equal(t, "piped content", string(content)) - }) - - t.Run("FilePathValueRealFile", func(t *testing.T) { - t.Parallel() - - tmpDir := t.TempDir() - writeTestFile(t, tmpDir, "test.txt", "file content") - - stdin := &onceStdinReader{stdinReader: strings.NewReader("unused stdin")} - - withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue(filepath.Join(tmpDir, "test.txt"))}, EmbedText, stdin) - require.NoError(t, err) - require.Equal(t, map[string]any{"file": "file content"}, withEmbedded) - }) -} - -// TestEmbedFilesUploadMetadata verifies that EmbedIOReader mode wraps file readers with filename and -// content-type metadata so the multipart encoder populates `Content-Disposition` and `Content-Type` headers. -func TestEmbedFilesUploadMetadata(t *testing.T) { - t.Parallel() - - tmpDir := t.TempDir() - writeTestFile(t, tmpDir, "hello.txt", "hi") - writeTestFile(t, tmpDir, "page.html", "") - writeTestFile(t, tmpDir, "blob.bin", "\x00\x01") - - cases := []struct { - basename string - wantContentType string - }{ - {"hello.txt", "text/plain; charset=utf-8"}, - {"page.html", "text/html; charset=utf-8"}, - {"blob.bin", "application/octet-stream"}, - } - - for _, tc := range cases { - t.Run("AtPrefix_"+tc.basename, func(t *testing.T) { - t.Parallel() - - path := filepath.Join(tmpDir, tc.basename) - withEmbedded, err := embedFiles(map[string]any{"file": "@" + path}, EmbedIOReader, nil) - require.NoError(t, err) - - upload, ok := withEmbedded.(map[string]any)["file"].(fileUpload) - require.True(t, ok, "expected fileUpload, got %T", withEmbedded.(map[string]any)["file"]) - require.Equal(t, tc.basename, upload.Filename()) - require.Equal(t, upload.ContentType(), tc.wantContentType) - require.NoError(t, upload.Close()) - }) - - t.Run("FilePathValue_"+tc.basename, func(t *testing.T) { - t.Parallel() - - path := filepath.Join(tmpDir, tc.basename) - withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue(path)}, EmbedIOReader, nil) - require.NoError(t, err) - - upload, ok := withEmbedded.(map[string]any)["file"].(fileUpload) - require.True(t, ok, "expected fileUpload, got %T", withEmbedded.(map[string]any)["file"]) - require.Equal(t, tc.basename, upload.Filename()) - require.Equal(t, upload.ContentType(), tc.wantContentType) - require.NoError(t, upload.Close()) - }) - } -} - -func writeTestFile(t *testing.T, dir, filename, content string) { - t.Helper() - - path := filepath.Join(dir, filename) - - err := os.WriteFile(path, []byte(content), 0644) - require.NoError(t, err, "failed to write test file %s", path) -} diff --git a/pkg/cmd/machine.go b/pkg/cmd/machine.go deleted file mode 100644 index 73259d3..0000000 --- a/pkg/cmd/machine.go +++ /dev/null @@ -1,477 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/dedalus-labs/dedalus-cli/internal/apiquery" - "github.com/dedalus-labs/dedalus-cli/internal/requestflag" - "github.com/dedalus-labs/dedalus-go" - "github.com/dedalus-labs/dedalus-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var machinesCreate = cli.Command{ - Name: "create", - Usage: "Create machine", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "autosleep", - Usage: `Idle window before autosleep. Accepts fixed duration units like 30s, 30m, 2h, 7d3h4s, or 1w3d, raw seconds ("1800"), or never to disable.`, - Default: "300s", - BodyPath: "autosleep", - }, - &requestflag.Flag[int64]{ - Name: "memory-mib", - Usage: "Memory in MiB.", - Default: 4096, - BodyPath: "memory_mib", - }, - &requestflag.Flag[int64]{ - Name: "storage-gib", - Usage: "Storage in GiB.", - Default: 10, - BodyPath: "storage_gib", - }, - &requestflag.Flag[float64]{ - Name: "vcpu", - Usage: "CPU in vCPUs.", - Default: 1, - BodyPath: "vcpu", - }, - }, - Action: handleMachinesCreate, - HideHelpCommand: true, -} - -var machinesRetrieve = cli.Command{ - Name: "retrieve", - Usage: "Get machine", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - }, - Action: handleMachinesRetrieve, - HideHelpCommand: true, -} - -var machinesUpdate = cli.Command{ - Name: "update", - Usage: "Update machine", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - &requestflag.Flag[string]{ - Name: "autosleep", - Usage: `Idle window before autosleep. Accepts fixed duration units like 30s, 30m, 2h, 7d3h4s, or 1w3d, raw seconds ("1800"), or never to disable.`, - BodyPath: "autosleep", - }, - &requestflag.Flag[int64]{ - Name: "memory-mib", - Usage: "Memory in MiB.", - BodyPath: "memory_mib", - }, - &requestflag.Flag[int64]{ - Name: "storage-gib", - Usage: "Storage in GiB.", - BodyPath: "storage_gib", - }, - &requestflag.Flag[float64]{ - Name: "vcpu", - Usage: "CPU in vCPUs.", - BodyPath: "vcpu", - }, - }, - Action: handleMachinesUpdate, - HideHelpCommand: true, -} - -var machinesList = cli.Command{ - Name: "list", - Usage: "List machines", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "cursor", - QueryPath: "cursor", - }, - &requestflag.Flag[int64]{ - Name: "limit", - QueryPath: "limit", - }, - &requestflag.Flag[int64]{ - Name: "max-items", - Usage: "The maximum number of items to return (use -1 for unlimited).", - }, - }, - Action: handleMachinesList, - HideHelpCommand: true, -} - -var machinesDelete = cli.Command{ - Name: "delete", - Usage: "Destroy machine", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - }, - Action: handleMachinesDelete, - HideHelpCommand: true, -} - -var machinesSleep = cli.Command{ - Name: "sleep", - Usage: "Sleep a running machine", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - }, - Action: handleMachinesSleep, - HideHelpCommand: true, -} - -var machinesWake = cli.Command{ - Name: "wake", - Usage: "Wake a sleeping machine", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - }, - Action: handleMachinesWake, - HideHelpCommand: true, -} - -func handleMachinesCreate(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineNewParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.New(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines create", - Transform: transform, - }) -} - -func handleMachinesRetrieve(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineGetParams{ - MachineID: cmd.Value("machine-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.Get(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines retrieve", - Transform: transform, - }) -} - -func handleMachinesUpdate(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineUpdateParams{ - MachineID: cmd.Value("machine-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.Update(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines update", - Transform: transform, - }) -} - -func handleMachinesList(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineListParams{} - - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - if format == "raw" { - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.List(ctx, params, options...) - if err != nil { - return err - } - obj := gjson.ParseBytes(res) - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines list", - Transform: transform, - }) - } else { - iter := client.Machines.ListAutoPaging(ctx, params, options...) - maxItems := int64(-1) - if cmd.IsSet("max-items") { - maxItems = cmd.Value("max-items").(int64) - } - return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines list", - Transform: transform, - }) - } -} - -func handleMachinesDelete(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineDeleteParams{ - MachineID: cmd.Value("machine-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.Delete(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines delete", - Transform: transform, - }) -} - -func handleMachinesSleep(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineSleepParams{ - MachineID: cmd.Value("machine-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.Sleep(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines sleep", - Transform: transform, - }) -} - -func handleMachinesWake(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineWakeParams{ - MachineID: cmd.Value("machine-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.Wake(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines wake", - Transform: transform, - }) -} diff --git a/pkg/cmd/machine_test.go b/pkg/cmd/machine_test.go deleted file mode 100644 index 9b89704..0000000 --- a/pkg/cmd/machine_test.go +++ /dev/null @@ -1,124 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/dedalus-labs/dedalus-cli/internal/mocktest" -) - -func TestMachinesCreate(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "create", - "--autosleep", "autosleep", - "--memory-mib", "1", - "--storage-gib", "1", - "--vcpu", "1", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "autosleep: autosleep\n" + - "memory_mib: 1\n" + - "storage_gib: 1\n" + - "vcpu: 1\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "machines", "create", - ) - }) -} - -func TestMachinesRetrieve(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "retrieve", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - ) - }) -} - -func TestMachinesUpdate(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "update", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - "--autosleep", "autosleep", - "--memory-mib", "0", - "--storage-gib", "0", - "--vcpu", "0", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "autosleep: autosleep\n" + - "memory_mib: 0\n" + - "storage_gib: 0\n" + - "vcpu: 0\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "machines", "update", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - ) - }) -} - -func TestMachinesList(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "list", - "--max-items", "10", - "--cursor", "cursor", - "--limit", "0", - ) - }) -} - -func TestMachinesDelete(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "delete", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - ) - }) -} - -func TestMachinesSleep(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "sleep", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - ) - }) -} - -func TestMachinesWake(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "wake", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - ) - }) -} diff --git a/pkg/cmd/machineexecution.go b/pkg/cmd/machineexecution.go deleted file mode 100644 index a7c2804..0000000 --- a/pkg/cmd/machineexecution.go +++ /dev/null @@ -1,480 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - // @custom start - "fmt" - // @custom end - - "github.com/dedalus-labs/dedalus-cli/internal/apiquery" - "github.com/dedalus-labs/dedalus-cli/internal/requestflag" - "github.com/dedalus-labs/dedalus-go" - "github.com/dedalus-labs/dedalus-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var machinesExecutionsCreate = cli.Command{ - Name: "create", - Usage: "Create execution", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - &requestflag.Flag[any]{ - Name: "command", - Required: true, - BodyPath: "command", - }, - &requestflag.Flag[string]{ - Name: "cwd", - BodyPath: "cwd", - }, - &requestflag.Flag[map[string]any]{ - Name: "env", - BodyPath: "env", - }, - &requestflag.Flag[string]{ - Name: "stdin", - BodyPath: "stdin", - }, - &requestflag.Flag[int64]{ - Name: "timeout-ms", - BodyPath: "timeout_ms", - }, - }, - Action: handleMachinesExecutionsCreate, - HideHelpCommand: true, -} - -var machinesExecutionsRetrieve = cli.Command{ - Name: "retrieve", - Usage: "Get execution", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - &requestflag.Flag[string]{ - Name: "execution-id", - Required: true, - PathParam: "execution_id", - }, - }, - Action: handleMachinesExecutionsRetrieve, - HideHelpCommand: true, -} - -var machinesExecutionsList = cli.Command{ - Name: "list", - Usage: "List executions", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - &requestflag.Flag[string]{ - Name: "cursor", - QueryPath: "cursor", - }, - &requestflag.Flag[int64]{ - Name: "limit", - QueryPath: "limit", - }, - &requestflag.Flag[int64]{ - Name: "max-items", - Usage: "The maximum number of items to return (use -1 for unlimited).", - }, - }, - Action: handleMachinesExecutionsList, - HideHelpCommand: true, -} - -var machinesExecutionsDelete = cli.Command{ - Name: "delete", - Usage: "Delete execution", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - &requestflag.Flag[string]{ - Name: "execution-id", - Required: true, - PathParam: "execution_id", - }, - }, - Action: handleMachinesExecutionsDelete, - HideHelpCommand: true, -} - -var machinesExecutionsEvents = cli.Command{ - Name: "events", - Usage: "List execution events", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - &requestflag.Flag[string]{ - Name: "execution-id", - Required: true, - PathParam: "execution_id", - }, - &requestflag.Flag[string]{ - Name: "cursor", - QueryPath: "cursor", - }, - &requestflag.Flag[int64]{ - Name: "limit", - QueryPath: "limit", - }, - &requestflag.Flag[int64]{ - Name: "max-items", - Usage: "The maximum number of items to return (use -1 for unlimited).", - }, - }, - Action: handleMachinesExecutionsEvents, - HideHelpCommand: true, -} - -var machinesExecutionsOutput = cli.Command{ - Name: "output", - Usage: "Get execution output", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - &requestflag.Flag[string]{ - Name: "execution-id", - Required: true, - PathParam: "execution_id", - }, - }, - Action: handleMachinesExecutionsOutput, - HideHelpCommand: true, -} - -func handleMachinesExecutionsCreate(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - // @custom start - // Accepts literal command arguments alongside the generated JSON input path. - argv := cmd.Args().Slice() - if len(argv) > 0 { - if cmd.IsSet("command") { - return fmt.Errorf("use either --command or arguments after --, not both") - } - if argv[0] == "" { - return fmt.Errorf("the executable after -- must not be empty") - } - // Satisfy required-field validation without letting file expansion - // interpret argv values such as @file. Insert literal argv afterward. - if err := cmd.Set("command", "[]"); err != nil { - return err - } - } - // @custom end - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - // @custom start - // Inserts literal arguments after flag processing to bypass local @file expansion. - if len(argv) > 0 { - options = append(options, option.WithJSONSet("command", argv)) - } - // @custom end - - params := dedalus.MachineExecutionNewParams{ - MachineID: cmd.Value("machine-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.Executions.New(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines executions create", - Transform: transform, - }) -} - -func handleMachinesExecutionsRetrieve(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineExecutionGetParams{ - MachineID: cmd.Value("machine-id").(string), - ExecutionID: cmd.Value("execution-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.Executions.Get(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines executions retrieve", - Transform: transform, - }) -} - -func handleMachinesExecutionsList(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineExecutionListParams{ - MachineID: cmd.Value("machine-id").(string), - } - - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - if format == "raw" { - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.Executions.List(ctx, params, options...) - if err != nil { - return err - } - obj := gjson.ParseBytes(res) - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines executions list", - Transform: transform, - }) - } else { - iter := client.Machines.Executions.ListAutoPaging(ctx, params, options...) - maxItems := int64(-1) - if cmd.IsSet("max-items") { - maxItems = cmd.Value("max-items").(int64) - } - return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines executions list", - Transform: transform, - }) - } -} - -func handleMachinesExecutionsDelete(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineExecutionDeleteParams{ - MachineID: cmd.Value("machine-id").(string), - ExecutionID: cmd.Value("execution-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.Executions.Delete(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines executions delete", - Transform: transform, - }) -} - -func handleMachinesExecutionsEvents(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineExecutionEventsParams{ - MachineID: cmd.Value("machine-id").(string), - ExecutionID: cmd.Value("execution-id").(string), - } - - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - if format == "raw" { - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.Executions.Events(ctx, params, options...) - if err != nil { - return err - } - obj := gjson.ParseBytes(res) - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines executions events", - Transform: transform, - }) - } else { - iter := client.Machines.Executions.EventsAutoPaging(ctx, params, options...) - maxItems := int64(-1) - if cmd.IsSet("max-items") { - maxItems = cmd.Value("max-items").(int64) - } - return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines executions events", - Transform: transform, - }) - } -} - -func handleMachinesExecutionsOutput(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineExecutionOutputParams{ - MachineID: cmd.Value("machine-id").(string), - ExecutionID: cmd.Value("execution-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.Executions.Output(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines executions output", - Transform: transform, - }) -} diff --git a/pkg/cmd/machineexecution_test.go b/pkg/cmd/machineexecution_test.go deleted file mode 100644 index 58f1e24..0000000 --- a/pkg/cmd/machineexecution_test.go +++ /dev/null @@ -1,108 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/dedalus-labs/dedalus-cli/internal/mocktest" -) - -func TestMachinesExecutionsCreate(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "executions", "create", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - "--command", "[string]", - "--cwd", "cwd", - "--env", "{foo: string}", - "--stdin", "stdin", - "--timeout-ms", "0", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "command:\n" + - " - string\n" + - "cwd: cwd\n" + - "env:\n" + - " foo: string\n" + - "stdin: stdin\n" + - "timeout_ms: 0\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "machines", "executions", "create", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - ) - }) -} - -func TestMachinesExecutionsRetrieve(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "executions", "retrieve", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - "--execution-id", "execution_id", - ) - }) -} - -func TestMachinesExecutionsList(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "executions", "list", - "--max-items", "10", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - "--cursor", "cursor", - "--limit", "0", - ) - }) -} - -func TestMachinesExecutionsDelete(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "executions", "delete", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - "--execution-id", "execution_id", - ) - }) -} - -func TestMachinesExecutionsEvents(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "executions", "events", - "--max-items", "10", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - "--execution-id", "execution_id", - "--cursor", "cursor", - "--limit", "0", - ) - }) -} - -func TestMachinesExecutionsOutput(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "executions", "output", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - "--execution-id", "execution_id", - ) - }) -} diff --git a/pkg/cmd/machinessh.go b/pkg/cmd/machinessh.go deleted file mode 100644 index 5fa8004..0000000 --- a/pkg/cmd/machinessh.go +++ /dev/null @@ -1,290 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/dedalus-labs/dedalus-cli/internal/apiquery" - "github.com/dedalus-labs/dedalus-cli/internal/requestflag" - "github.com/dedalus-labs/dedalus-go" - "github.com/dedalus-labs/dedalus-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var machinesSSHCreate = cli.Command{ - Name: "create", - Usage: "Create SSH session", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - &requestflag.Flag[string]{ - Name: "public-key", - Required: true, - BodyPath: "public_key", - }, - }, - Action: handleMachinesSSHCreate, - HideHelpCommand: true, -} - -var machinesSSHRetrieve = cli.Command{ - Name: "retrieve", - Usage: "Get SSH session", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - &requestflag.Flag[string]{ - Name: "session-id", - Required: true, - PathParam: "session_id", - }, - }, - Action: handleMachinesSSHRetrieve, - HideHelpCommand: true, -} - -var machinesSSHList = cli.Command{ - Name: "list", - Usage: "List SSH sessions", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - &requestflag.Flag[string]{ - Name: "cursor", - QueryPath: "cursor", - }, - &requestflag.Flag[int64]{ - Name: "limit", - QueryPath: "limit", - }, - &requestflag.Flag[int64]{ - Name: "max-items", - Usage: "The maximum number of items to return (use -1 for unlimited).", - }, - }, - Action: handleMachinesSSHList, - HideHelpCommand: true, -} - -var machinesSSHDelete = cli.Command{ - Name: "delete", - Usage: "Delete SSH session", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "machine-id", - Required: true, - PathParam: "machine_id", - }, - &requestflag.Flag[string]{ - Name: "session-id", - Required: true, - PathParam: "session_id", - }, - }, - Action: handleMachinesSSHDelete, - HideHelpCommand: true, -} - -func handleMachinesSSHCreate(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineSSHNewParams{ - MachineID: cmd.Value("machine-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.SSH.New(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines ssh create", - Transform: transform, - }) -} - -func handleMachinesSSHRetrieve(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineSSHGetParams{ - MachineID: cmd.Value("machine-id").(string), - SessionID: cmd.Value("session-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.SSH.Get(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines ssh retrieve", - Transform: transform, - }) -} - -func handleMachinesSSHList(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineSSHListParams{ - MachineID: cmd.Value("machine-id").(string), - } - - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - if format == "raw" { - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.SSH.List(ctx, params, options...) - if err != nil { - return err - } - obj := gjson.ParseBytes(res) - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines ssh list", - Transform: transform, - }) - } else { - iter := client.Machines.SSH.ListAutoPaging(ctx, params, options...) - maxItems := int64(-1) - if cmd.IsSet("max-items") { - maxItems = cmd.Value("max-items").(int64) - } - return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines ssh list", - Transform: transform, - }) - } -} - -func handleMachinesSSHDelete(ctx context.Context, cmd *cli.Command) error { - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := dedalus.MachineSSHDeleteParams{ - MachineID: cmd.Value("machine-id").(string), - SessionID: cmd.Value("session-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.SSH.Delete(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "machines ssh delete", - Transform: transform, - }) -} diff --git a/pkg/cmd/machinessh_test.go b/pkg/cmd/machinessh_test.go deleted file mode 100644 index c5540ec..0000000 --- a/pkg/cmd/machinessh_test.go +++ /dev/null @@ -1,70 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/dedalus-labs/dedalus-cli/internal/mocktest" -) - -func TestMachinesSSHCreate(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "ssh", "create", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - "--public-key", "public_key", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("public_key: public_key") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "machines", "ssh", "create", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - ) - }) -} - -func TestMachinesSSHRetrieve(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "ssh", "retrieve", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - "--session-id", "session_id", - ) - }) -} - -func TestMachinesSSHList(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "ssh", "list", - "--max-items", "10", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - "--cursor", "cursor", - "--limit", "0", - ) - }) -} - -func TestMachinesSSHDelete(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "machines", "ssh", "delete", - "--machine-id", "dm-ecc2efdd-ddfa-31a9-c6f1-b833d337aa7c", - "--session-id", "session_id", - ) - }) -} diff --git a/pkg/cmd/nesting.go b/pkg/cmd/nesting.go deleted file mode 100644 index 17af30d..0000000 --- a/pkg/cmd/nesting.go +++ /dev/null @@ -1,50 +0,0 @@ -// @custom start -// Re-parents Stainless's colon-flattened subresource commands into -// space-separated subcommands. `dedalus machines:executions create` becomes -// `dedalus machines executions create`. The colon form is removed. -// -// Stainless's Go CLI generator emits nested subresources as colon-delimited -// top-level commands; native nesting is a tracked feature request (Slack -// thread with Nick, 2026-04). Once that ships, delete this file. - -package cmd - -import ( - "strings" - - "github.com/urfave/cli/v3" -) - -func init() { - Command.Commands = renestColonCommands(Command.Commands) - // Registers exec after nesting so both resource names share the same commands. - configureExecutionArguments(Command.Command("machines").Command("executions")) -} - -// renestColonCommands moves any command whose Name contains ':' under the -// matching prefix command at the same level. Applied recursively so future -// `a:b:c` shapes work without changes. -func renestColonCommands(cmds []*cli.Command) []*cli.Command { - byName := map[string]*cli.Command{} - for _, c := range cmds { - byName[c.Name] = c - } - kept := cmds[:0] - for _, c := range cmds { - if i := strings.IndexByte(c.Name, ':'); i > 0 { - if parent, ok := byName[c.Name[:i]]; ok { - child := *c - child.Name = c.Name[i+1:] - parent.Commands = append(parent.Commands, &child) - continue - } - } - kept = append(kept, c) - } - for _, c := range kept { - c.Commands = renestColonCommands(c.Commands) - } - return kept -} - -// @custom end diff --git a/pkg/cmd/nesting_test.go b/pkg/cmd/nesting_test.go deleted file mode 100644 index 06b9c9e..0000000 --- a/pkg/cmd/nesting_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package cmd - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/urfave/cli/v3" -) - -// findSub returns the immediate subcommand of c with the given name, or nil. -func findSub(c *cli.Command, name string) *cli.Command { - for _, sc := range c.Commands { - if sc.Name == name { - return sc - } - } - return nil -} - -func TestRenestColonCommands_one_level(t *testing.T) { - t.Parallel() - - cmds := []*cli.Command{ - {Name: "machines", Commands: []*cli.Command{{Name: "create"}, {Name: "list"}}}, - {Name: "machines:executions", Commands: []*cli.Command{{Name: "create"}, {Name: "list"}}}, - {Name: "machines:ssh", Commands: []*cli.Command{{Name: "create"}}}, - } - - out := renestColonCommands(cmds) - - require.Len(t, out, 1, "only `machines` should remain at the top level") - assert.Equal(t, "machines", out[0].Name) - - // Parent's own commands are preserved. - assert.NotNil(t, findSub(out[0], "create")) - assert.NotNil(t, findSub(out[0], "list")) - - // Subresources are re-parented and renamed. - execs := findSub(out[0], "executions") - require.NotNil(t, execs, "machines:executions should become machines > executions") - assert.NotNil(t, findSub(execs, "create")) - assert.NotNil(t, findSub(execs, "list")) - - ssh := findSub(out[0], "ssh") - require.NotNil(t, ssh) - assert.NotNil(t, findSub(ssh, "create")) -} - -func TestRenestColonCommands_recurses_for_nested_subresources(t *testing.T) { - t.Parallel() - - // Hypothetical future shape: `a:b:c`. Today Stainless only emits one - // colon, but if that ever changes we want the wrapper to keep working. - cmds := []*cli.Command{ - {Name: "a", Commands: []*cli.Command{ - {Name: "list"}, - {Name: "b", Commands: []*cli.Command{ - {Name: "list"}, - }}, - {Name: "b:c", Commands: []*cli.Command{ - {Name: "create"}, - }}, - }}, - } - - out := renestColonCommands(cmds) - - require.Len(t, out, 1) - a := out[0] - assert.NotNil(t, findSub(a, "list")) - - b := findSub(a, "b") - require.NotNil(t, b) - assert.NotNil(t, findSub(b, "list")) - - c := findSub(b, "c") - require.NotNil(t, c, "b:c should be re-parented under b as `c`") - assert.NotNil(t, findSub(c, "create")) - - // Original colon entry is gone from `a`'s direct children. - assert.Nil(t, findSub(a, "b:c")) -} - -func TestRenestColonCommands_leaves_orphan_colon_commands_alone(t *testing.T) { - t.Parallel() - - // No `foo` parent exists, so `foo:bar` has nothing to attach to and - // must be left at the top level rather than silently dropped. - cmds := []*cli.Command{ - {Name: "foo:bar"}, - } - - out := renestColonCommands(cmds) - require.Len(t, out, 1) - assert.Equal(t, "foo:bar", out[0].Name) -} - -// @custom start -func TestInvariantMachineCommandsMatchPublicContract(t *testing.T) { - t.Parallel() - - if findSub(Command, "usage") != nil { - t.Error("usage is outside the public API contract") - } - machines := findSub(Command, "machines") - require.NotNil(t, machines) - names := make([]string, 0, len(machines.Commands)) - for _, command := range machines.Commands { - names = append(names, command.Name) - } - require.ElementsMatch(t, []string{ - "create", "retrieve", "update", "list", "delete", "sleep", "wake", "ssh", "executions", - }, names) -} - -// @custom end diff --git a/pkg/cmd/rename.go b/pkg/cmd/rename.go deleted file mode 100644 index f154355..0000000 --- a/pkg/cmd/rename.go +++ /dev/null @@ -1,84 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "net/http" - "net/url" - - "github.com/dedalus-labs/dedalus-cli/internal/apiquery" - "github.com/dedalus-labs/dedalus-go" - "github.com/dedalus-labs/dedalus-go/option" - "github.com/urfave/cli/v3" -) - -type renameMachineResponse struct { - MachineID string `json:"machine_id"` - Name string `json:"name"` -} - -func init() { - Command.Commands = append(Command.Commands, &cli.Command{ - Name: "rename", - Usage: "Rename a machine", - UsageText: "dedalus rename ", - Category: "MACHINE", - Suggest: true, - Action: handleRename, - HideHelpCommand: true, - }) -} - -func handleRename(ctx context.Context, cmd *cli.Command) error { - args := cmd.Args().Slice() - if len(args) != 2 { - return fmt.Errorf("expected a current machine ID or name and a new name; usage: dedalus rename ") - } - current, newName := args[0], args[1] - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - result, err := renameMachine(ctx, &client, current, newName, options...) - if err != nil { - return err - } - acceptedName := result.Name - if acceptedName == "" { - acceptedName = newName - } - - fmt.Printf("Successfully renamed %s to %s\nmachine-id: %s\n", current, acceptedName, result.MachineID) - return nil -} - -func renameMachine( - ctx context.Context, - client *dedalus.Client, - current string, - newName string, - options ...option.RequestOption, -) (*renameMachineResponse, error) { - var result renameMachineResponse - err := client.Execute( - ctx, - http.MethodPatch, - fmt.Sprintf("v1/machines/%s", url.PathEscape(current)), - map[string]string{"name": newName}, - &result, - options..., - ) - if err != nil { - return nil, err - } - return &result, nil -} diff --git a/pkg/cmd/rename_test.go b/pkg/cmd/rename_test.go deleted file mode 100644 index ab27c76..0000000 --- a/pkg/cmd/rename_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package cmd - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/dedalus-labs/dedalus-go" - "github.com/dedalus-labs/dedalus-go/option" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRenameMachine(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - assert.Equal(t, http.MethodPatch, request.Method) - assert.Equal(t, "/v1/machines/hotel-california", request.URL.Path) - - var body map[string]string - require.NoError(t, json.NewDecoder(request.Body).Decode(&body)) - assert.Equal(t, map[string]string{"name": "breakfast"}, body) - - response.Header().Set("Content-Type", "application/json") - _, _ = response.Write([]byte(`{"machine_id":"dm-0198abc123","name":"breakfast"}`)) - })) - defer server.Close() - - client := dedalus.NewClient(option.WithBaseURL(server.URL)) - result, err := renameMachine(context.Background(), &client, "hotel-california", "breakfast") - - require.NoError(t, err) - assert.Equal(t, "dm-0198abc123", result.MachineID) - assert.Equal(t, "breakfast", result.Name) -} - -func TestRenameMachineSurfacesServerMessage(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { - response.Header().Set("Content-Type", "application/json") - response.WriteHeader(http.StatusUnprocessableEntity) - _ = json.NewEncoder(response).Encode(map[string]string{ - "error_code": "MACHINE_NAME_INVALID", - "message": `invalid name "My Box": names are lowercase with no spaces`, - }) - })) - defer server.Close() - - client := dedalus.NewClient(option.WithBaseURL(server.URL)) - _, err := renameMachine(context.Background(), &client, "dm-0198abc123", "My Box") - - require.Error(t, err) - assert.Contains(t, err.Error(), "MACHINE_NAME_INVALID") - assert.Contains(t, err.Error(), "names are lowercase with no spaces") -} diff --git a/pkg/cmd/ssh.go b/pkg/cmd/ssh.go deleted file mode 100644 index 970c431..0000000 --- a/pkg/cmd/ssh.go +++ /dev/null @@ -1,228 +0,0 @@ -// Implements `dedalus ssh `: -// -// 1. Generate ephemeral ed25519 keypair in a temp directory. -// 2. POST /v1/machines/{machine_id}/ssh with the public key. -// 3. Poll GET until status=ready. -// 4. Write user certificate and host CA to temp files. -// 5. Exec the local ssh binary with explicit trust settings. -// 6. Clean up temp directory on exit (including signal-driven exit). - -package cmd - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "encoding/pem" - "fmt" - "os" - "os/exec" - "os/signal" - "path/filepath" - "strconv" - "syscall" - "time" - - "github.com/dedalus-labs/dedalus-go" - "github.com/urfave/cli/v3" - "golang.org/x/crypto/ssh" -) - -const ( - sshPollInterval = 500 * time.Millisecond - sshPollMax = 120 -) - -func init() { - Command.Commands = append(Command.Commands, &cli.Command{ - Name: "ssh", - Usage: "SSH into a running machine", - UsageText: "dedalus ssh ", - Category: "MACHINE", - Suggest: true, - Flags: []cli.Flag{}, - Action: handleSSH, - HideHelpCommand: true, - }) -} - -func handleSSH(ctx context.Context, cmd *cli.Command) error { - machineID := cmd.Args().First() - if machineID == "" { - return fmt.Errorf("machine_id is required; usage: dedalus ssh ") - } - - client := dedalus.NewClient(getDefaultRequestOptions(cmd)...) - - dir, err := os.MkdirTemp("", "dedalus-ssh-*") - if err != nil { - return fmt.Errorf("create temp directory for ephemeral SSH keys: %w", err) - } - removed := false - remove := func() { - if !removed { - os.RemoveAll(dir) - removed = true - } - } - defer remove() - trapSignals(remove) - - keyPath := filepath.Join(dir, "key") - pubKey, err := genKeypair(keyPath) - if err != nil { - return err - } - - sess, err := awaitSSHSession(ctx, &client, machineID, pubKey) - if err != nil { - return err - } - - conn := sess.Connection - if conn.UserCertificate == "" { - return fmt.Errorf("SSH session %s is ready but the server returned no user certificate; this is a server-side bug", sess.SessionID) - } - if conn.Endpoint == "" { - return fmt.Errorf("SSH session %s is ready but the server returned no endpoint; this is a server-side bug", sess.SessionID) - } - if conn.HostTrust.PublicKey == "" { - return fmt.Errorf("SSH session %s is ready but the server returned no host CA public key; this is a server-side bug", sess.SessionID) - } - - certPath := filepath.Join(dir, "key-cert.pub") - if err := os.WriteFile(certPath, []byte(conn.UserCertificate), 0600); err != nil { - return fmt.Errorf("write certificate: %w", err) - } - - khPath := filepath.Join(dir, "known_hosts") - line := fmt.Sprintf("@cert-authority %s %s\n", conn.HostTrust.HostPattern, conn.HostTrust.PublicKey) - if err := os.WriteFile(khPath, []byte(line), 0600); err != nil { - return fmt.Errorf("write known_hosts: %w", err) - } - - return runSSH(ctx, keyPath, certPath, khPath, conn) -} - -func awaitSSHSession( - ctx context.Context, - client *dedalus.Client, - machineID, pubKey string, -) (*dedalus.SSHSession, error) { - resp, err := client.Machines.SSH.New(ctx, dedalus.MachineSSHNewParams{ - MachineID: machineID, - SSHSessionCreateParams: dedalus.SSHSessionCreateParams{ - PublicKey: pubKey, - }, - }) - if err != nil { - return nil, fmt.Errorf("create ssh session: %w", err) - } - fmt.Fprintf(os.Stderr, "ssh %s: %s\n", resp.SessionID, resp.Status) - - for i := 0; i < sshPollMax; i++ { - switch resp.Status { - case dedalus.SSHSessionStatusReady: - return resp, nil - case dedalus.SSHSessionStatusFailed: - msg := resp.ErrorMessage - if msg == "" { - msg = "no detail from server" - } - return nil, fmt.Errorf("SSH session failed: %s (error_code=%s)", msg, resp.ErrorCode) - case dedalus.SSHSessionStatusExpired: - return nil, fmt.Errorf("SSH session expired before it became ready; try again with a fresh session") - case dedalus.SSHSessionStatusClosed: - return nil, fmt.Errorf("SSH session was closed before it became ready; another client may have deleted it") - } - - delay := sshPollInterval - if resp.RetryAfterMs > 0 { - delay = time.Duration(resp.RetryAfterMs) * time.Millisecond - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(delay): - } - - resp, err = client.Machines.SSH.Get(ctx, dedalus.MachineSSHGetParams{ - MachineID: machineID, - SessionID: resp.SessionID, - }) - if err != nil { - return nil, fmt.Errorf("poll ssh session: %w", err) - } - fmt.Fprintf(os.Stderr, "ssh %s: %s\n", resp.SessionID, resp.Status) - } - return nil, fmt.Errorf("SSH session did not become ready after %d polls (%v); the Dedalus Machine may be unresponsive or the SSH gateway may be down", sshPollMax, time.Duration(sshPollMax)*sshPollInterval) -} - -func runSSH(ctx context.Context, keyPath, certPath, khPath string, conn dedalus.SSHConnection) error { - bin, err := exec.LookPath("ssh") - if err != nil { - return fmt.Errorf("ssh not found in PATH; install OpenSSH and ensure 'ssh' is available in your shell: %w", err) - } - - fmt.Fprintf(os.Stderr, "connecting to %s@%s:%d\n", conn.SSHUsername, conn.Endpoint, conn.Port) - - c := exec.CommandContext(ctx, bin, - "-i", keyPath, - "-o", "CertificateFile="+certPath, - "-o", "UserKnownHostsFile="+khPath, - "-o", "GlobalKnownHostsFile=/dev/null", - "-o", "StrictHostKeyChecking=yes", - "-o", "IdentitiesOnly=yes", - "-p", strconv.FormatInt(conn.Port, 10), - conn.SSHUsername+"@"+conn.Endpoint, - ) - c.Stdin = os.Stdin - c.Stdout = os.Stdout - c.Stderr = os.Stderr - - if err := c.Run(); err != nil { - if exit, ok := err.(*exec.ExitError); ok { - os.Exit(exit.ExitCode()) - } - return err - } - return nil -} - -func genKeypair(keyPath string) (string, error) { - pub, priv, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - return "", fmt.Errorf("generate ed25519 key: %w", err) - } - - privPEM, err := ssh.MarshalPrivateKey(priv, "") - if err != nil { - return "", fmt.Errorf("marshal private key: %w", err) - } - if err := os.WriteFile(keyPath, pem.EncodeToMemory(privPEM), 0600); err != nil { - return "", fmt.Errorf("write private key: %w", err) - } - - sshPub, err := ssh.NewPublicKey(pub) - if err != nil { - return "", fmt.Errorf("convert to ssh public key: %w", err) - } - authorized := string(ssh.MarshalAuthorizedKey(sshPub)) - - if err := os.WriteFile(keyPath+".pub", []byte(authorized), 0644); err != nil { - return "", fmt.Errorf("write public key: %w", err) - } - return authorized, nil -} - -func trapSignals(cleanup func()) { - ch := make(chan os.Signal, 1) - signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) - go func() { - <-ch - cleanup() - signal.Reset(syscall.SIGINT, syscall.SIGTERM) - p, _ := os.FindProcess(os.Getpid()) - _ = p.Signal(syscall.SIGINT) - }() -} diff --git a/pkg/cmd/ssh_test.go b/pkg/cmd/ssh_test.go deleted file mode 100644 index 734a534..0000000 --- a/pkg/cmd/ssh_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - gossh "golang.org/x/crypto/ssh" -) - -func TestGenKeypair_produces_valid_ed25519(t *testing.T) { - tmpDir := t.TempDir() - keyPath := filepath.Join(tmpDir, "key") - - pubKeyStr, err := genKeypair(keyPath) - require.NoError(t, err) - - // Public key is valid authorized_key format. - pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(pubKeyStr)) - require.NoError(t, err) - assert.Equal(t, "ssh-ed25519", pub.Type()) - - // Private key file exists with restricted permissions. - info, err := os.Stat(keyPath) - require.NoError(t, err) - assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) - - // Private key is parseable OpenSSH format. - privBytes, err := os.ReadFile(keyPath) - require.NoError(t, err) - assert.True(t, strings.HasPrefix(string(privBytes), "-----BEGIN OPENSSH PRIVATE KEY-----"), - "private key must be OpenSSH format, got: %s", string(privBytes[:40])) - - signer, err := gossh.ParsePrivateKey(privBytes) - require.NoError(t, err) - assert.Equal(t, "ssh-ed25519", signer.PublicKey().Type()) - - // Public key file also written. - pubFileBytes, err := os.ReadFile(keyPath + ".pub") - require.NoError(t, err) - assert.True(t, strings.HasPrefix(string(pubFileBytes), "ssh-ed25519 ")) -} - -func TestGenKeypair_keys_correspond(t *testing.T) { - tmpDir := t.TempDir() - keyPath := filepath.Join(tmpDir, "key") - - pubKeyStr, err := genKeypair(keyPath) - require.NoError(t, err) - - privBytes, err := os.ReadFile(keyPath) - require.NoError(t, err) - signer, err := gossh.ParsePrivateKey(privBytes) - require.NoError(t, err) - - pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(pubKeyStr)) - require.NoError(t, err) - - // The public key derived from the private key must match. - assert.Equal(t, - gossh.MarshalAuthorizedKey(signer.PublicKey()), - gossh.MarshalAuthorizedKey(pub), - "public key from private key must match the returned authorized key") -} - -func TestGenKeypair_unique_per_call(t *testing.T) { - tmpDir := t.TempDir() - - pub1, err := genKeypair(filepath.Join(tmpDir, "key1")) - require.NoError(t, err) - - pub2, err := genKeypair(filepath.Join(tmpDir, "key2")) - require.NoError(t, err) - - assert.NotEqual(t, pub1, pub2, "each invocation must produce a fresh keypair") -} - -func TestKnownHostsLine_format(t *testing.T) { - hostPattern := "*.ssh.dedaluslabs.ai" - caPublicKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyData" - - line := "@cert-authority " + hostPattern + " " + caPublicKey + "\n" - - assert.True(t, strings.HasPrefix(line, "@cert-authority ")) - assert.Contains(t, line, hostPattern) - assert.Contains(t, line, caPublicKey) - assert.True(t, strings.HasSuffix(line, "\n")) - - // The line is parseable as a known_hosts entry by splitting. - fields := strings.Fields(strings.TrimSpace(line)) - assert.Equal(t, 4, len(fields), "known_hosts cert-authority line should have 4 fields") - assert.Equal(t, "@cert-authority", fields[0]) - assert.Equal(t, hostPattern, fields[1]) - assert.Equal(t, "ssh-ed25519", fields[2]) -} - -func TestSSHArgs_structure(t *testing.T) { - args := []string{ - "-i", "/tmp/key", - "-o", "CertificateFile=/tmp/key-cert.pub", - "-o", "UserKnownHostsFile=/tmp/known_hosts", - "-o", "GlobalKnownHostsFile=/dev/null", - "-o", "StrictHostKeyChecking=yes", - "-o", "IdentitiesOnly=yes", - "-p", "2222", - "workspace@ssh.dedaluslabs.ai", - } - - // Identity file is first. - assert.Equal(t, "-i", args[0]) - - // CertificateFile is set. - certIdx := -1 - for i, a := range args { - if strings.HasPrefix(a, "CertificateFile=") { - certIdx = i - break - } - } - assert.Greater(t, certIdx, 0, "CertificateFile option must be present") - - // StrictHostKeyChecking=yes is enforced (no TOFU). - found := false - for _, a := range args { - if a == "StrictHostKeyChecking=yes" { - found = true - } - } - assert.True(t, found, "StrictHostKeyChecking=yes must be set") - - // IdentitiesOnly=yes prevents ssh-agent from offering other keys. - idOnly := false - for _, a := range args { - if a == "IdentitiesOnly=yes" { - idOnly = true - } - } - assert.True(t, idOnly, "IdentitiesOnly=yes must be set") - - // GlobalKnownHostsFile=/dev/null prevents system-wide trust. - globalNull := false - for _, a := range args { - if a == "GlobalKnownHostsFile=/dev/null" { - globalNull = true - } - } - assert.True(t, globalNull, "GlobalKnownHostsFile must be /dev/null") - - // Last arg is user@host. - last := args[len(args)-1] - assert.Contains(t, last, "@", "last arg must be user@host") -} - -func TestCleanup_removes_temp_directory(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "dedalus-ssh-test-*") - require.NoError(t, err) - - keyPath := filepath.Join(tmpDir, "key") - require.NoError(t, os.WriteFile(keyPath, []byte("secret"), 0600)) - require.NoError(t, os.WriteFile(keyPath+".pub", []byte("public"), 0644)) - - os.RemoveAll(tmpDir) - - _, err = os.Stat(tmpDir) - assert.True(t, os.IsNotExist(err), "temp directory must be removed after cleanup") - _, err = os.Stat(keyPath) - assert.True(t, os.IsNotExist(err), "private key must be removed after cleanup") -} diff --git a/pkg/cmd/startup_update.go b/pkg/cmd/startup_update.go deleted file mode 100644 index 13a49dc..0000000 --- a/pkg/cmd/startup_update.go +++ /dev/null @@ -1,382 +0,0 @@ -// Copyright (c) 2026 Dedalus Labs, Inc. All rights reserved. - -package cmd - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/charmbracelet/x/term" -) - -/* -The startup update prompt runs before urfave parses or executes the requested -command. Its control flow is: - - 1. Skip non-interactive, CI, help, version, completion, and update commands. - 2. Read the cached release and use it immediately while it is fresh. - 3. Refresh stale or missing cache entries with a bounded network request. - 4. If that refresh fails, compare a previously valid cache entry. - 5. Prompt only when the selected release is newer than the running build. - -Accepting the prompt delegates to the same updater used by `dedalus update`. -The true return value tells the caller not to execute the original command. -*/ - -const ( - dedalusHomeEnv = "DEDALUS_HOME" - disableUpdateCheckEnv = "DEDALUS_NO_UPDATE_CHECK" - startupUpdateCacheFile = "version.json" - // startupUpdateCheckInterval keeps routine invocations off the release API - // while allowing a fresh check during each day of CLI use. - startupUpdateCheckInterval = 20 * time.Hour - // startupUpdateCheckTimeout keeps a slow network from delaying command dispatch. - startupUpdateCheckTimeout = 2 * time.Second -) - -// startupVersionInfo is the complete on-disk cache entry. LastCheckedAt is the -// time of a successful release query, not the time the cache was read. -type startupVersionInfo struct { - LatestVersion string `json:"latest_version"` - LastCheckedAt time.Time `json:"last_checked_at"` -} - -// startupUpdatePrompt owns startup eligibility, release caching, and prompting. -// Function fields isolate terminal, clock, network, and update effects in tests. -type startupUpdatePrompt struct { - stdin io.Reader - stderr io.Writer - getenv func(string) string - homeDir func() (string, error) - now func() time.Time - isInteractive func() bool - latestVersion func(context.Context) (string, error) - runUpdate func(context.Context) error - cachePath string -} - -// MaybeRunStartupUpdate prompts interactive users before command execution. It -// returns true only when the user selected the updater, so callers can stop the -// original command even when the update returns an error. -func MaybeRunStartupUpdate(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) { - p := newStartupUpdatePrompt(stdin, stdout, stderr) - return p.run(ctx, args) -} - -func newStartupUpdatePrompt(stdin io.Reader, stdout, stderr io.Writer) *startupUpdatePrompt { - p := &startupUpdatePrompt{ - stdin: stdin, - stderr: stderr, - getenv: os.Getenv, - homeDir: os.UserHomeDir, - now: time.Now, - } - p.isInteractive = func() bool { - return terminalReader(stdin) && terminalWriter(stdout) && terminalWriter(stderr) - } - p.latestVersion = func(ctx context.Context) (string, error) { - updater := newUpdater(io.Discard, io.Discard) - return updater.latestVersion(ctx) - } - p.runUpdate = func(ctx context.Context) error { - return newUpdater(stdout, stderr).update(ctx, updateOptions{}) - } - return p -} - -// run returns whether startup processing consumed the original command. -func (p *startupUpdatePrompt) run(ctx context.Context, args []string) (bool, error) { - if p.shouldSkip(args) { - return false, nil - } - - latest, ok := p.upgradeVersion(ctx) - if !ok { - return false, nil - } - - return p.prompt(ctx, latest) -} - -// shouldSkip protects flows that must remain non-interactive. It examines raw -// arguments because startup prompting runs before the CLI parser. -func (p *startupUpdatePrompt) shouldSkip(args []string) bool { - if !p.isInteractive() { - return true - } - if envTruthy(p.getenv("CI")) || envTruthy(p.getenv(disableUpdateCheckEnv)) { - return true - } - if hasHelpOrVersionArg(args) { - return true - } - - switch rootCommandArg(args) { - case "update", "__complete", "@completion", "@manpages", "help": - return true - default: - return false - } -} - -// upgradeVersion selects one release for comparison. A fresh cache avoids the -// network. A stale or missing cache gets one bounded refresh; a successful -// response remains usable when the cache write fails, while a failed refresh -// may still use a previously valid cache entry. -func (p *startupUpdatePrompt) upgradeVersion(ctx context.Context) (string, bool) { - cachePath, err := p.versionCachePath() - if err != nil { - return "", false - } - - info, cacheOK := readStartupVersionInfo(cachePath) - if cacheOK && p.now().Sub(info.LastCheckedAt) < startupUpdateCheckInterval { - return newerStartupVersion(info.LatestVersion) - } - - checkCtx, cancel := context.WithTimeout(ctx, startupUpdateCheckTimeout) - defer cancel() - - latest, err := p.latestVersion(checkCtx) - if err == nil { - if err := writeStartupVersionInfo(cachePath, startupVersionInfo{ - LatestVersion: latest, - LastCheckedAt: p.now(), - }); err != nil { - return newerStartupVersion(latest) - } - return newerStartupVersion(latest) - } - - if cacheOK { - return newerStartupVersion(info.LatestVersion) - } - return "", false -} - -// prompt returns true only after the user accepts the update. Input failures and -// negative answers leave the original command eligible to run. -func (p *startupUpdatePrompt) prompt(ctx context.Context, latest string) (bool, error) { - fmt.Fprintf(p.stderr, "A new Dedalus CLI is available.\n\n") - fmt.Fprintf(p.stderr, "Current: %s\n", versionTag(Version)) - fmt.Fprintf(p.stderr, "Latest: %s\n\n", versionTag(latest)) - fmt.Fprint(p.stderr, "Update now?\n") - fmt.Fprint(p.stderr, "> Yes\n") - fmt.Fprint(p.stderr, " Not now\n\n") - fmt.Fprint(p.stderr, "Press Enter to update, or type n then Enter to skip: ") - - answer, err := bufio.NewReader(p.stdin).ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - fmt.Fprintln(p.stderr) - return false, nil - } - if errors.Is(err, io.EOF) && strings.TrimSpace(answer) == "" { - fmt.Fprintln(p.stderr) - return false, nil - } - if shouldRunStartupUpdate(answer) { - fmt.Fprintln(p.stderr) - return true, p.runUpdate(ctx) - } - - fmt.Fprintln(p.stderr) - return false, nil -} - -func (p *startupUpdatePrompt) versionCachePath() (string, error) { - if p.cachePath != "" { - return p.cachePath, nil - } - home := strings.TrimSpace(p.getenv(dedalusHomeEnv)) - if home == "" { - userHome, err := p.homeDir() - if err != nil { - return "", err - } - home = filepath.Join(userHome, ".dedalus") - } - return filepath.Join(home, startupUpdateCacheFile), nil -} - -// readStartupVersionInfo treats unreadable, malformed, and incomplete data as a -// cache miss. The caller then decides whether a network refresh is possible. -func readStartupVersionInfo(path string) (startupVersionInfo, bool) { - data, err := os.ReadFile(path) - if err != nil { - return startupVersionInfo{}, false - } - var info startupVersionInfo - if err := json.Unmarshal(data, &info); err != nil { - return startupVersionInfo{}, false - } - if strings.TrimSpace(info.LatestVersion) == "" || info.LastCheckedAt.IsZero() { - return startupVersionInfo{}, false - } - return info, true -} - -func writeStartupVersionInfo(path string, info startupVersionInfo) error { - data, err := json.Marshal(info) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - return os.WriteFile(path, append(data, '\n'), 0644) -} - -func newerStartupVersion(latest string) (string, bool) { - if isNewerVersion(latest, Version) { - return versionTag(latest), true - } - return "", false -} - -func shouldRunStartupUpdate(answer string) bool { - answer = strings.ToLower(strings.TrimSpace(answer)) - return answer == "" || answer == "y" || answer == "yes" -} - -// rootCommandArg finds the first positional root command without invoking the -// CLI parser. Root flags that consume a following value must be skipped here. -func rootCommandArg(args []string) string { - for i := 1; i < len(args); i++ { - arg := args[i] - if arg == "--" { - return "" - } - if strings.HasPrefix(arg, "-") { - if rootFlagTakesValue(arg) && !strings.Contains(arg, "=") { - i++ - } - continue - } - return arg - } - return "" -} - -// rootFlagTakesValue mirrors value-bearing flags on the Stainless-generated root -// command. New root flags must be added here or their value may be mistaken for -// a command. -func rootFlagTakesValue(arg string) bool { - name := strings.TrimLeft(strings.SplitN(arg, "=", 2)[0], "-") - switch name { - case "api-key", "base-url", "dedalus-org-id", "format", "format-error", "transform", "transform-error", "x-api-key": - return true - default: - return false - } -} - -func hasHelpOrVersionArg(args []string) bool { - for _, arg := range args[1:] { - switch arg { - case "-h", "--help", "-v", "--version": - return true - } - } - return false -} - -func envTruthy(value string) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -func terminalReader(r io.Reader) bool { - f, ok := r.(*os.File) - return ok && term.IsTerminal(f.Fd()) -} - -func terminalWriter(w io.Writer) bool { - f, ok := w.(*os.File) - return ok && term.IsTerminal(f.Fd()) -} - -type parsedVersion struct { - major int - minor int - patch int - prerelease string -} - -// isNewerVersion compares the numeric release tuple, then the prerelease suffix. -// Tags outside that grammar compare as normalized strings; any difference is -// treated as an available update. -func isNewerVersion(candidate, current string) bool { - candidateVersion, okCandidate := parseVersion(candidate) - currentVersion, okCurrent := parseVersion(current) - if !okCandidate || !okCurrent { - return !sameVersion(candidate, current) - } - - if candidateVersion.major != currentVersion.major { - return candidateVersion.major > currentVersion.major - } - if candidateVersion.minor != currentVersion.minor { - return candidateVersion.minor > currentVersion.minor - } - if candidateVersion.patch != currentVersion.patch { - return candidateVersion.patch > currentVersion.patch - } - if candidateVersion.prerelease == currentVersion.prerelease { - return false - } - if candidateVersion.prerelease == "" { - return true - } - if currentVersion.prerelease == "" { - return false - } - return candidateVersion.prerelease > currentVersion.prerelease -} - -// parseVersion accepts three numeric components and an optional prerelease -// suffix. Prerelease suffixes are compared lexicographically by isNewerVersion. -func parseVersion(version string) (parsedVersion, bool) { - version = strings.TrimPrefix(strings.TrimSpace(version), "v") - if version == "" { - return parsedVersion{}, false - } - - mainVersion, prerelease, _ := strings.Cut(version, "-") - parts := strings.Split(mainVersion, ".") - if len(parts) != 3 { - return parsedVersion{}, false - } - - major, err := strconv.Atoi(parts[0]) - if err != nil { - return parsedVersion{}, false - } - minor, err := strconv.Atoi(parts[1]) - if err != nil { - return parsedVersion{}, false - } - patch, err := strconv.Atoi(parts[2]) - if err != nil { - return parsedVersion{}, false - } - - return parsedVersion{ - major: major, - minor: minor, - patch: patch, - prerelease: prerelease, - }, true -} diff --git a/pkg/cmd/startup_update_test.go b/pkg/cmd/startup_update_test.go deleted file mode 100644 index bece302..0000000 --- a/pkg/cmd/startup_update_test.go +++ /dev/null @@ -1,204 +0,0 @@ -// Copyright (c) 2026 Dedalus Labs, Inc. All rights reserved. - -package cmd - -import ( - "bytes" - "context" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestStartupUpdatePromptUsesFreshCacheAndRunsUpdate(t *testing.T) { - now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) - cachePath := filepath.Join(t.TempDir(), startupUpdateCacheFile) - if err := writeStartupVersionInfo(cachePath, startupVersionInfo{ - LatestVersion: "v9.9.9", - LastCheckedAt: now, - }); err != nil { - t.Fatalf("writeStartupVersionInfo() returned unexpected error: %v", err) - } - - var stderr bytes.Buffer - ranUpdate := false - prompt := newTestStartupUpdatePrompt(t, cachePath, "\n", &stderr) - prompt.now = func() time.Time { return now } - prompt.latestVersion = func(context.Context) (string, error) { - t.Fatal("fresh cache should not fetch latest version") - return "", nil - } - prompt.runUpdate = func(context.Context) error { - ranUpdate = true - return nil - } - - updated, err := prompt.run(context.Background(), []string{"dedalus", "machines", "list"}) - if err != nil { - t.Fatalf("startup update prompt returned unexpected error: %v", err) - } - if !updated { - t.Fatal("startup update prompt did not report that update ran") - } - if !ranUpdate { - t.Fatal("startup update prompt did not run updater") - } - - got := stderr.String() - for _, want := range []string{ - "A new Dedalus CLI is available.", - "Current: v" + Version, - "Latest: v9.9.9", - "Update now?\n> Yes\n Not now", - } { - if !strings.Contains(got, want) { - t.Errorf("prompt output = %q, want substring %q", got, want) - } - } -} - -func TestStartupUpdatePromptDeclinesUpdate(t *testing.T) { - now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) - cachePath := filepath.Join(t.TempDir(), startupUpdateCacheFile) - if err := writeStartupVersionInfo(cachePath, startupVersionInfo{ - LatestVersion: "v9.9.9", - LastCheckedAt: now, - }); err != nil { - t.Fatalf("writeStartupVersionInfo() returned unexpected error: %v", err) - } - - var stderr bytes.Buffer - prompt := newTestStartupUpdatePrompt(t, cachePath, "n\n", &stderr) - prompt.now = func() time.Time { return now } - prompt.runUpdate = func(context.Context) error { - t.Fatal("declining the prompt should not run updater") - return nil - } - - updated, err := prompt.run(context.Background(), []string{"dedalus", "machines", "list"}) - if err != nil { - t.Fatalf("startup update prompt returned unexpected error: %v", err) - } - if updated { - t.Fatal("startup update prompt reported an update after user declined") - } - if got, want := stderr.String(), "Update now?\n> Yes\n Not now"; !strings.Contains(got, want) { - t.Errorf("prompt output = %q, want substring %q", got, want) - } -} - -func TestStartupUpdatePromptSkipsNonInteractiveLaunch(t *testing.T) { - cachePath := filepath.Join(t.TempDir(), startupUpdateCacheFile) - if err := writeStartupVersionInfo(cachePath, startupVersionInfo{ - LatestVersion: "v9.9.9", - LastCheckedAt: time.Now(), - }); err != nil { - t.Fatalf("writeStartupVersionInfo() returned unexpected error: %v", err) - } - - var stderr bytes.Buffer - prompt := newTestStartupUpdatePrompt(t, cachePath, "\n", &stderr) - prompt.isInteractive = func() bool { return false } - prompt.runUpdate = func(context.Context) error { - t.Fatal("noninteractive launch should not run updater") - return nil - } - - updated, err := prompt.run(context.Background(), []string{"dedalus", "machines", "list"}) - if err != nil { - t.Fatalf("startup update prompt returned unexpected error: %v", err) - } - if updated { - t.Fatal("noninteractive launch reported an update") - } - if got := stderr.String(); got != "" { - t.Errorf("noninteractive prompt output = %q, want empty", got) - } -} - -func TestStartupUpdatePromptRefreshesStaleCache(t *testing.T) { - now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) - cachePath := filepath.Join(t.TempDir(), startupUpdateCacheFile) - if err := writeStartupVersionInfo(cachePath, startupVersionInfo{ - LatestVersion: "v" + Version, - LastCheckedAt: now.Add(-startupUpdateCheckInterval - time.Minute), - }); err != nil { - t.Fatalf("writeStartupVersionInfo() returned unexpected error: %v", err) - } - - var stderr bytes.Buffer - prompt := newTestStartupUpdatePrompt(t, cachePath, "n\n", &stderr) - prompt.now = func() time.Time { return now } - prompt.latestVersion = func(context.Context) (string, error) { - return "v9.9.9", nil - } - - updated, err := prompt.run(context.Background(), []string{"dedalus", "machines", "list"}) - if err != nil { - t.Fatalf("startup update prompt returned unexpected error: %v", err) - } - if updated { - t.Fatal("declining refreshed prompt reported an update") - } - - info, ok := readStartupVersionInfo(cachePath) - if !ok { - t.Fatal("expected refreshed startup version cache") - } - if info.LatestVersion != "v9.9.9" { - t.Errorf("refreshed latest version = %q, want v9.9.9", info.LatestVersion) - } - if !info.LastCheckedAt.Equal(now) { - t.Errorf("refreshed last_checked_at = %s, want %s", info.LastCheckedAt, now) - } -} - -func TestRootCommandArgSkipsRootUpdateOnly(t *testing.T) { - t.Parallel() - - if got, want := rootCommandArg([]string{"dedalus", "--format", "json", "update"}), "update"; got != want { - t.Errorf("rootCommandArg(root update) = %q, want %q", got, want) - } - if got, want := rootCommandArg([]string{"dedalus", "machines", "update"}), "machines"; got != want { - t.Errorf("rootCommandArg(nested update) = %q, want %q", got, want) - } -} - -func TestIsNewerVersion(t *testing.T) { - t.Parallel() - - tests := []struct { - candidate string - current string - want bool - }{ - {candidate: "v0.4.1", current: "0.4.0", want: true}, - {candidate: "v0.4.0", current: "0.4.0", want: false}, - {candidate: "v0.3.9", current: "0.4.0", want: false}, - {candidate: "v1.0.0-beta", current: "v1.0.0", want: false}, - {candidate: "v1.0.0", current: "v1.0.0-beta", want: true}, - } - - for _, tt := range tests { - if got := isNewerVersion(tt.candidate, tt.current); got != tt.want { - t.Errorf("isNewerVersion(%q, %q) = %t, want %t", tt.candidate, tt.current, got, tt.want) - } - } -} - -func newTestStartupUpdatePrompt(t *testing.T, cachePath string, stdin string, stderr *bytes.Buffer) *startupUpdatePrompt { - t.Helper() - - return &startupUpdatePrompt{ - stdin: strings.NewReader(stdin), - stderr: stderr, - getenv: func(string) string { return "" }, - homeDir: func() (string, error) { return t.TempDir(), nil }, - now: time.Now, - isInteractive: func() bool { return true }, - latestVersion: func(context.Context) (string, error) { return "v" + Version, nil }, - runUpdate: func(context.Context) error { return nil }, - cachePath: cachePath, - } -} diff --git a/pkg/cmd/suggest.go b/pkg/cmd/suggest.go deleted file mode 100644 index b4b637c..0000000 --- a/pkg/cmd/suggest.go +++ /dev/null @@ -1,126 +0,0 @@ -package cmd - -import ( - "fmt" - "math" - "slices" - "strings" - - "github.com/urfave/cli/v3" -) - -// This entire file is mostly taken from urfave/cli/v3's source, with the exception of suggestCommand which is -// modified for a nicer error message. - -// jaroDistance is the measure of similarity between two strings. It returns a -// value between 0 and 1, where 1 indicates identical strings and 0 indicates -// completely different strings. -// -// Adapted from https://github.com/xrash/smetrics/blob/5f08fbb34913bc8ab95bb4f2a89a0637ca922666/jaro.go. -func jaroDistance(a, b string) float64 { - if len(a) == 0 && len(b) == 0 { - return 1 - } - if len(a) == 0 || len(b) == 0 { - return 0 - } - - lenA := float64(len(a)) - lenB := float64(len(b)) - hashA := make([]bool, len(a)) - hashB := make([]bool, len(b)) - maxDistance := int(math.Max(0, math.Floor(math.Max(lenA, lenB)/2.0)-1)) - - var matches float64 - for i := 0; i < len(a); i++ { - start := int(math.Max(0, float64(i-maxDistance))) - end := int(math.Min(lenB-1, float64(i+maxDistance))) - - for j := start; j <= end; j++ { - if hashB[j] { - continue - } - if a[i] == b[j] { - hashA[i] = true - hashB[j] = true - matches++ - break - } - } - } - if matches == 0 { - return 0 - } - - var transpositions float64 - var j int - for i := 0; i < len(a); i++ { - if !hashA[i] { - continue - } - for !hashB[j] { - j++ - } - if a[i] != b[j] { - transpositions++ - } - j++ - } - - transpositions /= 2 - return ((matches / lenA) + (matches / lenB) + ((matches - transpositions) / matches)) / 3.0 -} - -// jaroWinkler is more accurate when strings have a common prefix up to a -// defined maximum length. -// -// Adapted from https://github.com/xrash/smetrics/blob/5f08fbb34913bc8ab95bb4f2a89a0637ca922666/jaro-winkler.go. -func jaroWinkler(a, b string) float64 { - const ( - boostThreshold = 0.7 - prefixSize = 4 - ) - jaroDist := jaroDistance(a, b) - if jaroDist <= boostThreshold { - return jaroDist - } - - prefix := int(math.Min(float64(len(a)), math.Min(float64(prefixSize), float64(len(b))))) - - var prefixMatch float64 - for i := 0; i < prefix; i++ { - if a[i] == b[i] { - prefixMatch++ - } else { - break - } - } - return jaroDist + 0.1*prefixMatch*(1.0-jaroDist) -} - -// suggestCommand takes a list of commands and a provided string to suggest a -// command name -func suggestCommand(commands []*cli.Command, provided string) string { - distance := 0.0 - var lineage []*cli.Command - for _, command := range commands { - for _, name := range command.Names() { - newDistance := jaroWinkler(name, provided) - if newDistance > distance { - distance = newDistance - lineage = command.Lineage() - } - } - } - - var parts []string - for _, command := range lineage { - parts = append(parts, command.Name) - } - slices.Reverse(parts) - return fmt.Sprintf("Did you mean '%s'?", strings.Join(parts, " ")) -} - -func init() { - cli.SuggestCommand = suggestCommand -} diff --git a/pkg/cmd/update.go b/pkg/cmd/update.go deleted file mode 100644 index 4a28eaf..0000000 --- a/pkg/cmd/update.go +++ /dev/null @@ -1,406 +0,0 @@ -package cmd - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - - "github.com/urfave/cli/v3" -) - -/* -The update command delegates installation to the mechanism that owns the -running binary. Its control flow is: - - 1. Fetch the newest GitHub release and stop if it matches the current build. - 2. Stop after reporting versions when --check is set. - 3. Classify the executable from its platform, resolved path, and installer - evidence. - 4. Run the matching installer, or print manual instructions when ownership - cannot be proved. - -Homebrew ownership requires both a path inside Homebrew's prefix and an -installed dedalus cask. Direct installs are recognized by their default path, -the marker written by install.sh, or DEDALUS_INSTALL_DIR. Detection checks -Homebrew first because its cask also installs a binary named dedalus. -*/ - -const ( - installScriptURL = "https://raw.githubusercontent.com/dedalus-labs/dedalus-cli/main/scripts/install.sh" - installPS1URL = "https://raw.githubusercontent.com/dedalus-labs/dedalus-cli/main/scripts/install.ps1" - latestReleaseURL = "https://api.github.com/repos/dedalus-labs/dedalus-cli/releases/latest" - defaultUnixBinDir = ".local/bin" - installMarkerFile = ".dedalus-cli-install" -) - -var updateCommand = cli.Command{ - Name: "update", - Usage: "Update the Dedalus CLI", - UsageText: "dedalus update [--check]", - Category: "CLI", - Suggest: true, - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "check", - Usage: "Check for the latest release without installing it.", - }, - }, - Action: handleUpdate, - HideHelpCommand: true, -} - -func init() { - Command.Commands = append(Command.Commands, &updateCommand) -} - -type updateOptions struct { - checkOnly bool -} - -// installMethod identifies the update action selected for the running binary. -type installMethod int - -const ( - installMethodCurl installMethod = iota - installMethodHomebrewCask - installMethodWindows - installMethodUnknown -) - -// updater owns release discovery, install detection, and command execution. -// Function fields isolate operating-system effects so tests can replace them. -type updater struct { - goos string - stdout io.Writer - stderr io.Writer - executable func() (string, error) - lookPath func(string) (string, error) - commandOutput func(context.Context, string, ...string) (string, error) - runCommand func(context.Context, []string, string, ...string) error - httpClient *http.Client - latestURL string -} - -// detectedInstall records the selected action and the executable, when available. -type detectedInstall struct { - method installMethod - exe string -} - -type latestRelease struct { - TagName string `json:"tag_name"` -} - -func handleUpdate(ctx context.Context, c *cli.Command) error { - return newUpdater(c.Root().Writer, c.Root().ErrWriter).update(ctx, updateOptions{ - checkOnly: c.Bool("check"), - }) -} - -func newUpdater(stdout, stderr io.Writer) *updater { - if stdout == nil { - stdout = os.Stdout - } - if stderr == nil { - stderr = os.Stderr - } - u := &updater{ - goos: runtime.GOOS, - stdout: stdout, - stderr: stderr, - executable: os.Executable, - lookPath: exec.LookPath, - httpClient: http.DefaultClient, - latestURL: latestReleaseURL, - } - u.commandOutput = u.defaultCommandOutput - u.runCommand = u.defaultRunCommand - return u -} - -func (u *updater) update(ctx context.Context, opts updateOptions) error { - latest, err := u.latestVersion(ctx) - if err != nil { - return err - } - - current := versionTag(Version) - fmt.Fprintf(u.stdout, "Current version: %s\n", current) - fmt.Fprintf(u.stdout, "Latest version: %s\n", latest) - - if sameVersion(current, latest) { - fmt.Fprintf(u.stdout, "dedalus is already at %s\n", current) - return nil - } - if opts.checkOnly { - return nil - } - - install := u.detectInstall(ctx) - - switch install.method { - case installMethodHomebrewCask: - return u.updateWithHomebrew(ctx) - case installMethodWindows: - return u.updateWithWindowsInstaller(ctx, install.exe, latest) - case installMethodCurl: - return u.updateWithInstallScript(ctx, install.exe, latest) - case installMethodUnknown: - return u.printManualUpdate() - default: - return fmt.Errorf("unknown install method %d", install.method) - } -} - -func (u *updater) latestVersion(ctx context.Context) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.latestURL, nil) - if err != nil { - return "", fmt.Errorf("build latest release request: %w", err) - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", fmt.Sprintf("Dedalus/CLI %s", Version)) - - resp, err := u.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("fetch latest release: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("fetch latest release: got %s", resp.Status) - } - - var release latestRelease - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return "", fmt.Errorf("decode latest release: %w", err) - } - tag := strings.TrimSpace(release.TagName) - if tag == "" { - return "", fmt.Errorf("fetch latest release: missing tag_name") - } - return versionTag(tag), nil -} - -// detectInstall returns unknown unless one install mechanism has enough evidence. -// The checks are ordered from strongest ownership evidence to weakest. -func (u *updater) detectInstall(ctx context.Context) detectedInstall { - exe, err := u.executablePath() - if err != nil { - if u.goos == "windows" { - return detectedInstall{method: installMethodWindows} - } - return detectedInstall{method: installMethodUnknown} - } - - if u.goos == "windows" { - return detectedInstall{method: installMethodWindows, exe: exe} - } - if u.isHomebrewCask(ctx, exe) { - return detectedInstall{method: installMethodHomebrewCask, exe: exe} - } - if u.isLikelyCurlInstall(exe) { - return detectedInstall{method: installMethodCurl, exe: exe} - } - return detectedInstall{method: installMethodUnknown, exe: exe} -} - -// isHomebrewCask requires the executable path and Homebrew's cask database to -// agree. Merely finding brew on PATH does not establish ownership. -func (u *updater) isHomebrewCask(ctx context.Context, exe string) bool { - if u.goos != "darwin" { - return false - } - brew, err := u.lookPath("brew") - if err != nil { - return false - } - prefix, err := u.commandOutput(ctx, brew, "--prefix") - if err != nil || !pathWithin(exe, strings.TrimSpace(prefix)) { - return false - } - return u.commandSucceeds(ctx, brew, "list", "--cask", "--versions", "dedalus") -} - -// isLikelyCurlInstall recognizes direct installer layouts. The default path -// covers installs created before the marker existed. The marker persists custom -// install directories; the environment recognizes an active installer override. -func (u *updater) isLikelyCurlInstall(exe string) bool { - if u.goos == "windows" { - return false - } - if filepath.Base(exe) != "dedalus" { - return false - } - home, err := os.UserHomeDir() - if err == nil && pathWithin(exe, filepath.Join(home, defaultUnixBinDir)) { - return true - } - if hasInstallScriptMarker(exe) { - return true - } - installDir := os.Getenv("DEDALUS_INSTALL_DIR") - return installDir != "" && pathWithin(exe, installDir) -} - -func (u *updater) updateWithHomebrew(ctx context.Context) error { - brew, err := u.lookPath("brew") - if err != nil { - return fmt.Errorf("find brew: %w", err) - } - args := []string{"upgrade", "--cask", "dedalus"} - fmt.Fprintf(u.stdout, "Running: brew %s\n", strings.Join(args, " ")) - return u.runCommand(ctx, nil, brew, args...) -} - -// updateWithInstallScript pins the published installer to the executable's -// current directory and to the release the version check reported, so a custom -// direct install is updated in place. -func (u *updater) updateWithInstallScript(ctx context.Context, exe, version string) error { - env := []string{"DEDALUS_INSTALL_DIR=" + filepath.Dir(exe), "DEDALUS_VERSION=" + version} - fmt.Fprintf(u.stdout, "Running installer with %s\n", strings.Join(env, " ")) - return u.runCommand(ctx, env, "bash", "-c", "curl -fsSL "+installScriptURL+" | bash") -} - -// updateWithWindowsInstaller runs the published PowerShell installer pinned to -// the executable's directory and to the release the version check reported. -// Windows locks a running exe against overwrite but allows renaming it, so -// install.ps1 moves the running dedalus.exe aside before installing; the new -// binary applies from the next invocation. PATH edits are disabled because an -// update must not mutate the user's PATH registry. Without a resolved -// executable path there is no directory to pin, so print the manual command -// instead. -func (u *updater) updateWithWindowsInstaller(ctx context.Context, exe, version string) error { - if exe == "" { - fmt.Fprintln(u.stdout, "Could not locate the running dedalus.exe.") - fmt.Fprintln(u.stdout, "Run this from PowerShell:") - fmt.Fprintf(u.stdout, " irm %s | iex\n", installPS1URL) - return nil - } - - env := []string{ - "DEDALUS_INSTALL_DIR=" + filepath.Dir(exe), - "DEDALUS_NO_MODIFY_PATH=1", - "DEDALUS_VERSION=" + version, - } - fmt.Fprintf(u.stdout, "Running installer with %s\n", strings.Join(env, " ")) - err := u.runCommand(ctx, env, "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "irm "+installPS1URL+" | iex") - if err != nil { - fmt.Fprintln(u.stderr, "The installer failed. Run it from a new PowerShell session:") - fmt.Fprintf(u.stderr, " %sirm %s | iex\n", powerShellEnvPrefix(env), installPS1URL) - return fmt.Errorf("run windows installer: %w", err) - } - fmt.Fprintln(u.stdout, "The new version takes effect from your next command.") - return nil -} - -func (u *updater) printManualUpdate() error { - fmt.Fprintln(u.stdout, "Could not determine how this dedalus binary was installed.") - fmt.Fprintln(u.stdout, "Update with the package manager or installer you originally used.") - return nil -} - -// executablePath resolves symlinks when possible so detection inspects the -// installation path instead of the path of a launcher or shim. -func (u *updater) executablePath() (string, error) { - exe, err := u.executable() - if err != nil { - return "", fmt.Errorf("locate current executable: %w", err) - } - if resolved, err := filepath.EvalSymlinks(exe); err == nil { - exe = resolved - } - return exe, nil -} - -func (u *updater) commandSucceeds(ctx context.Context, name string, args ...string) bool { - _, err := u.commandOutput(ctx, name, args...) - return err == nil -} - -func (u *updater) defaultCommandOutput(ctx context.Context, name string, args ...string) (string, error) { - c := exec.CommandContext(ctx, name, args...) - out, err := c.Output() - return string(out), err -} - -func (u *updater) defaultRunCommand(ctx context.Context, env []string, name string, args ...string) error { - c := exec.CommandContext(ctx, name, args...) - if len(env) > 0 { - c.Env = append(os.Environ(), env...) - } - c.Stdin = os.Stdin - c.Stdout = u.stdout - c.Stderr = u.stderr - return c.Run() -} - -func versionTag(version string) string { - version = strings.TrimSpace(version) - if version == "" || strings.HasPrefix(version, "v") { - return version - } - return "v" + version -} - -func sameVersion(a, b string) bool { - return strings.TrimPrefix(versionTag(a), "v") == strings.TrimPrefix(versionTag(b), "v") -} - -// pathWithin uses path components instead of string prefixes, so directories -// such as /opt/homebrew-old cannot match /opt/homebrew. -func pathWithin(path, dir string) bool { - if strings.TrimSpace(dir) == "" { - return false - } - absPath, ok := comparablePath(path) - if !ok { - return false - } - absDir, ok := comparablePath(dir) - if !ok { - return false - } - rel, err := filepath.Rel(absDir, absPath) - if err != nil { - return false - } - return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))) -} - -func comparablePath(path string) (string, bool) { - abs, err := filepath.Abs(path) - if err != nil { - return "", false - } - if resolved, err := filepath.EvalSymlinks(abs); err == nil { - abs = resolved - } - return abs, true -} - -func hasInstallScriptMarker(exe string) bool { - info, err := os.Stat(filepath.Join(filepath.Dir(exe), installMarkerFile)) - return err == nil && !info.IsDir() -} - -func powerShellSingleQuoted(value string) string { - return strings.ReplaceAll(value, "'", "''") -} - -// powerShellEnvPrefix renders KEY=VALUE pairs as `$env:KEY = 'VALUE'; ` so a -// printed manual command reproduces the exact environment of the failed run. -func powerShellEnvPrefix(env []string) string { - var b strings.Builder - for _, kv := range env { - k, v, _ := strings.Cut(kv, "=") - fmt.Fprintf(&b, "$env:%s = '%s'; ", k, powerShellSingleQuoted(v)) - } - return b.String() -} diff --git a/pkg/cmd/update_test.go b/pkg/cmd/update_test.go deleted file mode 100644 index 4a82d14..0000000 --- a/pkg/cmd/update_test.go +++ /dev/null @@ -1,386 +0,0 @@ -package cmd - -import ( - "bytes" - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "slices" - "strings" - "testing" -) - -func TestLatestVersion(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - t.Errorf("latestVersion request method = %q, want GET", r.Method) - } - if r.URL.Path != "/latest" { - t.Errorf("latestVersion request path = %q, want /latest", r.URL.Path) - } - if got := r.Header.Get("Accept"); got != "application/vnd.github+json" { - t.Errorf("latestVersion Accept header = %q, want application/vnd.github+json", got) - } - if got := r.Header.Get("User-Agent"); got == "" { - t.Error("latestVersion User-Agent header is empty") - } - io.WriteString(w, `{"tag_name":"v9.8.7"}`) - })) - defer server.Close() - - updater := newTestUpdater(t) - updater.latestURL = server.URL + "/latest" - - got, err := updater.latestVersion(context.Background()) - if err != nil { - t.Fatalf("latestVersion() returned unexpected error: %v", err) - } - if want := "v9.8.7"; got != want { - t.Errorf("latestVersion() = %q, want %q", got, want) - } -} - -func TestUpdateCheckOnly(t *testing.T) { - t.Parallel() - - server := latestVersionServer(t, "v9.9.9") - defer server.Close() - - var stdout bytes.Buffer - updater := newTestUpdater(t) - updater.stdout = &stdout - updater.latestURL = server.URL + "/latest" - updater.runCommand = func(context.Context, []string, string, ...string) error { - t.Fatal("update --check should not run commands") - return nil - } - - if err := updater.update(context.Background(), updateOptions{checkOnly: true}); err != nil { - t.Fatalf("update(checkOnly: true) returned unexpected error: %v", err) - } - - got := stdout.String() - for _, want := range []string{"Current version: v" + Version, "Latest version: v9.9.9"} { - if !strings.Contains(got, want) { - t.Errorf("update(checkOnly: true) output = %q, want substring %q", got, want) - } - } -} - -func TestUpdateSkipsCurrentVersion(t *testing.T) { - t.Parallel() - - server := latestVersionServer(t, "v"+Version) - defer server.Close() - - var stdout bytes.Buffer - updater := newTestUpdater(t) - updater.stdout = &stdout - updater.latestURL = server.URL + "/latest" - updater.runCommand = func(context.Context, []string, string, ...string) error { - t.Fatal("update should not run commands when current version is latest") - return nil - } - - if err := updater.update(context.Background(), updateOptions{}); err != nil { - t.Fatalf("update() returned unexpected error: %v", err) - } - if got, want := stdout.String(), "dedalus is already at v"+Version; !strings.Contains(got, want) { - t.Errorf("update() output = %q, want substring %q", got, want) - } -} - -func TestUpdateHomebrewCask(t *testing.T) { - t.Parallel() - - prefix := t.TempDir() - exe := filepath.Join(prefix, "bin", "dedalus") - writeExecutable(t, exe) - - server := latestVersionServer(t, "v9.9.9") - defer server.Close() - - var ranName string - var ranArgs []string - updater := newTestUpdater(t) - updater.goos = "darwin" - updater.executable = func() (string, error) { return exe, nil } - updater.latestURL = server.URL + "/latest" - updater.lookPath = func(name string) (string, error) { - if name == "brew" { - return "/opt/homebrew/bin/brew", nil - } - return "", errors.New("not found") - } - updater.commandOutput = func(_ context.Context, name string, args ...string) (string, error) { - switch { - case name == "/opt/homebrew/bin/brew" && slices.Equal(args, []string{"--prefix"}): - return prefix + "\n", nil - case name == "/opt/homebrew/bin/brew" && slices.Equal(args, []string{"list", "--cask", "--versions", "dedalus"}): - return "dedalus 9.8.0\n", nil - default: - return "", errors.New("unexpected command") - } - } - updater.runCommand = func(_ context.Context, _ []string, name string, args ...string) error { - ranName = name - ranArgs = slices.Clone(args) - return nil - } - - if err := updater.update(context.Background(), updateOptions{}); err != nil { - t.Fatalf("update() returned unexpected error: %v", err) - } - if ranName != "/opt/homebrew/bin/brew" { - t.Errorf("update() command name = %q, want /opt/homebrew/bin/brew", ranName) - } - if want := []string{"upgrade", "--cask", "dedalus"}; !slices.Equal(ranArgs, want) { - t.Errorf("update() command args = %v, want %v", ranArgs, want) - } -} - -func TestUpdateCurlInstall(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - exe := filepath.Join(home, ".local", "bin", "dedalus") - writeExecutable(t, exe) - - server := latestVersionServer(t, "v9.9.9") - defer server.Close() - - var ranEnv []string - var ranName string - var ranArgs []string - updater := newTestUpdater(t) - updater.executable = func() (string, error) { return exe, nil } - updater.latestURL = server.URL + "/latest" - updater.runCommand = func(_ context.Context, env []string, name string, args ...string) error { - ranEnv = slices.Clone(env) - ranName = name - ranArgs = slices.Clone(args) - return nil - } - - if err := updater.update(context.Background(), updateOptions{}); err != nil { - t.Fatalf("update() returned unexpected error: %v", err) - } - resolvedExe, err := filepath.EvalSymlinks(exe) - if err != nil { - t.Fatalf("EvalSymlinks(%q) returned unexpected error: %v", exe, err) - } - if want := []string{"DEDALUS_INSTALL_DIR=" + filepath.Dir(resolvedExe), "DEDALUS_VERSION=v9.9.9"}; !slices.Equal(ranEnv, want) { - t.Errorf("update() env = %v, want %v", ranEnv, want) - } - if ranName != "bash" { - t.Errorf("update() command name = %q, want bash", ranName) - } - if want := []string{"-c", "curl -fsSL " + installScriptURL + " | bash"}; !slices.Equal(ranArgs, want) { - t.Errorf("update() command args = %v, want %v", ranArgs, want) - } -} - -func TestUpdateCustomCurlInstallWithMarker(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - exe := filepath.Join(t.TempDir(), "bin", "dedalus") - writeExecutable(t, exe) - writeInstallMarker(t, filepath.Dir(exe)) - - server := latestVersionServer(t, "v9.9.9") - defer server.Close() - - var ranEnv []string - var ranName string - updater := newTestUpdater(t) - updater.executable = func() (string, error) { return exe, nil } - updater.latestURL = server.URL + "/latest" - updater.runCommand = func(_ context.Context, env []string, name string, args ...string) error { - ranEnv = slices.Clone(env) - ranName = name - return nil - } - - if err := updater.update(context.Background(), updateOptions{}); err != nil { - t.Fatalf("update() returned unexpected error: %v", err) - } - resolvedExe, err := filepath.EvalSymlinks(exe) - if err != nil { - t.Fatalf("EvalSymlinks(%q) returned unexpected error: %v", exe, err) - } - if want := []string{"DEDALUS_INSTALL_DIR=" + filepath.Dir(resolvedExe), "DEDALUS_VERSION=v9.9.9"}; !slices.Equal(ranEnv, want) { - t.Errorf("update() env = %v, want %v", ranEnv, want) - } - if ranName != "bash" { - t.Errorf("update() command name = %q, want bash", ranName) - } -} - -func TestWindowsUpdateRunsInstaller(t *testing.T) { - t.Parallel() - - server := latestVersionServer(t, "v9.9.9") - defer server.Close() - - exe := filepath.Join("C:", "Users", "me", ".local", "bin", "dedalus.exe") - var ranEnv []string - var ranName string - var ranArgs []string - updater := newTestUpdater(t) - updater.goos = "windows" - updater.latestURL = server.URL + "/latest" - updater.executable = func() (string, error) { return exe, nil } - updater.runCommand = func(_ context.Context, env []string, name string, args ...string) error { - ranEnv = slices.Clone(env) - ranName = name - ranArgs = slices.Clone(args) - return nil - } - - if err := updater.update(context.Background(), updateOptions{}); err != nil { - t.Fatalf("update() returned unexpected error: %v", err) - } - if want := []string{"DEDALUS_INSTALL_DIR=" + filepath.Dir(exe), "DEDALUS_NO_MODIFY_PATH=1", "DEDALUS_VERSION=v9.9.9"}; !slices.Equal(ranEnv, want) { - t.Errorf("update() env = %v, want %v", ranEnv, want) - } - if ranName != "powershell" { - t.Errorf("update() command name = %q, want powershell", ranName) - } - if want := []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "irm " + installPS1URL + " | iex"}; !slices.Equal(ranArgs, want) { - t.Errorf("update() command args = %v, want %v", ranArgs, want) - } -} - -func TestWindowsUpdateInstallerFailureShowsManualCommand(t *testing.T) { - t.Parallel() - - server := latestVersionServer(t, "v9.9.9") - defer server.Close() - - var stderr bytes.Buffer - updater := newTestUpdater(t) - updater.goos = "windows" - updater.stderr = &stderr - updater.latestURL = server.URL + "/latest" - updater.executable = func() (string, error) { - return filepath.Join("C:", "Users", "me", ".local", "bin", "dedalus.exe"), nil - } - updater.runCommand = func(context.Context, []string, string, ...string) error { - return errors.New("exit status 1") - } - - if err := updater.update(context.Background(), updateOptions{}); err == nil { - t.Fatal("update() did not surface installer failure") - } - got := stderr.String() - for _, want := range []string{"$env:DEDALUS_INSTALL_DIR", "$env:DEDALUS_NO_MODIFY_PATH = '1'", "$env:DEDALUS_VERSION = 'v9.9.9'", "install.ps1"} { - if !strings.Contains(got, want) { - t.Errorf("installer failure output = %q, want substring %q", got, want) - } - } -} - -func TestWindowsUpdateWithoutExecutablePrintsCommand(t *testing.T) { - t.Parallel() - - server := latestVersionServer(t, "v9.9.9") - defer server.Close() - - var stdout bytes.Buffer - updater := newTestUpdater(t) - updater.goos = "windows" - updater.stdout = &stdout - updater.latestURL = server.URL + "/latest" - updater.executable = func() (string, error) { return "", errors.New("no executable") } - updater.runCommand = func(context.Context, []string, string, ...string) error { - t.Fatal("unknown executable location should not run the installer") - return nil - } - - if err := updater.update(context.Background(), updateOptions{}); err != nil { - t.Fatalf("update() returned unexpected error: %v", err) - } - if got, want := stdout.String(), "irm "+installPS1URL+" | iex"; !strings.Contains(got, want) { - t.Errorf("windows update output = %q, want substring %q", got, want) - } -} - -func TestUnknownInstallPrintsManualInstructions(t *testing.T) { - t.Parallel() - - exe := filepath.Join(t.TempDir(), "dedalus") - server := latestVersionServer(t, "v9.9.9") - defer server.Close() - - var stdout bytes.Buffer - updater := newTestUpdater(t) - updater.stdout = &stdout - updater.executable = func() (string, error) { return exe, nil } - updater.latestURL = server.URL + "/latest" - - if err := updater.update(context.Background(), updateOptions{}); err != nil { - t.Fatalf("update() returned unexpected error: %v", err) - } - if got, want := stdout.String(), "Could not determine how this dedalus binary was installed."; !strings.Contains(got, want) { - t.Errorf("unknown install output = %q, want substring %q", got, want) - } -} - -func newTestUpdater(t *testing.T) *updater { - t.Helper() - - updater := newUpdater(io.Discard, io.Discard) - updater.goos = "linux" - updater.executable = func() (string, error) { - return filepath.Join(t.TempDir(), "dedalus"), nil - } - updater.lookPath = func(string) (string, error) { - return "", errors.New("not found") - } - updater.commandOutput = func(context.Context, string, ...string) (string, error) { - return "", errors.New("not found") - } - updater.runCommand = func(context.Context, []string, string, ...string) error { - return nil - } - return updater -} - -func latestVersionServer(t *testing.T, tag string) *httptest.Server { - t.Helper() - - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/latest" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - io.WriteString(w, `{"tag_name":"`+tag+`"}`) - })) -} - -func writeExecutable(t *testing.T, path string) { - t.Helper() - - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatalf("MkdirAll(%q) returned unexpected error: %v", filepath.Dir(path), err) - } - if err := os.WriteFile(path, []byte("old"), 0755); err != nil { - t.Fatalf("WriteFile(%q) returned unexpected error: %v", path, err) - } -} - -func writeInstallMarker(t *testing.T, dir string) { - t.Helper() - - if err := os.WriteFile(filepath.Join(dir, installMarkerFile), []byte("method=install-script\n"), 0644); err != nil { - t.Fatalf("WriteFile(%q) returned unexpected error: %v", installMarkerFile, err) - } -} diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go deleted file mode 100644 index c1f2884..0000000 --- a/pkg/cmd/version.go +++ /dev/null @@ -1,5 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -const Version = "0.6.0" // x-release-please-version diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..7028752 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,434 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + ansis: + specifier: ^4.3.0 + version: 4.3.1 + commander: + specifier: ^14.0.3 + version: 14.0.3 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@dedalus-labs/hollywood': + specifier: 0.0.5 + version: 0.0.5 + '@types/node': + specifier: ^20.17.0 + version: 20.19.43 + typescript: + specifier: ^6.0.0 + version: 6.0.3 + +packages: + + '@actions/core@3.0.1': + resolution: {integrity: sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==} + + '@actions/exec@3.0.0': + resolution: {integrity: sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==} + + '@actions/expressions@0.3.61': + resolution: {integrity: sha512-Ig+tXELvTjlqxQAWBQnLNn2dPpNraLMdSnT+vDzDPmJtbNUhIAH/Z9kVT8pZ8UtJi9vUGs8hwHE8JFJmbOnLEA==} + engines: {node: '>= 20'} + + '@actions/http-client@4.0.1': + resolution: {integrity: sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==} + + '@actions/io@3.0.2': + resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} + + '@actions/workflow-parser@0.3.61': + resolution: {integrity: sha512-NBjJrbwwrEN3LSN0NAISLpCWQLcPSBjmAIkLXHgjGX8Yql4PiD33ifSAvr9sIMU9uIs8wZiGUnvo9yPIMIfKdg==} + engines: {node: '>= 20'} + + '@dedalus-labs/hollywood@0.0.5': + resolution: {integrity: sha512-9UlnGlYuyTCDE8PJzIc6HOo6VFjDxrYnh68VsFGjbDYOye1hXcpjPSESdg2vnmgSZHuWx9lbr5eKtQP3LdsU1Q==} + engines: {node: '>=20'} + hasBin: true + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@octokit/openapi-types@28.0.0': + resolution: {integrity: sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + cronstrue@2.59.0: + resolution: {integrity: sha512-YKGmAy84hKH+hHIIER07VCAHf9u0Ldelx1uU6EBxsRPDXIA1m5fsKmJfyC3xBhw6cVC/1i83VdbL4PvepTrt8A==} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@6.28.1: + resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} + engines: {node: '>=18.17'} + + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} + engines: {node: '>= 14.6'} + hasBin: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@actions/core@3.0.1': + dependencies: + '@actions/exec': 3.0.0 + '@actions/http-client': 4.0.1 + + '@actions/exec@3.0.0': + dependencies: + '@actions/io': 3.0.2 + + '@actions/expressions@0.3.61': {} + + '@actions/http-client@4.0.1': + dependencies: + tunnel: 0.0.6 + undici: 6.28.1 + + '@actions/io@3.0.2': {} + + '@actions/workflow-parser@0.3.61': + dependencies: + '@actions/expressions': 0.3.61 + cronstrue: 2.59.0 + yaml: 2.9.0 + + '@dedalus-labs/hollywood@0.0.5': + dependencies: + '@actions/core': 3.0.1 + '@actions/exec': 3.0.0 + '@actions/expressions': 0.3.61 + '@actions/workflow-parser': 0.3.61 + '@octokit/openapi-types': 28.0.0 + esbuild: 0.28.1 + yaml: 2.8.4 + zod: 4.4.3 + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@octokit/openapi-types@28.0.0': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + ansis@4.3.1: {} + + commander@14.0.3: {} + + cronstrue@2.59.0: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + tunnel@0.0.6: {} + + typescript@6.0.3: {} + + undici-types@6.21.0: {} + + undici@6.28.1: {} + + yaml@2.8.4: {} + + yaml@2.9.0: {} + + zod@4.4.3: {} diff --git a/release-please-config.json b/release-please-config.json index ef95266..2e78b2e 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,16 +1,16 @@ { "packages": { - ".": {} + ".": { + "release-type": "node", + "extra-files": ["src/commands/index.ts", "src/sdk/version.ts"] + } }, - "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", - "include-v-in-tag": true, - "include-component-in-tag": false, - "versioning": "prerelease", - "prerelease": true, "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": false, - "pull-request-header": "Automated Release PR", + "include-component-in-tag": false, "pull-request-title-pattern": "release: ${version}", + "pull-request-header": "Automated Release PR", + "pull-request-footer": "The semver version number is based on included commit messages. To release a specific version,\nedit this pull request title to `release: 1.2.3` — Scalar re-creates this pull request at that\nversion, so wait for the `Release PR version` check to pass before merging.\n\nBrought to you by [Scalar](https://scalar.com)", "changelog-sections": [ { "type": "feat", @@ -36,35 +36,9 @@ "type": "docs", "section": "Documentation" }, - { - "type": "style", - "section": "Styles" - }, { "type": "refactor", "section": "Refactors" - }, - { - "type": "test", - "section": "Tests", - "hidden": true - }, - { - "type": "build", - "section": "Build System" - }, - { - "type": "ci", - "section": "Continuous Integration", - "hidden": true } - ], - "reviewers": [ - "@windsornguyen" - ], - "release-type": "simple", - "extra-files": [ - "pkg/cmd/version.go", - "README.md" ] -} \ No newline at end of file +} diff --git a/scalar-sdk.manifest.json b/scalar-sdk.manifest.json new file mode 100644 index 0000000..0bcba7b --- /dev/null +++ b/scalar-sdk.manifest.json @@ -0,0 +1,8004 @@ +{ + "name": "Dedalus", + "slug": "dedalus", + "generatorVersion": "0.33.3", + "servers": [ + "https://dcs.dedaluslabs.ai" + ], + "environments": { + "production": "https://dcs.dedaluslabs.ai" + }, + "environmentOrder": [ + "production" + ], + "auth": [ + "apiKey", + "bearer" + ], + "authDetails": [ + { + "kind": "apiKey", + "id": "ApiKeyAuth", + "in": "header", + "paramName": "x-api-key" + }, + { + "kind": "bearer", + "id": "BearerAuth", + "bearerFormat": "Dedalus API key or delegated access token" + } + ], + "clientHeaderParams": [], + "schemas": [ + { + "name": "artifact_ref", + "source": "artifact_ref", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "artifact_id", + "publicName": "artifact_id", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "name", + "publicName": "name", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "execution_create_params", + "source": "execution_create_params", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "command", + "publicName": "command", + "required": true, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "array", + "items": { + "kind": "primitive", + "type": "string" + } + }, + { + "kind": "null" + } + ] + } + }, + { + "name": "cwd", + "publicName": "cwd", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "env", + "publicName": "env", + "required": false, + "deprecated": false, + "type": { + "kind": "record", + "value": { + "kind": "primitive", + "type": "string" + }, + "propertyNames": { + "kind": "primitive", + "type": "string" + } + } + }, + { + "name": "stdin", + "publicName": "stdin", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "timeout_ms", + "publicName": "timeout_ms", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "create_params", + "source": "create_params", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "autosleep", + "publicName": "autosleep", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "memory_mib", + "publicName": "memory_mib", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64", + "validation": { + "exclusiveMinimum": 0 + } + } + }, + { + "name": "storage_gib", + "publicName": "storage_gib", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64", + "validation": { + "exclusiveMinimum": 0 + } + } + }, + { + "name": "vcpu", + "publicName": "vcpu", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "number", + "format": "double", + "validation": { + "exclusiveMinimum": 0 + } + } + } + ], + "additionalProperties": false + } + }, + { + "name": "ssh_session_create_params", + "source": "ssh_session_create_params", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "public_key", + "publicName": "public_key", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "error_detail", + "source": "error_detail", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "location", + "publicName": "location", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "message", + "publicName": "message", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "value", + "publicName": "value", + "required": false, + "deprecated": false, + "type": { + "kind": "unknown" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "error_model", + "source": "error_model", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "detail", + "publicName": "detail", + "required": false, + "deprecated": false, + "extensions": { + "source": [ + "examples" + ], + "examples": "Property foo is required but is missing." + }, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "errors", + "publicName": "errors", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "array", + "items": { + "kind": "ref", + "name": "error_detail" + } + }, + { + "kind": "null" + } + ] + } + }, + { + "name": "instance", + "publicName": "instance", + "required": false, + "deprecated": false, + "extensions": { + "source": [ + "examples" + ], + "examples": "https://example.com/error-log/abc123" + }, + "type": { + "kind": "primitive", + "type": "string", + "format": "uri" + } + }, + { + "name": "status", + "publicName": "status", + "required": false, + "deprecated": false, + "extensions": { + "source": [ + "examples" + ], + "examples": 400 + }, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "title", + "publicName": "title", + "required": false, + "deprecated": false, + "extensions": { + "source": [ + "examples" + ], + "examples": "Bad Request" + }, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "type", + "publicName": "type", + "required": false, + "deprecated": false, + "extensions": { + "source": [ + "examples" + ], + "examples": "https://example.com/errors/example" + }, + "type": { + "kind": "primitive", + "type": "string", + "format": "uri" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "execution_event", + "source": "execution_event", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "at", + "publicName": "at", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "date-time" + } + }, + { + "name": "chunk", + "publicName": "chunk", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "error_code", + "publicName": "error_code", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "error_message", + "publicName": "error_message", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "exit_code", + "publicName": "exit_code", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "sequence", + "publicName": "sequence", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "signal", + "publicName": "signal", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "status", + "publicName": "status", + "required": false, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "wake_in_progress", + "queued", + "running", + "succeeded", + "failed", + "cancelled", + "expired" + ], + "names": [ + "WakeInProgress", + "Queued", + "Running", + "Succeeded", + "Failed", + "Cancelled", + "Expired" + ], + "deprecations": [ + false, + false, + false, + false, + false, + false, + false + ] + } + }, + { + "name": "type", + "publicName": "type", + "required": true, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "lifecycle", + "stdout", + "stderr" + ], + "names": [ + "Lifecycle", + "Stdout", + "Stderr" + ], + "deprecations": [ + false, + false, + false + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "execution_events", + "source": "execution_events", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "items", + "publicName": "items", + "required": true, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "array", + "items": { + "kind": "ref", + "name": "execution_event" + } + }, + { + "kind": "null" + } + ] + } + }, + { + "name": "next_cursor", + "publicName": "next_cursor", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "execution_list", + "source": "execution_list", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "items", + "publicName": "items", + "required": true, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "array", + "items": { + "kind": "ref", + "name": "execution" + } + }, + { + "kind": "null" + } + ] + } + }, + { + "name": "next_cursor", + "publicName": "next_cursor", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "status", + "source": "status", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "capture", + "publicName": "capture", + "required": true, + "deprecated": false, + "type": { + "kind": "ref", + "name": "machine_execution_log_capture" + } + }, + { + "name": "code", + "publicName": "code", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "execution_id", + "publicName": "execution_id", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "publication", + "publicName": "publication", + "required": false, + "deprecated": false, + "type": { + "kind": "ref", + "name": "publication" + } + }, + { + "name": "stream_id", + "publicName": "stream_id", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "execution_output", + "source": "execution_output", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "execution_id", + "publicName": "execution_id", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "stderr", + "publicName": "stderr", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "stderr_bytes", + "publicName": "stderr_bytes", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "stderr_truncated", + "publicName": "stderr_truncated", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "boolean" + } + }, + { + "name": "stdout", + "publicName": "stdout", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "stdout_bytes", + "publicName": "stdout_bytes", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "stdout_truncated", + "publicName": "stdout_truncated", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "boolean" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "execution", + "source": "execution", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "artifacts", + "publicName": "artifacts", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "array", + "items": { + "kind": "ref", + "name": "artifact_ref" + } + }, + { + "kind": "null" + } + ] + } + }, + { + "name": "command", + "publicName": "command", + "required": true, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "array", + "items": { + "kind": "primitive", + "type": "string" + } + }, + { + "kind": "null" + } + ] + } + }, + { + "name": "completed_at", + "publicName": "completed_at", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "date-time" + } + }, + { + "name": "created_at", + "publicName": "created_at", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "date-time" + } + }, + { + "name": "creation_request_id", + "publicName": "creation_request_id", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "creation_trace_id", + "publicName": "creation_trace_id", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "cwd", + "publicName": "cwd", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "env_keys", + "publicName": "env_keys", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "array", + "items": { + "kind": "primitive", + "type": "string" + } + }, + { + "kind": "null" + } + ] + } + }, + { + "name": "error_code", + "publicName": "error_code", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "error_message", + "publicName": "error_message", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "execution_id", + "publicName": "execution_id", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "exit_code", + "publicName": "exit_code", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "expires_at", + "publicName": "expires_at", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "date-time" + } + }, + { + "name": "log_capture", + "publicName": "log_capture", + "required": true, + "deprecated": false, + "type": { + "kind": "ref", + "name": "machine_execution_log_capture" + } + }, + { + "name": "machine_id", + "publicName": "machine_id", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "uuid" + } + }, + { + "name": "retry_after_ms", + "publicName": "retry_after_ms", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "signal", + "publicName": "signal", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "started_at", + "publicName": "started_at", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "date-time" + } + }, + { + "name": "status", + "publicName": "status", + "required": true, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "wake_in_progress", + "queued", + "running", + "succeeded", + "failed", + "cancelled", + "expired" + ], + "names": [ + "WakeInProgress", + "Queued", + "Running", + "Succeeded", + "Failed", + "Cancelled", + "Expired" + ], + "deprecations": [ + false, + false, + false, + false, + false, + false, + false + ] + } + }, + { + "name": "stderr_bytes", + "publicName": "stderr_bytes", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "stderr_truncated", + "publicName": "stderr_truncated", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "boolean" + } + }, + { + "name": "stdout_bytes", + "publicName": "stdout_bytes", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "stdout_truncated", + "publicName": "stdout_truncated", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "boolean" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "machine", + "source": "machine", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "autosleep_seconds", + "publicName": "autosleep_seconds", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64", + "validation": { + "minimum": 0, + "maximum": 9223372036 + } + } + }, + { + "name": "desired_state", + "publicName": "desired_state", + "required": true, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "running", + "sleeping", + "destroyed" + ], + "names": [ + "Running", + "Sleeping", + "Destroyed" + ], + "deprecations": [ + false, + false, + false + ] + } + }, + { + "name": "machine_id", + "publicName": "machine_id", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "uuid" + } + }, + { + "name": "memory_mib", + "publicName": "memory_mib", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "phase", + "publicName": "phase", + "required": true, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "accepted", + "placement_pending", + "starting", + "running", + "stopping", + "sleeping", + "destroying", + "destroyed", + "failed" + ], + "names": [ + "Accepted", + "PlacementPending", + "Starting", + "Running", + "Stopping", + "Sleeping", + "Destroying", + "Destroyed", + "Failed" + ], + "deprecations": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + } + }, + { + "name": "storage_gib", + "publicName": "storage_gib", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "vcpu", + "publicName": "vcpu", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "number", + "format": "double" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "lifecycle_status", + "source": "lifecycle_status", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "last_error", + "publicName": "last_error", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "last_progress_at", + "publicName": "last_progress_at", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "date-time" + } + }, + { + "name": "last_transition_at", + "publicName": "last_transition_at", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "date-time" + } + }, + { + "name": "memory_assigned_mib", + "publicName": "memory_assigned_mib", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64", + "validation": { + "minimum": 1 + } + } + }, + { + "name": "memory_configured_mib", + "publicName": "memory_configured_mib", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64", + "validation": { + "minimum": 1 + } + } + }, + { + "name": "memory_last_autoresized_at", + "publicName": "memory_last_autoresized_at", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "date-time" + } + }, + { + "name": "memory_resize_state", + "publicName": "memory_resize_state", + "required": false, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "stable", + "error", + "pending_capacity" + ], + "names": [ + "Stable", + "Error", + "PendingCapacity" + ], + "deprecations": [ + false, + false, + false + ] + } + }, + { + "name": "memory_target_mib", + "publicName": "memory_target_mib", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64", + "validation": { + "minimum": 1 + } + } + }, + { + "name": "phase", + "publicName": "phase", + "required": true, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "accepted", + "placement_pending", + "starting", + "running", + "stopping", + "sleeping", + "destroying", + "destroyed", + "failed" + ], + "names": [ + "Accepted", + "PlacementPending", + "Starting", + "Running", + "Stopping", + "Sleeping", + "Destroying", + "Destroyed", + "Failed" + ], + "deprecations": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + } + }, + { + "name": "reason", + "publicName": "reason", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "retryable", + "publicName": "retryable", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "boolean" + } + }, + { + "name": "revision", + "publicName": "revision", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "machine_detail_response", + "source": "machine_detail_response", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "autosleep_seconds", + "publicName": "autosleep_seconds", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64", + "validation": { + "minimum": 0, + "maximum": 9223372036 + } + } + }, + { + "name": "desired_state", + "publicName": "desired_state", + "required": true, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "running", + "sleeping", + "destroyed" + ], + "names": [ + "Running", + "Sleeping", + "Destroyed" + ], + "deprecations": [ + false, + false, + false + ] + } + }, + { + "name": "machine_id", + "publicName": "machine_id", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "uuid" + } + }, + { + "name": "memory_mib", + "publicName": "memory_mib", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "status", + "publicName": "status", + "required": true, + "deprecated": false, + "type": { + "kind": "ref", + "name": "lifecycle_status" + } + }, + { + "name": "storage_gib", + "publicName": "storage_gib", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "vcpu", + "publicName": "vcpu", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "number", + "format": "double" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "machine_execution_log_capture", + "source": "machine_execution_log_capture", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "code", + "publicName": "code", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "lost_bytes", + "publicName": "lost_bytes", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "validation": { + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 20 + } + } + }, + { + "name": "state", + "publicName": "state", + "required": false, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "pending", + "incomplete", + "unavailable" + ], + "names": [ + "Pending", + "Incomplete", + "Unavailable" + ], + "deprecations": [ + false, + false, + false + ] + } + }, + { + "name": "unconfirmed_bytes", + "publicName": "unconfirmed_bytes", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "validation": { + "pattern": "^(0|[1-9][0-9]*)$", + "maxLength": 20 + } + } + } + ], + "additionalProperties": false + } + }, + { + "name": "machine_id_path_segment", + "source": "machine_id_path_segment", + "publicAliases": [], + "deprecated": false, + "extensions": { + "source": [ + "examples" + ], + "examples": "017f22e2-79b0-7cc3-98c4-dc0c0c07398f" + }, + "type": { + "kind": "primitive", + "type": "string", + "validation": { + "pattern": "^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "minLength": 36, + "maxLength": 39 + } + } + }, + { + "name": "machine_list_item", + "source": "machine_list_item", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "autosleep_seconds", + "publicName": "autosleep_seconds", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64", + "validation": { + "minimum": 0, + "maximum": 9223372036 + } + } + }, + { + "name": "created_at", + "publicName": "created_at", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "date-time" + } + }, + { + "name": "desired_state", + "publicName": "desired_state", + "required": true, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "running", + "sleeping", + "destroyed" + ], + "names": [ + "Running", + "Sleeping", + "Destroyed" + ], + "deprecations": [ + false, + false, + false + ] + } + }, + { + "name": "machine_id", + "publicName": "machine_id", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "uuid" + } + }, + { + "name": "memory_mib", + "publicName": "memory_mib", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "phase", + "publicName": "phase", + "required": true, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "accepted", + "placement_pending", + "starting", + "running", + "stopping", + "sleeping", + "destroying", + "destroyed", + "failed" + ], + "names": [ + "Accepted", + "PlacementPending", + "Starting", + "Running", + "Stopping", + "Sleeping", + "Destroying", + "Destroyed", + "Failed" + ], + "deprecations": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + } + }, + { + "name": "storage_gib", + "publicName": "storage_gib", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "vcpu", + "publicName": "vcpu", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "number", + "format": "double" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "machine_list", + "source": "machine_list", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "items", + "publicName": "items", + "required": true, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "array", + "items": { + "kind": "ref", + "name": "machine_list_item" + } + }, + { + "kind": "null" + } + ] + } + }, + { + "name": "next_cursor", + "publicName": "next_cursor", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "policy", + "source": "policy", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "enabled", + "publicName": "enabled", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "boolean" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "settings", + "source": "settings", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "enabled", + "publicName": "enabled", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "boolean" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "public_path_segment", + "source": "public_path_segment", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "validation": { + "pattern": "^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$", + "minLength": 1, + "maxLength": 253 + } + } + }, + { + "name": "publication", + "source": "publication", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "code", + "publicName": "code", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "complete", + "publicName": "complete", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "boolean" + } + }, + { + "name": "queued_messages", + "publicName": "queued_messages", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64", + "validation": { + "minimum": 0 + } + } + }, + { + "name": "state", + "publicName": "state", + "required": true, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "active", + "finishing", + "failed", + "closed" + ], + "names": [ + "Active", + "Finishing", + "Failed", + "Closed" + ], + "deprecations": [ + false, + false, + false, + false + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "read_token", + "source": "read_token", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "access_token", + "publicName": "access_token", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "base_url", + "publicName": "base_url", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "execution_id", + "publicName": "execution_id", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "expires_at", + "publicName": "expires_at", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "date-time" + } + }, + { + "name": "stream_id", + "publicName": "stream_id", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "token_type", + "publicName": "token_type", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "ssh_connection", + "source": "ssh_connection", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "endpoint", + "publicName": "endpoint", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "host_trust", + "publicName": "host_trust", + "required": false, + "deprecated": false, + "type": { + "kind": "ref", + "name": "ssh_host_trust" + } + }, + { + "name": "port", + "publicName": "port", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "ssh_username", + "publicName": "ssh_username", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "user_certificate", + "publicName": "user_certificate", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "ssh_host_trust", + "source": "ssh_host_trust", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "host_pattern", + "publicName": "host_pattern", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "kind", + "publicName": "kind", + "required": true, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "cert_authority" + ], + "names": [ + "CertAuthority" + ], + "deprecations": [ + false + ] + } + }, + { + "name": "public_key", + "publicName": "public_key", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "ssh_session_list", + "source": "ssh_session_list", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "items", + "publicName": "items", + "required": true, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "array", + "items": { + "kind": "ref", + "name": "ssh_session" + } + }, + { + "kind": "null" + } + ] + } + }, + { + "name": "next_cursor", + "publicName": "next_cursor", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "ssh_session", + "source": "ssh_session", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "connection", + "publicName": "connection", + "required": false, + "deprecated": false, + "type": { + "kind": "ref", + "name": "ssh_connection" + } + }, + { + "name": "created_at", + "publicName": "created_at", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "date-time" + } + }, + { + "name": "error_code", + "publicName": "error_code", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "error_message", + "publicName": "error_message", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "expires_at", + "publicName": "expires_at", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "date-time" + } + }, + { + "name": "machine_id", + "publicName": "machine_id", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "uuid" + } + }, + { + "name": "ready_at", + "publicName": "ready_at", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string", + "format": "date-time" + } + }, + { + "name": "retry_after_ms", + "publicName": "retry_after_ms", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "session_id", + "publicName": "session_id", + "required": true, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "status", + "publicName": "status", + "required": true, + "deprecated": false, + "type": { + "kind": "enum", + "values": [ + "wake_in_progress", + "ssh_in_progress", + "ready", + "closed", + "expired", + "failed" + ], + "names": [ + "WakeInProgress", + "SshInProgress", + "Ready", + "Closed", + "Expired", + "Failed" + ], + "deprecations": [ + false, + false, + false, + false, + false, + false + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "update_params", + "source": "update_params", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "autosleep", + "publicName": "autosleep", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "memory_mib", + "publicName": "memory_mib", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "storage_gib", + "publicName": "storage_gib", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "integer", + "format": "int64" + } + }, + { + "name": "vcpu", + "publicName": "vcpu", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "number", + "format": "double" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateResponseHeaders", + "source": "CreateResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "ETag", + "publicName": "ETag", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Location", + "publicName": "Location", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "X-Dedalus-Storage-Operation-Id", + "publicName": "X-Dedalus-Storage-Operation-Id", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "null" + } + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateResponseHeaders", + "source": "CreateResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "ETag", + "publicName": "ETag", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Location", + "publicName": "Location", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "X-Dedalus-Storage-Operation-Id", + "publicName": "X-Dedalus-Storage-Operation-Id", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "null" + } + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus401ResponseHeaders", + "source": "CreateStatus401ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus403ResponseHeaders", + "source": "CreateStatus403ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus409ResponseHeaders", + "source": "CreateStatus409ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus429ResponseHeaders", + "source": "CreateStatus429ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus503ResponseHeaders", + "source": "CreateStatus503ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatusdefaultResponseHeaders", + "source": "CreateStatusdefaultResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "RetrieveResponseHeaders", + "source": "RetrieveResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "ETag", + "publicName": "ETag", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "UpdateResponseHeaders", + "source": "UpdateResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "ETag", + "publicName": "ETag", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Location", + "publicName": "Location", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "X-Dedalus-Storage-Operation-Id", + "publicName": "X-Dedalus-Storage-Operation-Id", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "null" + } + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "UpdateResponseHeaders", + "source": "UpdateResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "ETag", + "publicName": "ETag", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Location", + "publicName": "Location", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "X-Dedalus-Storage-Operation-Id", + "publicName": "X-Dedalus-Storage-Operation-Id", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "null" + } + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "UpdateStatus401ResponseHeaders", + "source": "UpdateStatus401ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "UpdateStatus403ResponseHeaders", + "source": "UpdateStatus403ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "UpdateStatus409ResponseHeaders", + "source": "UpdateStatus409ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "UpdateStatus429ResponseHeaders", + "source": "UpdateStatus429ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "UpdateStatus503ResponseHeaders", + "source": "UpdateStatus503ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "UpdateStatusdefaultResponseHeaders", + "source": "UpdateStatusdefaultResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "DeleteResponseHeaders", + "source": "DeleteResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "ETag", + "publicName": "ETag", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Location", + "publicName": "Location", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "X-Dedalus-Storage-Operation-Id", + "publicName": "X-Dedalus-Storage-Operation-Id", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "null" + } + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "DeleteResponseHeaders", + "source": "DeleteResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "ETag", + "publicName": "ETag", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Location", + "publicName": "Location", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "X-Dedalus-Storage-Operation-Id", + "publicName": "X-Dedalus-Storage-Operation-Id", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "null" + } + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "DeleteStatus401ResponseHeaders", + "source": "DeleteStatus401ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "DeleteStatus403ResponseHeaders", + "source": "DeleteStatus403ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "DeleteStatus409ResponseHeaders", + "source": "DeleteStatus409ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "DeleteStatus429ResponseHeaders", + "source": "DeleteStatus429ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "DeleteStatus503ResponseHeaders", + "source": "DeleteStatus503ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "DeleteStatusdefaultResponseHeaders", + "source": "DeleteStatusdefaultResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "SleepResponseHeaders", + "source": "SleepResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "ETag", + "publicName": "ETag", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Location", + "publicName": "Location", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "X-Dedalus-Storage-Operation-Id", + "publicName": "X-Dedalus-Storage-Operation-Id", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "null" + } + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "SleepResponseHeaders", + "source": "SleepResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "ETag", + "publicName": "ETag", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Location", + "publicName": "Location", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "X-Dedalus-Storage-Operation-Id", + "publicName": "X-Dedalus-Storage-Operation-Id", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "null" + } + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "SleepStatus401ResponseHeaders", + "source": "SleepStatus401ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "SleepStatus403ResponseHeaders", + "source": "SleepStatus403ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "SleepStatus409ResponseHeaders", + "source": "SleepStatus409ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "SleepStatus429ResponseHeaders", + "source": "SleepStatus429ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "SleepStatus503ResponseHeaders", + "source": "SleepStatus503ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "SleepStatusdefaultResponseHeaders", + "source": "SleepStatusdefaultResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "WakeResponseHeaders", + "source": "WakeResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "ETag", + "publicName": "ETag", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Location", + "publicName": "Location", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "X-Dedalus-Storage-Operation-Id", + "publicName": "X-Dedalus-Storage-Operation-Id", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "null" + } + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "WakeResponseHeaders", + "source": "WakeResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "ETag", + "publicName": "ETag", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Location", + "publicName": "Location", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "X-Dedalus-Storage-Operation-Id", + "publicName": "X-Dedalus-Storage-Operation-Id", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "null" + } + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "WakeStatus401ResponseHeaders", + "source": "WakeStatus401ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "WakeStatus403ResponseHeaders", + "source": "WakeStatus403ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "WakeStatus409ResponseHeaders", + "source": "WakeStatus409ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "WakeStatus429ResponseHeaders", + "source": "WakeStatus429ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "WakeStatus503ResponseHeaders", + "source": "WakeStatus503ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "WakeStatusdefaultResponseHeaders", + "source": "WakeStatusdefaultResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "RebootResponseHeaders", + "source": "RebootResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "ETag", + "publicName": "ETag", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Location", + "publicName": "Location", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "X-Dedalus-Storage-Operation-Id", + "publicName": "X-Dedalus-Storage-Operation-Id", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "null" + } + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "RebootResponseHeaders", + "source": "RebootResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "ETag", + "publicName": "ETag", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "Location", + "publicName": "Location", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + }, + { + "name": "X-Dedalus-Storage-Operation-Id", + "publicName": "X-Dedalus-Storage-Operation-Id", + "required": false, + "deprecated": false, + "type": { + "kind": "union", + "variants": [ + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "null" + } + ] + } + } + ], + "additionalProperties": false + } + }, + { + "name": "RebootStatus401ResponseHeaders", + "source": "RebootStatus401ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "RebootStatus403ResponseHeaders", + "source": "RebootStatus403ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "RebootStatus409ResponseHeaders", + "source": "RebootStatus409ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "RebootStatus429ResponseHeaders", + "source": "RebootStatus429ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "RebootStatus503ResponseHeaders", + "source": "RebootStatus503ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "RebootStatusdefaultResponseHeaders", + "source": "RebootStatusdefaultResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateResponseHeaders", + "source": "CreateResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus401ResponseHeaders", + "source": "CreateStatus401ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus403ResponseHeaders", + "source": "CreateStatus403ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus409ResponseHeaders", + "source": "CreateStatus409ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus429ResponseHeaders", + "source": "CreateStatus429ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus503ResponseHeaders", + "source": "CreateStatus503ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatusdefaultResponseHeaders", + "source": "CreateStatusdefaultResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateResponseHeaders", + "source": "CreateResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus401ResponseHeaders", + "source": "CreateStatus401ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus403ResponseHeaders", + "source": "CreateStatus403ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus409ResponseHeaders", + "source": "CreateStatus409ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus429ResponseHeaders", + "source": "CreateStatus429ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatus503ResponseHeaders", + "source": "CreateStatus503ResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateStatusdefaultResponseHeaders", + "source": "CreateStatusdefaultResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Idempotency-Key", + "publicName": "Idempotency-Key", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "RetrieveResponseHeaders", + "source": "RetrieveResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Cache-Control", + "publicName": "Cache-Control", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "ReauthorizeResponseHeaders", + "source": "ReauthorizeResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Cache-Control", + "publicName": "Cache-Control", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + }, + { + "name": "CreateTokenResponseHeaders", + "source": "CreateTokenResponseHeaders", + "publicAliases": [], + "deprecated": false, + "type": { + "kind": "object", + "properties": [ + { + "name": "Cache-Control", + "publicName": "Cache-Control", + "required": false, + "deprecated": false, + "type": { + "kind": "primitive", + "type": "string" + } + } + ], + "additionalProperties": false + } + } + ], + "resources": [ + "machines", + "machines.ssh", + "machines.executions", + "machines.executions.logs", + "machines.autoresizing", + "organization", + "organization.autoresizing" + ], + "publicResources": [ + "machines", + "machines.ssh", + "machines.executions", + "machines.executions.logs", + "machines.autoresizing", + "organization", + "organization.autoresizing" + ], + "operations": [ + { + "resource": "machines", + "publicResource": "machines", + "operation": "list", + "publicOperation": "list", + "deprecated": false, + "method": "GET", + "path": "/v1/machines", + "pathParams": [], + "publicPathParams": [], + "queryParams": [ + "limit", + "cursor" + ], + "publicQueryParams": [ + "limit", + "cursor" + ], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [], + "queryParamDetails": [ + { + "name": "limit", + "required": false, + "style": "form", + "explode": false + }, + { + "name": "cursor", + "required": false, + "style": "form", + "explode": false + } + ], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "MachineListParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "machine_list", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "pagination": { + "style": "cursor", + "itemsField": "items", + "cursorField": "next_cursor", + "cursorParam": "cursor", + "cursorFields": [ + "next_cursor" + ], + "cursorParams": [ + "cursor" + ], + "requestLocation": "query", + "responseLocation": "body", + "itemModel": { + "name": "machine_list_item", + "publicAliases": [] + } + }, + "transport": "http" + }, + { + "resource": "machines", + "publicResource": "machines", + "operation": "create", + "publicOperation": "create", + "deprecated": false, + "method": "POST", + "path": "/v1/machines", + "pathParams": [], + "publicPathParams": [], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [ + "autosleep", + "memory_mib", + "storage_gib", + "vcpu" + ], + "publicBodyParams": [ + "autosleep", + "memory_mib", + "storage_gib", + "vcpu" + ], + "bodyParamDetails": [ + { + "name": "autosleep", + "required": false + }, + { + "name": "memory_mib", + "required": false + }, + { + "name": "storage_gib", + "required": false + }, + { + "name": "vcpu", + "required": false + } + ], + "publicPositionalParams": [], + "pathParamDetails": [], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "MachineCreateParams" + }, + "requestBody": { + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "required": true, + "publicName": "body", + "publicIdentifier": "body" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "machine", + "publicAliases": [] + }, + "responseHeadersModel": { + "name": "CreateResponseHeaders", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "409", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus401ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus403ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "409", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus409ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus429ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus503ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + }, + "headersModel": { + "name": "CreateStatusdefaultResponseHeaders", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines", + "publicResource": "machines", + "operation": "retrieve", + "publicOperation": "retrieve", + "deprecated": false, + "method": "GET", + "path": "/v1/machines/{machine_id}", + "pathParams": [ + "machine_id" + ], + "publicPathParams": [ + "machine_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "MachineRetrieveParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "machine_detail_response", + "publicAliases": [] + }, + "responseHeadersModel": { + "name": "RetrieveResponseHeaders", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines", + "publicResource": "machines", + "operation": "update", + "publicOperation": "update", + "deprecated": false, + "method": "PATCH", + "path": "/v1/machines/{machine_id}", + "pathParams": [ + "machine_id" + ], + "publicPathParams": [ + "machine_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [ + "autosleep", + "memory_mib", + "storage_gib", + "vcpu" + ], + "publicBodyParams": [ + "autosleep", + "memory_mib", + "storage_gib", + "vcpu" + ], + "bodyParamDetails": [ + { + "name": "autosleep", + "required": false + }, + { + "name": "memory_mib", + "required": false + }, + { + "name": "storage_gib", + "required": false + }, + { + "name": "vcpu", + "required": false + } + ], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "MachineUpdateParams" + }, + "requestBody": { + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "required": true, + "publicName": "body", + "publicIdentifier": "body" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "machine", + "publicAliases": [] + }, + "responseHeadersModel": { + "name": "UpdateResponseHeaders", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "409", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "UpdateStatus401ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "UpdateStatus403ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "409", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "UpdateStatus409ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "UpdateStatus429ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "UpdateStatus503ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + }, + "headersModel": { + "name": "UpdateStatusdefaultResponseHeaders", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines", + "publicResource": "machines", + "operation": "delete", + "publicOperation": "delete", + "deprecated": false, + "method": "DELETE", + "path": "/v1/machines/{machine_id}", + "pathParams": [ + "machine_id" + ], + "publicPathParams": [ + "machine_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "MachineDeleteParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "machine", + "publicAliases": [] + }, + "responseHeadersModel": { + "name": "DeleteResponseHeaders", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "409", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "DeleteStatus401ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "DeleteStatus403ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "409", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "DeleteStatus409ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "DeleteStatus429ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "DeleteStatus503ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + }, + "headersModel": { + "name": "DeleteStatusdefaultResponseHeaders", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines", + "publicResource": "machines", + "operation": "sleep", + "publicOperation": "sleep", + "deprecated": false, + "method": "POST", + "path": "/v1/machines/{machine_id}/sleep", + "pathParams": [ + "machine_id" + ], + "publicPathParams": [ + "machine_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "MachineSleepParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "machine", + "publicAliases": [] + }, + "responseHeadersModel": { + "name": "SleepResponseHeaders", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "409", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "SleepStatus401ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "SleepStatus403ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "409", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "SleepStatus409ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "SleepStatus429ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "SleepStatus503ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + }, + "headersModel": { + "name": "SleepStatusdefaultResponseHeaders", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines", + "publicResource": "machines", + "operation": "wake", + "publicOperation": "wake", + "deprecated": false, + "method": "POST", + "path": "/v1/machines/{machine_id}/wake", + "pathParams": [ + "machine_id" + ], + "publicPathParams": [ + "machine_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "MachineWakeParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "machine", + "publicAliases": [] + }, + "responseHeadersModel": { + "name": "WakeResponseHeaders", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "409", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "WakeStatus401ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "WakeStatus403ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "409", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "WakeStatus409ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "WakeStatus429ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "WakeStatus503ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + }, + "headersModel": { + "name": "WakeStatusdefaultResponseHeaders", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines", + "publicResource": "machines", + "operation": "reboot", + "publicOperation": "reboot", + "deprecated": false, + "method": "POST", + "path": "/v1/machines/{machine_id}/reboot", + "pathParams": [ + "machine_id" + ], + "publicPathParams": [ + "machine_id" + ], + "queryParams": [ + "force" + ], + "publicQueryParams": [ + "force" + ], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [ + { + "name": "force", + "required": false, + "style": "form", + "explode": false + } + ], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "MachineRebootParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "machine", + "publicAliases": [] + }, + "responseHeadersModel": { + "name": "RebootResponseHeaders", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "409", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "RebootStatus401ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "RebootStatus403ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "409", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "RebootStatus409ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "RebootStatus429ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "RebootStatus503ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + }, + "headersModel": { + "name": "RebootStatusdefaultResponseHeaders", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines.ssh", + "publicResource": "machines.ssh", + "operation": "list", + "publicOperation": "list", + "deprecated": false, + "method": "GET", + "path": "/v1/machines/{machine_id}/ssh", + "pathParams": [ + "machine_id" + ], + "publicPathParams": [ + "machine_id" + ], + "queryParams": [ + "limit", + "cursor" + ], + "publicQueryParams": [ + "limit", + "cursor" + ], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [ + { + "name": "limit", + "required": false, + "style": "form", + "explode": false + }, + { + "name": "cursor", + "required": false, + "style": "form", + "explode": false + } + ], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "SshListParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "ssh_session_list", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "pagination": { + "style": "cursor", + "itemsField": "items", + "cursorField": "next_cursor", + "cursorParam": "cursor", + "cursorFields": [ + "next_cursor" + ], + "cursorParams": [ + "cursor" + ], + "requestLocation": "query", + "responseLocation": "body", + "itemModel": { + "name": "ssh_session", + "publicAliases": [] + } + }, + "transport": "http" + }, + { + "resource": "machines.ssh", + "publicResource": "machines.ssh", + "operation": "create", + "publicOperation": "create", + "deprecated": false, + "method": "POST", + "path": "/v1/machines/{machine_id}/ssh", + "pathParams": [ + "machine_id" + ], + "publicPathParams": [ + "machine_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [ + "public_key" + ], + "publicBodyParams": [ + "public_key" + ], + "bodyParamDetails": [ + { + "name": "public_key", + "required": true + } + ], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "SshCreateParams" + }, + "requestBody": { + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "required": true, + "publicName": "body", + "publicIdentifier": "body" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "ssh_session", + "publicAliases": [] + }, + "responseHeadersModel": { + "name": "CreateResponseHeaders", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "409", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus401ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus403ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "409", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus409ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus429ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus503ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + }, + "headersModel": { + "name": "CreateStatusdefaultResponseHeaders", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines.ssh", + "publicResource": "machines.ssh", + "operation": "retrieve", + "publicOperation": "retrieve", + "deprecated": false, + "method": "GET", + "path": "/v1/machines/{machine_id}/ssh/{session_id}", + "pathParams": [ + "machine_id", + "session_id" + ], + "publicPathParams": [ + "machine_id", + "session_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + }, + { + "name": "session_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "SshRetrieveParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "ssh_session", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines.ssh", + "publicResource": "machines.ssh", + "operation": "delete", + "publicOperation": "delete", + "deprecated": false, + "method": "DELETE", + "path": "/v1/machines/{machine_id}/ssh/{session_id}", + "pathParams": [ + "machine_id", + "session_id" + ], + "publicPathParams": [ + "machine_id", + "session_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + }, + { + "name": "session_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "SshDeleteParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "ssh_session", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines.executions", + "publicResource": "machines.executions", + "operation": "list", + "publicOperation": "list", + "deprecated": false, + "method": "GET", + "path": "/v1/machines/{machine_id}/executions", + "pathParams": [ + "machine_id" + ], + "publicPathParams": [ + "machine_id" + ], + "queryParams": [ + "limit", + "cursor" + ], + "publicQueryParams": [ + "limit", + "cursor" + ], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [ + { + "name": "limit", + "required": false, + "style": "form", + "explode": false + }, + { + "name": "cursor", + "required": false, + "style": "form", + "explode": false + } + ], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "ExecutionListParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "execution_list", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "pagination": { + "style": "cursor", + "itemsField": "items", + "cursorField": "next_cursor", + "cursorParam": "cursor", + "cursorFields": [ + "next_cursor" + ], + "cursorParams": [ + "cursor" + ], + "requestLocation": "query", + "responseLocation": "body", + "itemModel": { + "name": "execution", + "publicAliases": [] + } + }, + "transport": "http" + }, + { + "resource": "machines.executions", + "publicResource": "machines.executions", + "operation": "create", + "publicOperation": "create", + "deprecated": false, + "method": "POST", + "path": "/v1/machines/{machine_id}/executions", + "pathParams": [ + "machine_id" + ], + "publicPathParams": [ + "machine_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [ + "command", + "cwd", + "env", + "stdin", + "timeout_ms" + ], + "publicBodyParams": [ + "command", + "cwd", + "env", + "stdin", + "timeout_ms" + ], + "bodyParamDetails": [ + { + "name": "command", + "required": true + }, + { + "name": "cwd", + "required": false + }, + { + "name": "env", + "required": false + }, + { + "name": "stdin", + "required": false + }, + { + "name": "timeout_ms", + "required": false + } + ], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "ExecutionCreateParams" + }, + "requestBody": { + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "required": true, + "publicName": "body", + "publicIdentifier": "body" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "execution", + "publicAliases": [] + }, + "responseHeadersModel": { + "name": "CreateResponseHeaders", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "409", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus401ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus403ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "409", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus409ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus429ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "headersModel": { + "name": "CreateStatus503ResponseHeaders", + "publicAliases": [] + } + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + }, + "headersModel": { + "name": "CreateStatusdefaultResponseHeaders", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines.executions", + "publicResource": "machines.executions", + "operation": "retrieve", + "publicOperation": "retrieve", + "deprecated": false, + "method": "GET", + "path": "/v1/machines/{machine_id}/executions/{execution_id}", + "pathParams": [ + "machine_id", + "execution_id" + ], + "publicPathParams": [ + "machine_id", + "execution_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + }, + { + "name": "execution_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "ExecutionRetrieveParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "execution", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines.executions", + "publicResource": "machines.executions", + "operation": "delete", + "publicOperation": "delete", + "deprecated": false, + "method": "DELETE", + "path": "/v1/machines/{machine_id}/executions/{execution_id}", + "pathParams": [ + "machine_id", + "execution_id" + ], + "publicPathParams": [ + "machine_id", + "execution_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + }, + { + "name": "execution_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "ExecutionDeleteParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "execution", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines.executions", + "publicResource": "machines.executions", + "operation": "output", + "publicOperation": "output", + "deprecated": false, + "method": "GET", + "path": "/v1/machines/{machine_id}/executions/{execution_id}/output", + "pathParams": [ + "machine_id", + "execution_id" + ], + "publicPathParams": [ + "machine_id", + "execution_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + }, + { + "name": "execution_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "ExecutionOutputParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "execution_output", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines.executions", + "publicResource": "machines.executions", + "operation": "events", + "publicOperation": "events", + "deprecated": false, + "method": "GET", + "path": "/v1/machines/{machine_id}/executions/{execution_id}/events", + "pathParams": [ + "machine_id", + "execution_id" + ], + "publicPathParams": [ + "machine_id", + "execution_id" + ], + "queryParams": [ + "limit", + "cursor" + ], + "publicQueryParams": [ + "limit", + "cursor" + ], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + }, + { + "name": "execution_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [ + { + "name": "limit", + "required": false, + "style": "form", + "explode": false + }, + { + "name": "cursor", + "required": false, + "style": "form", + "explode": false + } + ], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "ExecutionEventsParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "execution_events", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "429", + "503", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "429", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "503", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "pagination": { + "style": "cursor", + "itemsField": "items", + "cursorField": "next_cursor", + "cursorParam": "cursor", + "cursorFields": [ + "next_cursor" + ], + "cursorParams": [ + "cursor" + ], + "requestLocation": "query", + "responseLocation": "body", + "itemModel": { + "name": "execution_event", + "publicAliases": [] + } + }, + "transport": "http" + }, + { + "resource": "machines.executions.logs", + "publicResource": "machines.executions.logs", + "operation": "retrieve", + "publicOperation": "retrieve", + "deprecated": false, + "method": "GET", + "path": "/v1/machines/{machine_id}/executions/{execution_id}/logs", + "pathParams": [ + "machine_id", + "execution_id" + ], + "publicPathParams": [ + "machine_id", + "execution_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + }, + { + "name": "execution_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "LogRetrieveParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "status", + "publicAliases": [] + }, + "responseHeadersModel": { + "name": "RetrieveResponseHeaders", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines.executions.logs", + "publicResource": "machines.executions.logs", + "operation": "reauthorize", + "publicOperation": "reauthorize", + "deprecated": false, + "method": "POST", + "path": "/v1/machines/{machine_id}/executions/{execution_id}/logs/reauthorize", + "pathParams": [ + "machine_id", + "execution_id" + ], + "publicPathParams": [ + "machine_id", + "execution_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + }, + { + "name": "execution_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "LogReauthorizeParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "status", + "publicAliases": [] + }, + "responseHeadersModel": { + "name": "ReauthorizeResponseHeaders", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines.executions.logs", + "publicResource": "machines.executions.logs", + "operation": "createToken", + "publicOperation": "createToken", + "deprecated": false, + "method": "POST", + "path": "/v1/machines/{machine_id}/executions/{execution_id}/logs/token", + "pathParams": [ + "machine_id", + "execution_id" + ], + "publicPathParams": [ + "machine_id", + "execution_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + }, + { + "name": "execution_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "LogCreateTokenParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "read_token", + "publicAliases": [] + }, + "responseHeadersModel": { + "name": "CreateTokenResponseHeaders", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines.autoresizing", + "publicResource": "machines.autoresizing", + "operation": "retrieve", + "publicOperation": "retrieve", + "deprecated": false, + "method": "GET", + "path": "/v1/machines/{machine_id}/autoresizing", + "pathParams": [ + "machine_id" + ], + "publicPathParams": [ + "machine_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "AutoresizingRetrieveParams" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "settings", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "machines.autoresizing", + "publicResource": "machines.autoresizing", + "operation": "update", + "publicOperation": "update", + "deprecated": false, + "method": "PUT", + "path": "/v1/machines/{machine_id}/autoresizing", + "pathParams": [ + "machine_id" + ], + "publicPathParams": [ + "machine_id" + ], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [ + "enabled" + ], + "publicBodyParams": [ + "enabled" + ], + "bodyParamDetails": [ + { + "name": "enabled", + "required": true + } + ], + "publicPositionalParams": [], + "pathParamDetails": [ + { + "name": "machine_id", + "required": true, + "style": "simple", + "explode": false + } + ], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "AutoresizingUpdateParams" + }, + "requestBody": { + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "required": true, + "publicName": "body", + "publicIdentifier": "body" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "settings", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "organization.autoresizing", + "publicResource": "organization.autoresizing", + "operation": "retrieve", + "publicOperation": "retrieve", + "deprecated": false, + "method": "GET", + "path": "/v1/organization/autoresizing", + "pathParams": [], + "publicPathParams": [], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [], + "publicBodyParams": [], + "bodyParamDetails": [], + "publicPositionalParams": [], + "pathParamDetails": [], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "policy", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + }, + { + "resource": "organization.autoresizing", + "publicResource": "organization.autoresizing", + "operation": "update", + "publicOperation": "update", + "deprecated": false, + "method": "PUT", + "path": "/v1/organization/autoresizing", + "pathParams": [], + "publicPathParams": [], + "queryParams": [], + "publicQueryParams": [], + "headerParams": [], + "publicHeaderParams": [], + "bodyParams": [ + "enabled" + ], + "publicBodyParams": [ + "enabled" + ], + "bodyParamDetails": [ + { + "name": "enabled", + "required": true + } + ], + "publicPositionalParams": [], + "pathParamDetails": [], + "queryParamDetails": [], + "headerParamDetails": [], + "cookieParams": [], + "publicCookieParams": [], + "cookieParamDetails": [], + "paramsModel": { + "publicName": "AutoresizingUpdateParams" + }, + "requestBody": { + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ], + "required": true, + "publicName": "body", + "publicIdentifier": "body" + }, + "response": { + "status": "200", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + "responseModel": { + "name": "policy", + "publicAliases": [] + }, + "result": { + "successStatus": "200", + "errorStatuses": [ + "401", + "403", + "default" + ] + }, + "errorResponses": [ + { + "status": "401", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "403", + "contentType": "application/json", + "encoding": "json", + "contents": [ + { + "contentType": "application/json", + "encoding": "json" + } + ] + }, + { + "status": "default", + "contentType": "application/problem+json", + "encoding": "json", + "contents": [ + { + "contentType": "application/problem+json", + "encoding": "json" + } + ], + "model": { + "name": "error_model", + "publicAliases": [] + } + } + ], + "responseLinks": [], + "transport": "http" + } + ], + "webhooks": [] +} diff --git a/scripts/bootstrap b/scripts/bootstrap deleted file mode 100755 index bbc786d..0000000 --- a/scripts/bootstrap +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then - brew bundle check >/dev/null 2>&1 || { - echo -n "==> Install Homebrew dependencies? (y/N): " - read -r response - case "$response" in - [yY][eE][sS]|[yY]) - brew bundle - ;; - *) - ;; - esac - echo - } -fi -echo "==> Installing Go dependencies…" -go mod tidy -e || true diff --git a/scripts/build b/scripts/build deleted file mode 100755 index af8e3c9..0000000 --- a/scripts/build +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/dedalus-labs/dedalus-go,github.com/stainless-sdks/dedalus-go" - -echo "==> Building dedalus" -go build ./cmd/dedalus diff --git a/scripts/finalize-build.mjs b/scripts/finalize-build.mjs new file mode 100644 index 0000000..8d580cd --- /dev/null +++ b/scripts/finalize-build.mjs @@ -0,0 +1,50 @@ +import { chmod, readdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, extname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const RELATIVE_SPECIFIER_RE = /(from\s+["']|import\(\s*["'])(\.{1,2}\/[^"']+)(["'])/g; + +await Promise.all([ + rewriteRelativeSpecifiers(resolve(root, 'dist/esm')), + markCommonJsOutput(resolve(root, 'dist/cjs')), +]); +await chmod(resolve(root, 'dist/esm/bin.js'), 0o755).catch(() => {}); + +async function rewriteRelativeSpecifiers(dir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (error) { + if (error && error.code === 'ENOENT') return; + throw error; + } + + await Promise.all( + entries.map(async (entry) => { + const path = resolve(dir, entry.name); + if (entry.isDirectory()) { + await rewriteRelativeSpecifiers(path); + return; + } + if (!path.endsWith('.js') && !path.endsWith('.d.ts')) return; + const source = await readFile(path, 'utf8'); + await writeFile(path, source.replace(RELATIVE_SPECIFIER_RE, addJsExtension), 'utf8'); + }), + ); +} + +async function markCommonJsOutput(dir) { + try { + await readdir(dir); + await writeFile(resolve(dir, 'package.json'), '{\n "type": "commonjs"\n}\n', 'utf8'); + } catch (error) { + if (error && error.code === 'ENOENT') return; + throw error; + } +} + +function addJsExtension(match, prefix, specifier, suffix) { + if (extname(specifier)) return match; + return `${prefix}${specifier}.js${suffix}`; +} diff --git a/scripts/format b/scripts/format deleted file mode 100755 index db2a3fa..0000000 --- a/scripts/format +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -echo "==> Running gofmt -s -w" -gofmt -s -w . diff --git a/scripts/install.ps1 b/scripts/install.ps1 deleted file mode 100644 index 313a5a3..0000000 --- a/scripts/install.ps1 +++ /dev/null @@ -1,321 +0,0 @@ -<# -.SYNOPSIS - The installer for the Dedalus CLI on Windows. - -.DESCRIPTION - Detects the host architecture, downloads the matching release archive from - https://github.com/dedalus-labs/dedalus-cli/releases, extracts dedalus.exe, - and installs it to $HOME\.local\bin (overridable). Optionally adds the - install directory to the user PATH via the registry. - - Mirrors scripts/install.sh 1:1 so docs, env vars, and muscle memory are - symmetric across macOS, Linux, and Windows. - -.PARAMETER InstallDir - Install directory. Defaults to $env:DEDALUS_INSTALL_DIR, then $HOME\.local\bin. - -.PARAMETER Version - Version tag to install (e.g. v0.1.0). Defaults to $env:DEDALUS_VERSION, - then the latest GitHub release. - -.PARAMETER NoModifyPath - Skip modifying the user PATH. Also honored via $env:DEDALUS_NO_MODIFY_PATH=1. - -.EXAMPLE - irm https://raw.githubusercontent.com/dedalus-labs/dedalus-cli/main/scripts/install.ps1 | iex - -.EXAMPLE - # Pin a version - iex "& {$(irm https://raw.githubusercontent.com/dedalus-labs/dedalus-cli/main/scripts/install.ps1)} -Version v0.1.0" -#> - -[CmdletBinding()] -param ( - [string]$InstallDir, - [string]$Version, - [switch]$NoModifyPath -) - -$ErrorActionPreference = 'Stop' - -$Repo = 'dedalus-labs/dedalus-cli' -$Binary = 'dedalus' - -function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan } -function Write-Ok { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green } -function Write-Warn { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow } -function Write-Err { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red } - -function Show-Banner { - $art = @' - - .. - .. - ... - .... - .... - ..... - ..... - ...... - ........ - ........ - ......... - ........... ... - ............ .... - ............. .... - .............. ..... - .............. ...... - .............. ....... - ............ ....... - ......... ........ - ........ ......... - ...... ........... -...... ............ -..... .. -.... .... -... .... -.. ....... -. .......... -. .................... -. .................. - - Dedalus CLI - dedaluslabs.ai - -'@ - Write-Host $art -} - -function Assert-Environment { - if ($PSVersionTable.PSVersion.Major -lt 5) { - Write-Err "PowerShell 5.1 or newer is required (found $($PSVersionTable.PSVersion))." - Write-Err "Upgrade: https://learn.microsoft.com/powershell/scripting/install/installing-powershell" - exit 1 - } - - $policy = Get-ExecutionPolicy - $allowed = @('Unrestricted', 'RemoteSigned', 'Bypass') - if ($policy -notin $allowed) { - Write-Err "PowerShell execution policy is '$policy'; need one of: $($allowed -join ', ')." - Write-Err "Run: Set-ExecutionPolicy RemoteSigned -Scope CurrentUser" - exit 1 - } - - # GitHub requires TLS 1.2. PS 5.1 defaults to TLS 1.0/1.1 on older Windows. - if ([Net.ServicePointManager]::SecurityProtocol -notmatch 'Tls12') { - [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 - } -} - -function Get-Arch { - $raw = $null - try { - $raw = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() - } catch { - $raw = $env:PROCESSOR_ARCHITECTURE - } - - switch -Regex ($raw) { - '^(X64|AMD64)$' { return 'amd64' } - '^ARM64$' { return 'arm64' } - '^(X86|IA32)$' { return '386' } - default { - Write-Err "Unsupported architecture: $raw" - exit 1 - } - } -} - -function Get-LatestVersion { - $url = "https://api.github.com/repos/$Repo/releases/latest" - $headers = @{ - 'Accept' = 'application/vnd.github+json' - 'User-Agent' = 'dedalus-cli-installer' - } - - try { - $release = Invoke-RestMethod -Uri $url -Headers $headers -ErrorAction Stop - } catch { - Write-Err "Could not determine latest version from $url" - Write-Err $_.Exception.Message - exit 1 - } - - $tag = $release.tag_name - if (-not $tag) { - Write-Err "Could not parse version tag from GitHub API response: $url" - exit 1 - } - return $tag.Trim() -} - -function Install-Dedalus { - param( - [string]$Arch, - [string]$VersionTag, - [string]$Destination - ) - - $versionNum = $VersionTag.TrimStart('v') - $archiveName = "${Binary}_${versionNum}_windows_${Arch}.zip" - $url = "https://github.com/$Repo/releases/download/$VersionTag/$archiveName" - - $tmpdir = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString())) - try { - $archivePath = Join-Path $tmpdir.FullName $archiveName - Write-Info "Downloading $url ..." - try { - Invoke-WebRequest -Uri $url -OutFile $archivePath -UseBasicParsing - } catch { - Write-Err "Download failed. Check that a release exists for windows/$Arch." - Write-Err $_.Exception.Message - exit 1 - } - - Write-Info "Extracting..." - Expand-Archive -LiteralPath $archivePath -DestinationPath $tmpdir.FullName -Force - - $extracted = Join-Path $tmpdir.FullName "$Binary.exe" - if (-not (Test-Path -LiteralPath $extracted)) { - Write-Err "Archive did not contain $Binary.exe" - exit 1 - } - - if (-not (Test-Path -LiteralPath $Destination)) { - New-Item -ItemType Directory -Path $Destination -Force | Out-Null - } - - $target = Join-Path $Destination "$Binary.exe" - - # Clear backups from earlier in-place updates. Removal fails only while - # that old binary is still running; the next install retries. - Get-ChildItem -LiteralPath $Destination -Filter "$Binary.exe.old-*" -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - - # Windows locks a running exe against deletion and overwrite but allows - # renaming it, so move any existing binary aside before installing. This - # lets `dedalus update` replace itself while it is running. If installing - # the new binary fails, restore the backup: an aborted install must leave - # the previous binary in place. - $backup = $null - if (Test-Path -LiteralPath $target) { - $backup = "$target.old-" + [guid]::NewGuid().ToString('N') - Move-Item -LiteralPath $target -Destination $backup -Force - } - - try { - Move-Item -LiteralPath $extracted -Destination $target -Force - } catch { - if ($backup) { - try { - Move-Item -LiteralPath $backup -Destination $target -Force - } catch { - Write-Err "Rollback failed; the previous binary is at $backup" - } - } - throw - } - if ($backup) { - Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue - } - Write-Ok "Installed $Binary to $target" - } finally { - Remove-Item -LiteralPath $tmpdir.FullName -Recurse -Force -ErrorAction SilentlyContinue - } -} - -function Test-PathContains { - param([string]$Directory) - - $sep = [System.IO.Path]::PathSeparator - $current = ($env:PATH -split $sep) | Where-Object { $_ } - foreach ($entry in $current) { - if ([string]::Equals($entry.TrimEnd('\'), $Directory.TrimEnd('\'), [System.StringComparison]::OrdinalIgnoreCase)) { - return $true - } - } - return $false -} - -function Add-UserPath { - param([string]$Directory) - - $registryPath = 'registry::HKEY_CURRENT_USER\Environment' - $existing = (Get-Item -LiteralPath $registryPath).GetValue('Path', '', 'DoNotExpandEnvironmentNames') - $entries = @() - if ($existing) { - $entries = $existing -split ';' | Where-Object { $_ } - } - - foreach ($entry in $entries) { - if ([string]::Equals($entry.TrimEnd('\'), $Directory.TrimEnd('\'), [System.StringComparison]::OrdinalIgnoreCase)) { - return $false - } - } - - $newPath = (@($Directory) + $entries) -join ';' - Set-ItemProperty -Type ExpandString -LiteralPath $registryPath -Name Path -Value $newPath - - # Broadcast WM_SETTINGCHANGE so explorer/new shells pick up the change without reboot. - # Uses a dummy env var round-trip, same trick uv uses. - $dummy = 'DEDALUS_CLI_PATH_' + [guid]::NewGuid().ToString('N') - [Environment]::SetEnvironmentVariable($dummy, '1', 'User') - [Environment]::SetEnvironmentVariable($dummy, [NullString]::Value, 'User') - - $env:PATH = "$Directory;$env:PATH" - return $true -} - -function Main { - Show-Banner - Assert-Environment - - if (-not $InstallDir) { - if ($env:DEDALUS_INSTALL_DIR) { - $InstallDir = $env:DEDALUS_INSTALL_DIR - } else { - $InstallDir = Join-Path $HOME '.local\bin' - } - } - - if (-not $Version) { - if ($env:DEDALUS_VERSION) { - $Version = $env:DEDALUS_VERSION - } - } - - if (-not $NoModifyPath -and $env:DEDALUS_NO_MODIFY_PATH) { - $NoModifyPath = $true - } - - $arch = Get-Arch - Write-Info "Detected windows/$arch" - - if (-not $Version) { - $Version = Get-LatestVersion - } - $Version = 'v' + $Version.TrimStart('v') - Write-Info "Version: $Version" - - Install-Dedalus -Arch $arch -VersionTag $Version -Destination $InstallDir - - if (-not (Test-PathContains -Directory $InstallDir)) { - if ($NoModifyPath) { - Write-Warn "$InstallDir is not in your PATH" - Write-Host " Add it manually in PowerShell:" - Write-Host " [Environment]::SetEnvironmentVariable('Path', `"$InstallDir;`" + [Environment]::GetEnvironmentVariable('Path','User'), 'User')" - Write-Host "" - } else { - if (Add-UserPath -Directory $InstallDir) { - Write-Ok "Added $InstallDir to your user PATH" - Write-Host " Restart your shell for the change to take effect in new sessions." - Write-Host "" - } - } - } - - Write-Ok "Ready! Run:" - Write-Host " $Binary --help" - Write-Host "" -} - -Main diff --git a/scripts/install.sh b/scripts/install.sh deleted file mode 100755 index 925fe1f..0000000 --- a/scripts/install.sh +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPO="dedalus-labs/dedalus-cli" -BINARY="dedalus" -INSTALL_DIR="${DEDALUS_INSTALL_DIR:-$HOME/.local/bin}" -INSTALL_MARKER=".dedalus-cli-install" -TMPDIR_CLEANUP="" - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } -info() { echo -e "${BLUE}[INFO]${NC} $1"; } -success() { echo -e "${GREEN}[OK]${NC} $1"; } -warning() { echo -e "${YELLOW}[WARN]${NC} $1"; } - -cleanup_tmpdir() { - if [[ -n "${TMPDIR_CLEANUP:-}" ]]; then - rm -rf -- "${TMPDIR_CLEANUP}" - TMPDIR_CLEANUP="" - fi -} - -trap cleanup_tmpdir EXIT - -detect_platform() { - local os arch - - case "$(uname -s)" in - Linux) os="linux" ;; - Darwin) os="macos" ;; - *) error "Unsupported OS: $(uname -s)"; exit 1 ;; - esac - - case "$(uname -m)" in - x86_64|amd64) arch="amd64" ;; - aarch64|arm64) arch="arm64" ;; - *) error "Unsupported architecture: $(uname -m)"; exit 1 ;; - esac - - PLATFORM="${os}" - ARCH="${arch}" - info "Detected ${PLATFORM}/${ARCH}" -} - -# DEDALUS_VERSION (set by `dedalus update`) pins the release; otherwise fetch latest. -resolve_version() { - if ! command -v curl &>/dev/null; then - error "curl is required" - exit 1 - fi - - VERSION="${DEDALUS_VERSION:-}" - if [[ -n "$VERSION" ]]; then - VERSION="v${VERSION#v}" - else - VERSION=$(curl -fsSL \ - -H 'Accept: application/vnd.github+json' \ - -H 'User-Agent: dedalus-cli-installer' \ - "https://api.github.com/repos/${REPO}/releases/latest" \ - | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') - - if [[ -z "$VERSION" ]]; then - error "Could not determine latest version from GitHub API" - exit 1 - fi - fi - - info "Version: ${VERSION}" -} - -download_and_install() { - local version_num="${VERSION#v}" - local archive_name="${BINARY}_${version_num}_${PLATFORM}_${ARCH}" - local ext="tar.gz" - - if [[ "$PLATFORM" == "macos" ]]; then - ext="zip" - fi - - local url="https://github.com/${REPO}/releases/download/${VERSION}/${archive_name}.${ext}" - - cleanup_tmpdir - TMPDIR_CLEANUP=$(mktemp -d) - local tmpdir="${TMPDIR_CLEANUP}" - - info "Downloading ${url}..." - if ! curl -fsSL "$url" -o "${tmpdir}/archive.${ext}"; then - error "Download failed. Check that a release exists for ${PLATFORM}/${ARCH}" - exit 1 - fi - - info "Extracting..." - if [[ "$ext" == "zip" ]]; then - unzip -oq "${tmpdir}/archive.zip" -d "$tmpdir" - else - tar -xzf "${tmpdir}/archive.tar.gz" -C "$tmpdir" - fi - - mkdir -p "$INSTALL_DIR" - mv "${tmpdir}/${BINARY}" "${INSTALL_DIR}/${BINARY}" - chmod +x "${INSTALL_DIR}/${BINARY}" - printf 'method=install-script\nversion=%s\n' "$VERSION" > "${INSTALL_DIR}/${INSTALL_MARKER}" - success "Installed ${BINARY} to ${INSTALL_DIR}/${BINARY}" -} - -main() { - cat <<'ART' - - .. - .. - ... - .... - .... - ..... - ..... - ...... - ........ - ........ - ......... - ........... ... - ............ .... - ............. .... - .............. ..... - .............. ...... - .............. ....... - ............ ....... - ......... ........ - ........ ......... - ...... ........... -...... ............ -..... .. -.... .... -... .... -.. ....... -. .......... -. .................... -. .................. - - Dedalus CLI - dedaluslabs.ai - -ART - detect_platform - resolve_version - download_and_install - - if [[ ":$PATH:" != *":${INSTALL_DIR}:"* ]]; then - warning "${INSTALL_DIR} is not in your PATH" - echo " Add it to your shell profile:" - echo " export PATH=\"${INSTALL_DIR}:\$PATH\"" - echo - fi - - if command -v "$BINARY" &>/dev/null; then - success "Ready! Run:" - echo " dedalus --help" - echo - fi -} - -main diff --git a/scripts/link b/scripts/link deleted file mode 100755 index c9d0047..0000000 --- a/scripts/link +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/dedalus-labs/dedalus-go,github.com/stainless-sdks/dedalus-go" - -REPLACEMENT="${1:-"../Dedalus-go"}" -echo "==> Replacing Go SDK with $REPLACEMENT" -if [[ -d "$REPLACEMENT" ]] || go list -m "$REPLACEMENT" >/dev/null; then - go mod edit -replace github.com/dedalus-labs/dedalus-go="$REPLACEMENT" - go mod tidy -e -else - echo "Skipping Go SDK replacement (branch may not exist on Go SDK)" -fi diff --git a/scripts/lint b/scripts/lint deleted file mode 100755 index 86eae73..0000000 --- a/scripts/lint +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/dedalus-labs/dedalus-go,github.com/stainless-sdks/dedalus-go" - -echo "==> Running Go build" -go build ./... diff --git a/scripts/mock b/scripts/mock deleted file mode 100755 index 6376077..0000000 --- a/scripts/mock +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -if [ "$1" == "--install" ]; then - npm exec --package=@stdy/cli@0.22.1 -- steady --version - exit 0 -fi - -if [[ -n "$1" && "$1" != '--'* ]]; then - URL="$1" - shift -else - URL="$(grep 'openapi_spec_url' .stats.yml | cut -d' ' -f2)" -fi - -# Check if the URL is empty -if [ -z "$URL" ]; then - echo "Error: No OpenAPI spec path/url provided or found in .stats.yml" - exit 1 -fi - -echo "==> Starting mock server with URL ${URL}" - -# Run steady mock on the given spec -MOCK_PORT="${STAINLESS_MOCK_PORT:-4010}" - -if [ "$1" == "--daemon" ]; then - # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.22.1 -- steady --version - - npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p "$MOCK_PORT" --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & - - # Wait for server to come online via health endpoint (max 30s) - echo -n "Waiting for server" - attempts=0 - while ! curl --silent --fail "http://127.0.0.1:$MOCK_PORT/_x-steady/health" >/dev/null 2>&1; do - if ! kill -0 $! 2>/dev/null; then - echo - cat .stdy.log - exit 1 - fi - attempts=$((attempts + 1)) - if [ "$attempts" -ge 300 ]; then - echo - echo "Timed out waiting for Steady server to start" - cat .stdy.log - exit 1 - fi - echo -n "." - sleep 0.1 - done - - echo -else - npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p "$MOCK_PORT" --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" -fi diff --git a/scripts/package-staging.mjs b/scripts/package-staging.mjs new file mode 100644 index 0000000..3d7bef3 --- /dev/null +++ b/scripts/package-staging.mjs @@ -0,0 +1,76 @@ +// Build first, then package an isolated, non-publishable CLI for staging validation. +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const version = process.argv[2]; +if (!version || !/^\d+\.\d+\.\d+-staging\.[a-zA-Z0-9.-]+$/.test(version)) { + throw new Error('usage: node scripts/package-staging.mjs X.Y.Z-staging.ID [output-directory]'); +} +const output = resolve(process.argv[3] ?? join(root, 'artifacts')); +const staging = mkdtempSync(join(tmpdir(), 'dedalus-staging-package-')); +const stagingURL = 'https://staging.dcs.dedaluslabs.ai'; +mkdirSync(output, { recursive: true }); + +const patchBuild = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + patchBuild(path); + continue; + } + if (!/\.(js|map)$/.test(entry.name)) continue; + const source = readFileSync(path, 'utf8'); + const staged = source + .replaceAll('https://dcs.dedaluslabs.ai', stagingURL) + .replace( + /(['"])[0-9]+\.[0-9]+\.[0-9]+\1(?=;?,?\s*\/\/ x-release-please-version)/g, + (_, quote) => quote + version + quote, + ); + writeFileSync(path, staged); + } +}; + +try { + const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + for (const name of ['dist', 'api.md', 'man', 'SKILL.md', 'README.md', 'LICENSE']) { + const source = join(root, name); + if (existsSync(source)) cpSync(source, join(staging, name), { recursive: true }); + } + if (!existsSync(join(staging, 'dist/esm/bin.js'))) + throw new Error('build the CLI before packaging'); + patchBuild(join(staging, 'dist')); + manifest.version = version; + manifest.private = true; + delete manifest.scripts; + delete manifest.devDependencies; + writeFileSync(join(staging, 'package.json'), JSON.stringify(manifest, null, 2) + '\n'); + execFileSync('pnpm', ['pack', '--pack-destination', output], { cwd: staging, stdio: 'pipe' }); + const filename = manifest.name.replace('@', '').replace('/', '-') + '-' + version + '.tgz'; + const path = join(output, filename); + const bytes = readFileSync(path); + const record = { + filename, + version, + private: true, + default_api: stagingURL, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; + writeFileSync(path + '.json', JSON.stringify(record, null, 2) + '\n'); + process.stdout.write(JSON.stringify({ path, ...record }, null, 2) + '\n'); +} finally { + rmSync(staging, { recursive: true, force: true }); +} diff --git a/scripts/publish-npm.mjs b/scripts/publish-npm.mjs new file mode 100644 index 0000000..894443f --- /dev/null +++ b/scripts/publish-npm.mjs @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +/** Package visibility this SDK publishes with, from `publish.npm.access`. */ +const ACCESS = 'public'; + +/** + * Dist-tag a stable release lands on, from `publish.npm.tag`. `null` leaves npm on its own default, + * `latest`. A prerelease derives its own tag below and never reads this. + */ +const STABLE_DIST_TAG = null; + +/** Reads the package identity once so every registry operation uses the same metadata. */ +export const readPackageMetadata = () => { + const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + if (typeof packageJson.name !== 'string' || packageJson.name.length === 0) { + throw new Error('Missing package name in package.json'); + } + if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) { + throw new Error('Missing package version in package.json'); + } + return { name: packageJson.name, version: packageJson.version }; +}; + +/** Derives an npm dist-tag from a semver prerelease while leaving other npm-handled versions unchanged. */ +export const npmDistTagForVersion = (version) => { + // Calendar versions such as `2026-02-23` use hyphens without denoting a semver prerelease. + // Only a numeric three-part core followed by `-` is interpreted as a prerelease here; npm + // remains the source of truth for whether the complete package version is publishable. + const prerelease = /^\d+\.\d+\.\d+-([^+]+)(?:\+.*)?$/.exec(version)?.[1]; + if (!prerelease) return undefined; + const candidate = prerelease.split('.')[0]; + return /^[a-z][a-z0-9-]*$/.test(candidate) ? candidate : 'next'; +}; + +/** Runs one npm command with inherited CI credentials and output. */ +const runNpm = (args, stdio = 'inherit') => spawnSync('npm', args, { stdio }); + +/** Publishes the repository package once, applying a derived prerelease dist-tag when needed. */ +export const publishNpm = (runner = runNpm, metadata = readPackageMetadata()) => { + const { name, version } = metadata; + const packageSpec = `${name}@${version}`; + if (runner(['view', packageSpec, 'version'], 'ignore').status === 0) { + console.log(`${packageSpec} already published to npm, skipping`); + return; + } + + // A prerelease's own tag wins over the configured one: `publish.npm.tag` names where *stable* + // releases land, so honouring it for a prerelease would point that tag at a prerelease build. + // + // A derived tag naming a stable line is suffixed rather than used as-is, because the two collide: + // `npmDistTagForVersion` falls back to `next` for any prerelease identifier it cannot use as a + // tag, so on a package configured with `tag: 'next'` a `1.3.0-RC.1` would otherwise publish over + // the stable line it names. + // + // Both lines are protected, not just the configured one. `latest` is what a bare `npm install` + // resolves whether or not `publish.npm.tag` moves stable releases elsewhere, so a `1.0.0-latest.1` + // on a package configured with `tag: 'next'` must not take it either. + const derived = npmDistTagForVersion(version); + const stableTags = [STABLE_DIST_TAG ?? 'latest', 'latest']; + const tag = + derived === undefined + ? STABLE_DIST_TAG + : stableTags.includes(derived) + ? `${derived}-prerelease` + : derived; + const args = ['publish', '--access', ACCESS, ...(tag ? ['--tag', tag] : [])]; + console.log( + tag ? `Publishing ${packageSpec} to npm with dist-tag ${tag}` : `Publishing ${packageSpec} to npm`, + ); + const result = runner(args); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`npm publish failed with exit code ${result.status ?? 'unknown'}`); +}; + +/** Runs publication only when Node executes this file directly, keeping its helpers testable. */ +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) publishNpm(); diff --git a/scripts/run b/scripts/run deleted file mode 100755 index 065b67a..0000000 --- a/scripts/run +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/dedalus-labs/dedalus-go,github.com/stainless-sdks/dedalus-go" - -go run ./cmd/dedalus "$@" diff --git a/scripts/test b/scripts/test deleted file mode 100755 index a8cd253..0000000 --- a/scripts/test +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/dedalus-labs/dedalus-go,github.com/stainless-sdks/dedalus-go" - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -NC='\033[0m' # No Color - -MOCK_PORT="${STAINLESS_MOCK_PORT:-4010}" - -function steady_is_running() { - curl --silent "http://127.0.0.1:$MOCK_PORT/_x-steady/health" >/dev/null 2>&1 -} - -kill_server_on_port() { - pids=$(lsof -t -i tcp:"$1" || echo "") - if [ "$pids" != "" ]; then - kill "$pids" - echo "Stopped $pids." - fi -} - -function is_overriding_api_base_url() { - [ -n "${TEST_API_BASE_URL:-}" ] -} - -if ! is_overriding_api_base_url && ! steady_is_running ; then - # When we exit this script, make sure to kill the background mock server process - trap 'kill_server_on_port "$MOCK_PORT"' EXIT - - # Start the dev server - ./scripts/mock --daemon -fi - -if is_overriding_api_base_url ; then - echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" - echo -elif ! steady_is_running ; then - echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Steady server" - echo -e "running against your OpenAPI spec." - echo - echo -e "To run the server, pass in the path or url of your OpenAPI" - echo -e "spec to the steady command:" - echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p $MOCK_PORT --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" - echo - - exit 1 -else - echo -e "${GREEN}✔ Mock steady server is running with your OpenAPI spec${NC}" - echo -fi - -if [ -n "${STAINLESS_MOCK_PORT:-}" ] && ! is_overriding_api_base_url ; then - export TEST_API_BASE_URL="http://127.0.0.1:$MOCK_PORT" -fi - -echo "==> Running tests" -go test ./... "$@" - -echo "==> Checking tests on Windows" -GOARCH=amd64 GOOS=windows go test -c ./... "$@" -# `go test -c` produces a bunch of .exe files; make sure to clean those up -find . -name "*.test.exe" -exec rm {} \; diff --git a/scripts/unlink b/scripts/unlink deleted file mode 100755 index e53990a..0000000 --- a/scripts/unlink +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -echo "==> Unlinking with local directory" -go mod edit -dropreplace github.com/dedalus-labs/dedalus-go diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh deleted file mode 100755 index 81fdc42..0000000 --- a/scripts/utils/upload-artifact.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -set -exuo pipefail - -BINARY_NAME="dedalus" -DIST_DIR="dist" -FILENAME="dist.zip" - -files=() -while IFS= read -r -d '' file; do - files+=("$file") -done < <(find "$DIST_DIR" -type f \( \ - -path "*amd64*/$BINARY_NAME" -o \ - -path "*arm64*/$BINARY_NAME" -o \ - -path "*amd64*/${BINARY_NAME}.exe" -o \ - -path "*arm64*/${BINARY_NAME}.exe" \ - \) -print0) - -if [[ ${#files[@]} -eq 0 ]]; then - echo -e "\033[31mNo binaries found for packaging.\033[0m" - exit 1 -fi - -rm -f "${DIST_DIR}/${FILENAME}" - -while IFS= read -r -d '' dir; do - printf "Remove the quarantine attribute before running the executable:\n\nxattr -d com.apple.quarantine %s\n" \ - "$BINARY_NAME" >"${dir}/README.txt" - files+=("${dir}/README.txt") -done < <(find "$DIST_DIR" -type d -path '*macos*' -print0) - -relative_files=() -for file in "${files[@]}"; do - relative_files+=("${file#"${DIST_DIR}"/}") -done - -(cd "$DIST_DIR" && zip -r "$FILENAME" "${relative_files[@]}") - -RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ - -H "Authorization: Bearer $AUTH" \ - -H "Content-Type: application/json") - -SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') - -if [[ "$SIGNED_URL" == "null" ]]; then - echo -e "\033[31mFailed to get signed URL.\033[0m" - exit 1 -fi - -UPLOAD_RESPONSE=$(curl -v -X PUT \ - -H "Content-Type: application/zip" \ - --data-binary "@${DIST_DIR}/${FILENAME}" "$SIGNED_URL" 2>&1) - -if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then - echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: Download and unzip: 'https://pkg.stainless.com/s/dedalus-cli/$SHA'. On macOS, run 'xattr -d com.apple.quarantine {executable name}'.\033[0m" -else - echo -e "\033[31mFailed to upload artifact.\033[0m" - exit 1 -fi diff --git a/scripts/verify-staging-package.mjs b/scripts/verify-staging-package.mjs new file mode 100644 index 0000000..697b6bd --- /dev/null +++ b/scripts/verify-staging-package.mjs @@ -0,0 +1,57 @@ +// Exercise the distributable package, including its runtime dependencies and staging defaults. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const root = process.cwd(); +const run = (file, args, options = {}) => + execFileSync(file, args, { cwd: root, encoding: 'utf8', ...options }); +const base = run(process.execPath, ['dist/esm/bin.js', '--version']).trim(); +const build = process.env.GITHUB_RUN_NUMBER ?? String(Date.now()); +const attempt = process.env.GITHUB_RUN_ATTEMPT ?? '1'; +const version = `${base}-staging.ci.${build}.${attempt}`; +const record = JSON.parse(run(process.execPath, ['scripts/package-staging.mjs', version])); +const directory = mkdtempSync(join(tmpdir(), 'dedalus-installed-package-')); +try { + writeFileSync(join(directory, 'package.json'), '{"private":true}\n'); + run('pnpm', ['add', '--ignore-scripts', record.path], { cwd: directory, stdio: 'inherit' }); + const installed = join(directory, 'node_modules/dedalus-cli'); + const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8')); + assert.equal(manifest.private, true); + assert.equal(manifest.version, version); + const binary = resolve(installed, manifest.bin.dedalus); + const cleanEnv = { PATH: process.env.PATH }; + assert.equal(run(process.execPath, [binary, '--version'], { env: cleanEnv }).trim(), version); + const doctor = JSON.parse(run(process.execPath, [binary, 'doctor', '--json'], { env: cleanEnv })); + assert.equal(doctor.api_origin, 'https://staging.dcs.dedaluslabs.ai'); + const sdkModule = JSON.stringify(pathToFileURL(join(installed, 'dist/esm/sdk/index.js')).href); + const sdkOrigin = run( + process.execPath, + [ + '--input-type=module', + '-e', + `import SDK from ${sdkModule}; process.stdout.write(new SDK({apiKey:'test-key'}).baseURL);`, + ], + { env: cleanEnv }, + ); + assert.equal(sdkOrigin, 'https://staging.dcs.dedaluslabs.ai'); + run( + process.execPath, + [ + '--test', + ...readdirSync('tests') + .filter((name) => /^feedback-.*\.test\.mjs$/.test(name)) + .map((name) => join('tests', name)), + ], + { + env: { ...process.env, FEEDBACK_PACKAGE_ROOT: installed }, + stdio: 'inherit', + }, + ); + process.stdout.write(`Verified ${record.filename}\nSHA256 ${record.sha256}\n`); +} finally { + rmSync(directory, { recursive: true, force: true }); +} diff --git a/src/bin.ts b/src/bin.ts new file mode 100644 index 0000000..315627b --- /dev/null +++ b/src/bin.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { run } from './index.js'; + +void run(); diff --git a/src/cli/completions.ts b/src/cli/completions.ts new file mode 100644 index 0000000..396b06c --- /dev/null +++ b/src/cli/completions.ts @@ -0,0 +1,15 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +// Shell completion scripts for this CLI, rendered from the same command table the program is +// built from. `dedalus completion ` prints one of them. + +const bash = + "# File generated from our OpenAPI spec by Scalar. Do not edit.\n\n__dedalus_known_command() {\n case \"$1\" in\n 'completion'|'machines'|'machines list'|'machines create'|'machines retrieve'|'machines update'|'machines delete'|'machines sleep'|'machines wake'|'machines reboot'|'machines:ssh'|'machines:ssh list'|'machines:ssh create'|'machines:ssh retrieve'|'machines:ssh delete'|'machines:executions'|'machines:executions list'|'machines:executions create'|'machines:executions retrieve'|'machines:executions delete'|'machines:executions output'|'machines:executions events'|'machines:executions:logs'|'machines:executions:logs retrieve'|'machines:executions:logs reauthorize'|'machines:executions:logs create-token'|'machines:autoresizing'|'machines:autoresizing retrieve'|'machines:autoresizing update'|'organization:autoresizing'|'organization:autoresizing retrieve'|'organization:autoresizing update'|'login'|'logout') return 0 ;;\n esac\n return 1\n}\n\n__dedalus_path_flag() {\n return 1\n}\n\n__dedalus_completion() {\n local cur prev node candidate words line index\n COMPREPLY=()\n cur=\"${COMP_WORDS[COMP_CWORD]}\"\n prev=''\n if (( COMP_CWORD > 0 )); then prev=\"${COMP_WORDS[COMP_CWORD-1]}\"; fi\n node=''\n for (( index = 1; index < COMP_CWORD; index++ )); do\n case \"${COMP_WORDS[index]}\" in\n -*) continue ;;\n esac\n if [ -z \"$node\" ]; then\n candidate=\"${COMP_WORDS[index]}\"\n else\n candidate=\"$node ${COMP_WORDS[index]}\"\n fi\n if __dedalus_known_command \"$candidate\"; then node=\"$candidate\"; fi\n done\n\n case \"$node\" in\n '') words='machines machines:ssh machines:executions machines:executions:logs machines:autoresizing organization:autoresizing login logout completion help --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --version --help' ;;\n 'completion') words='bash zsh fish --help' ;;\n 'machines') words='list create retrieve update delete sleep wake reboot --help' ;;\n 'machines list') words='--limit --cursor --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --max-items --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines create') words='--autosleep --memory-mib --storage-gib --vcpu --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines retrieve') words='--machine-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines update') words='--machine-id --autosleep --memory-mib --storage-gib --vcpu --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines delete') words='--machine-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines sleep') words='--machine-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines wake') words='--machine-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines reboot') words='--machine-id --force --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:ssh') words='list create retrieve delete --help' ;;\n 'machines:ssh list') words='--machine-id --limit --cursor --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --max-items --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:ssh create') words='--machine-id --public-key --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:ssh retrieve') words='--machine-id --session-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:ssh delete') words='--machine-id --session-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:executions') words='list create retrieve delete output events --help' ;;\n 'machines:executions list') words='--machine-id --limit --cursor --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --max-items --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:executions create') words='--machine-id --command --cwd --env --stdin --timeout-ms --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:executions retrieve') words='--machine-id --execution-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:executions delete') words='--machine-id --execution-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:executions output') words='--machine-id --execution-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:executions events') words='--machine-id --execution-id --limit --cursor --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --max-items --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:executions:logs') words='retrieve reauthorize create-token --help' ;;\n 'machines:executions:logs retrieve') words='--machine-id --execution-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:executions:logs reauthorize') words='--machine-id --execution-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:executions:logs create-token') words='--machine-id --execution-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:autoresizing') words='retrieve update --help' ;;\n 'machines:autoresizing retrieve') words='--machine-id --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'machines:autoresizing update') words='--machine-id --enabled --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'organization:autoresizing') words='retrieve update --help' ;;\n 'organization:autoresizing retrieve') words='--base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'organization:autoresizing update') words='--enabled --base-url --timeout --max-retries --format --format-error --transform --transform-error --raw-output --debug --api-key --x-api-key --dedalus-org-id --help' ;;\n 'login') words='--base-url --flow --help' ;;\n 'logout') words='--base-url --all --help' ;;\n *) words='' ;;\n esac\n\n if __dedalus_path_flag \"$node $prev\"; then\n compopt -o filenames 2>/dev/null\n while IFS= read -r line; do\n COMPREPLY+=( \"$line\" )\n done < <(compgen -f -- \"$cur\")\n return\n fi\n\n while IFS= read -r line; do\n COMPREPLY+=( \"$line\" )\n done < <(compgen -W \"$words\" -- \"$cur\")\n}\n\ncomplete -F __dedalus_completion 'dedalus'\n"; + +const zsh = + "#compdef dedalus\n# File generated from our OpenAPI spec by Scalar. Do not edit.\n\n__dedalus_known_command() {\n case \"$1\" in\n 'completion'|'machines'|'machines list'|'machines create'|'machines retrieve'|'machines update'|'machines delete'|'machines sleep'|'machines wake'|'machines reboot'|'machines:ssh'|'machines:ssh list'|'machines:ssh create'|'machines:ssh retrieve'|'machines:ssh delete'|'machines:executions'|'machines:executions list'|'machines:executions create'|'machines:executions retrieve'|'machines:executions delete'|'machines:executions output'|'machines:executions events'|'machines:executions:logs'|'machines:executions:logs retrieve'|'machines:executions:logs reauthorize'|'machines:executions:logs create-token'|'machines:autoresizing'|'machines:autoresizing retrieve'|'machines:autoresizing update'|'organization:autoresizing'|'organization:autoresizing retrieve'|'organization:autoresizing update'|'login'|'logout') return 0 ;;\n esac\n return 1\n}\n\n__dedalus_completion() {\n local node candidate\n local -a subcommands options\n local -i index matched\n node=''\n matched=1\n for (( index = 2; index < CURRENT; index++ )); do\n case ${words[index]} in\n -*) continue ;;\n esac\n if [[ -z $node ]]; then\n candidate=${words[index]}\n else\n candidate=\"$node ${words[index]}\"\n fi\n if __dedalus_known_command \"$candidate\"; then\n node=$candidate\n matched=$index\n fi\n done\n\n subcommands=()\n options=()\n case \"$node\" in\n '')\n subcommands=('machines:machines commands' 'machines\\:ssh:machines\\:ssh commands' 'machines\\:executions:machines\\:executions commands' 'machines\\:executions\\:logs:machines\\:executions\\:logs commands' 'machines\\:autoresizing:machines\\:autoresizing commands' 'organization\\:autoresizing:organization\\:autoresizing commands' 'login:Sign in and save credentials for later commands' 'logout:Forget saved credentials' 'completion:Print a shell completion script' 'help:Show help for a command')\n options=('--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--version[Print the CLI version]' '--help[Show help for this command]')\n ;;\n 'completion')\n subcommands=('bash:Print a bash completion script' 'zsh:Print a zsh completion script' 'fish:Print a fish completion script')\n options=('--help[Show help for this command]')\n ;;\n 'machines')\n subcommands=('list:List machines' 'create:Create machine' 'retrieve:Get machine' 'update:Update machine' 'delete:Destroy machine' 'sleep:Sleep a running machine' 'wake:Wake a sleeping machine' 'reboot:Reboot a machine with fresh memory')\n options=('--help[Show help for this command]')\n ;;\n 'machines list')\n options=('--limit[The limit query parameter]:value:' '--cursor[The cursor query parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--max-items[Maximum number of streamed items to print; use -1 for unlimited]:value:' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines create')\n options=('--autosleep[Idle window before autosleep]:value:' '--memory-mib[Memory in MiB]:value:' '--storage-gib[Storage in GiB]:value:' '--vcpu[CPU in vCPUs]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines retrieve')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines update')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--autosleep[Idle window before autosleep]:value:' '--memory-mib[Memory in MiB]:value:' '--storage-gib[Storage in GiB]:value:' '--vcpu[CPU in vCPUs]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines delete')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines sleep')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines wake')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines reboot')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--force[Recover from the last committed filesystem checkpoint without guest cooperation]' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:ssh')\n subcommands=('list:List SSH sessions' 'create:Create SSH session' 'retrieve:Get SSH session' 'delete:Delete SSH session')\n options=('--help[Show help for this command]')\n ;;\n 'machines:ssh list')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--limit[The limit query parameter]:value:' '--cursor[The cursor query parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--max-items[Maximum number of streamed items to print; use -1 for unlimited]:value:' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:ssh create')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--public-key[The public-key body parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:ssh retrieve')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--session-id[The session-id path parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:ssh delete')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--session-id[The session-id path parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:executions')\n subcommands=('list:List executions' 'create:Create execution' 'retrieve:Get execution' 'delete:Delete execution' 'output:Get execution output' 'events:List execution events')\n options=('--help[Show help for this command]')\n ;;\n 'machines:executions list')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--limit[The limit query parameter]:value:' '--cursor[The cursor query parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--max-items[Maximum number of streamed items to print; use -1 for unlimited]:value:' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:executions create')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--command[The command body parameter]:value:' '--cwd[The cwd body parameter]:value:' '--env[The env body parameter]:value:' '--stdin[The stdin body parameter]:value:' '--timeout-ms[The timeout-ms body parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:executions retrieve')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--execution-id[The execution-id path parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:executions delete')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--execution-id[The execution-id path parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:executions output')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--execution-id[The execution-id path parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:executions events')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--execution-id[The execution-id path parameter]:value:' '--limit[The limit query parameter]:value:' '--cursor[The cursor query parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--max-items[Maximum number of streamed items to print; use -1 for unlimited]:value:' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:executions:logs')\n subcommands=('retrieve:Get execution log status' 'reauthorize:Reauthorize execution log publication' 'create-token:Create execution log read token')\n options=('--help[Show help for this command]')\n ;;\n 'machines:executions:logs retrieve')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--execution-id[The execution-id path parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:executions:logs reauthorize')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--execution-id[The execution-id path parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:executions:logs create-token')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--execution-id[The execution-id path parameter]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:autoresizing')\n subcommands=('retrieve:Read this machine'\\''s RAM autoresizing settings' 'update:Set this machine'\\''s RAM autoresizing settings')\n options=('--help[Show help for this command]')\n ;;\n 'machines:autoresizing retrieve')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'machines:autoresizing update')\n options=('--machine-id[Bare, lowercase, hyphenated Machine UUID]:value:' '--enabled[The enabled body parameter]' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'organization:autoresizing')\n subcommands=('retrieve:Read organization RAM autoresizing policy' 'update:Set organization RAM autoresizing policy')\n options=('--help[Show help for this command]')\n ;;\n 'organization:autoresizing retrieve')\n options=('--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'organization:autoresizing update')\n options=('--enabled[Allow automatic RAM increases for all organization machines]' '--base-url[Override the base URL for API requests]:value:' '--timeout[Request timeout in milliseconds]:value:' '--max-retries[Number of retries for retryable failures]:value:' '--format[Output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--format-error[Error output format\\: auto, json, jsonl, pretty, raw, toon, yaml]:value:' '--transform[Dot-path transform for data output]:value:' '--transform-error[Dot-path transform for error output]:value:' '--raw-output[Print transformed string values without JSON quotes]' '--debug[Enable SDK debug logging]' '--api-key[Dedalus API key or short-lived delegated access token in Authorization\\: Bearer ]:value:' '--x-api-key[Dedalus API key]:value:' '--dedalus-org-id[Optional organization assertion]:value:' '--help[Show help for this command]')\n ;;\n 'login')\n options=('--base-url[Sign in against this base URL instead of the default]:value:' '--flow[Sign-in flow to use\\: api-key, x-api-key]:value:' '--help[Show help for this command]')\n ;;\n 'logout')\n options=('--base-url[Forget the credentials saved for this base URL]:value:' '--all[Forget every saved credential, for every base URL]' '--help[Show help for this command]')\n ;;\n esac\n\n words=( \"$words[1]\" \"${(@)words[matched+1,-1]}\" )\n (( CURRENT -= matched - 1 ))\n\n (( ${#options} )) && _arguments -s -S $options\n (( ${#subcommands} )) && _describe -t commands 'command' subcommands\n}\n\nif [[ ${zsh_eval_context[-1]} == loadautofunc ]]; then\n __dedalus_completion \"$@\"\nelse\n compdef __dedalus_completion 'dedalus'\nfi\n"; + +const fish = + "# File generated from our OpenAPI spec by Scalar. Do not edit.\n\nset -g __dedalus_paths 'completion' 'machines' 'machines list' 'machines create' 'machines retrieve' 'machines update' 'machines delete' 'machines sleep' 'machines wake' 'machines reboot' 'machines:ssh' 'machines:ssh list' 'machines:ssh create' 'machines:ssh retrieve' 'machines:ssh delete' 'machines:executions' 'machines:executions list' 'machines:executions create' 'machines:executions retrieve' 'machines:executions delete' 'machines:executions output' 'machines:executions events' 'machines:executions:logs' 'machines:executions:logs retrieve' 'machines:executions:logs reauthorize' 'machines:executions:logs create-token' 'machines:autoresizing' 'machines:autoresizing retrieve' 'machines:autoresizing update' 'organization:autoresizing' 'organization:autoresizing retrieve' 'organization:autoresizing update' 'login' 'logout'\n\nfunction __dedalus_path\n set -l path ''\n set -l tokens (commandline -opc)\n for token in $tokens[2..-1]\n if string match -q -- '-*' $token\n continue\n end\n set -l candidate\n if test -z \"$path\"\n set candidate $token\n else\n set candidate \"$path $token\"\n end\n if contains -- $candidate $__dedalus_paths\n set path $candidate\n end\n end\n echo $path\nend\n\nfunction __dedalus_at --argument-names index\n set -l current (__dedalus_path)\n if test \"$index\" -eq 0\n test -z \"$current\"\n else\n test \"$current\" = \"$__dedalus_paths[$index]\"\n end\nend\n\ncomplete -c 'dedalus' -f\ncomplete -c 'dedalus' -n '__dedalus_at 0' -a 'machines' -d 'machines commands'\ncomplete -c 'dedalus' -n '__dedalus_at 0' -a 'machines:ssh' -d 'machines:ssh commands'\ncomplete -c 'dedalus' -n '__dedalus_at 0' -a 'machines:executions' -d 'machines:executions commands'\ncomplete -c 'dedalus' -n '__dedalus_at 0' -a 'machines:executions:logs' -d 'machines:executions:logs commands'\ncomplete -c 'dedalus' -n '__dedalus_at 0' -a 'machines:autoresizing' -d 'machines:autoresizing commands'\ncomplete -c 'dedalus' -n '__dedalus_at 0' -a 'organization:autoresizing' -d 'organization:autoresizing commands'\ncomplete -c 'dedalus' -n '__dedalus_at 0' -a 'login' -d 'Sign in and save credentials for later commands'\ncomplete -c 'dedalus' -n '__dedalus_at 0' -a 'logout' -d 'Forget saved credentials'\ncomplete -c 'dedalus' -n '__dedalus_at 0' -a 'completion' -d 'Print a shell completion script'\ncomplete -c 'dedalus' -n '__dedalus_at 0' -a 'help' -d 'Show help for a command'\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'version' -d 'Print the CLI version'\ncomplete -c 'dedalus' -n '__dedalus_at 0' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 1' -a 'bash' -d 'Print a bash completion script'\ncomplete -c 'dedalus' -n '__dedalus_at 1' -a 'zsh' -d 'Print a zsh completion script'\ncomplete -c 'dedalus' -n '__dedalus_at 1' -a 'fish' -d 'Print a fish completion script'\ncomplete -c 'dedalus' -n '__dedalus_at 1' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 2' -a 'list' -d 'List machines'\ncomplete -c 'dedalus' -n '__dedalus_at 2' -a 'create' -d 'Create machine'\ncomplete -c 'dedalus' -n '__dedalus_at 2' -a 'retrieve' -d 'Get machine'\ncomplete -c 'dedalus' -n '__dedalus_at 2' -a 'update' -d 'Update machine'\ncomplete -c 'dedalus' -n '__dedalus_at 2' -a 'delete' -d 'Destroy machine'\ncomplete -c 'dedalus' -n '__dedalus_at 2' -a 'sleep' -d 'Sleep a running machine'\ncomplete -c 'dedalus' -n '__dedalus_at 2' -a 'wake' -d 'Wake a sleeping machine'\ncomplete -c 'dedalus' -n '__dedalus_at 2' -a 'reboot' -d 'Reboot a machine with fresh memory'\ncomplete -c 'dedalus' -n '__dedalus_at 2' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'limit' -d 'The limit query parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'cursor' -d 'The cursor query parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'max-items' -d 'Maximum number of streamed items to print; use -1 for unlimited' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 3' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'autosleep' -d 'Idle window before autosleep' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'memory-mib' -d 'Memory in MiB' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'storage-gib' -d 'Storage in GiB' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'vcpu' -d 'CPU in vCPUs' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 4' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 5' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'autosleep' -d 'Idle window before autosleep' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'memory-mib' -d 'Memory in MiB' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'storage-gib' -d 'Storage in GiB' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'vcpu' -d 'CPU in vCPUs' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 6' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 7' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 8' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 9' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'force' -d 'Recover from the last committed filesystem checkpoint without guest cooperation'\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 10' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 11' -a 'list' -d 'List SSH sessions'\ncomplete -c 'dedalus' -n '__dedalus_at 11' -a 'create' -d 'Create SSH session'\ncomplete -c 'dedalus' -n '__dedalus_at 11' -a 'retrieve' -d 'Get SSH session'\ncomplete -c 'dedalus' -n '__dedalus_at 11' -a 'delete' -d 'Delete SSH session'\ncomplete -c 'dedalus' -n '__dedalus_at 11' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'limit' -d 'The limit query parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'cursor' -d 'The cursor query parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'max-items' -d 'Maximum number of streamed items to print; use -1 for unlimited' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 12' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'public-key' -d 'The public-key body parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 13' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'session-id' -d 'The session-id path parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 14' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'session-id' -d 'The session-id path parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 15' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 16' -a 'list' -d 'List executions'\ncomplete -c 'dedalus' -n '__dedalus_at 16' -a 'create' -d 'Create execution'\ncomplete -c 'dedalus' -n '__dedalus_at 16' -a 'retrieve' -d 'Get execution'\ncomplete -c 'dedalus' -n '__dedalus_at 16' -a 'delete' -d 'Delete execution'\ncomplete -c 'dedalus' -n '__dedalus_at 16' -a 'output' -d 'Get execution output'\ncomplete -c 'dedalus' -n '__dedalus_at 16' -a 'events' -d 'List execution events'\ncomplete -c 'dedalus' -n '__dedalus_at 16' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'limit' -d 'The limit query parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'cursor' -d 'The cursor query parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'max-items' -d 'Maximum number of streamed items to print; use -1 for unlimited' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 17' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'command' -d 'The command body parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'cwd' -d 'The cwd body parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'env' -d 'The env body parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'stdin' -d 'The stdin body parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'timeout-ms' -d 'The timeout-ms body parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 18' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'execution-id' -d 'The execution-id path parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 19' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'execution-id' -d 'The execution-id path parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 20' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'execution-id' -d 'The execution-id path parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 21' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'execution-id' -d 'The execution-id path parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'limit' -d 'The limit query parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'cursor' -d 'The cursor query parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'max-items' -d 'Maximum number of streamed items to print; use -1 for unlimited' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 22' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 23' -a 'retrieve' -d 'Get execution log status'\ncomplete -c 'dedalus' -n '__dedalus_at 23' -a 'reauthorize' -d 'Reauthorize execution log publication'\ncomplete -c 'dedalus' -n '__dedalus_at 23' -a 'create-token' -d 'Create execution log read token'\ncomplete -c 'dedalus' -n '__dedalus_at 23' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'execution-id' -d 'The execution-id path parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 24' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'execution-id' -d 'The execution-id path parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 25' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'execution-id' -d 'The execution-id path parameter' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 26' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 27' -a 'retrieve' -d 'Read this machine\\'s RAM autoresizing settings'\ncomplete -c 'dedalus' -n '__dedalus_at 27' -a 'update' -d 'Set this machine\\'s RAM autoresizing settings'\ncomplete -c 'dedalus' -n '__dedalus_at 27' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 28' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'machine-id' -d 'Bare, lowercase, hyphenated Machine UUID' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'enabled' -d 'The enabled body parameter'\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 29' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 30' -a 'retrieve' -d 'Read organization RAM autoresizing policy'\ncomplete -c 'dedalus' -n '__dedalus_at 30' -a 'update' -d 'Set organization RAM autoresizing policy'\ncomplete -c 'dedalus' -n '__dedalus_at 30' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 31' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 31' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 31' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 31' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 31' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 31' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 31' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 31' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 31' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 31' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 31' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 31' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 31' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'enabled' -d 'Allow automatic RAM increases for all organization machines'\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'base-url' -d 'Override the base URL for API requests' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'timeout' -d 'Request timeout in milliseconds' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'max-retries' -d 'Number of retries for retryable failures' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'format' -d 'Output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'format-error' -d 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'transform' -d 'Dot-path transform for data output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'transform-error' -d 'Dot-path transform for error output' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'raw-output' -d 'Print transformed string values without JSON quotes'\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'debug' -d 'Enable SDK debug logging'\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'api-key' -d 'Dedalus API key or short-lived delegated access token in Authorization: Bearer ' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'x-api-key' -d 'Dedalus API key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'dedalus-org-id' -d 'Optional organization assertion' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 32' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 33' -l 'base-url' -d 'Sign in against this base URL instead of the default' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 33' -l 'flow' -d 'Sign-in flow to use: api-key, x-api-key' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 33' -l 'help' -d 'Show help for this command'\ncomplete -c 'dedalus' -n '__dedalus_at 34' -l 'base-url' -d 'Forget the credentials saved for this base URL' -r -f\ncomplete -c 'dedalus' -n '__dedalus_at 34' -l 'all' -d 'Forget every saved credential, for every base URL'\ncomplete -c 'dedalus' -n '__dedalus_at 34' -l 'help' -d 'Show help for this command'\n"; + +export const completions = { bash, zsh, fish } as const; diff --git a/src/cli/credentials.ts b/src/cli/credentials.ts new file mode 100644 index 0000000..4edf3f3 --- /dev/null +++ b/src/cli/credentials.ts @@ -0,0 +1,647 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { randomBytes } from 'node:crypto'; +import { + closeSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { keychainClear, keychainLookup, keychainRead, keychainStore, keychainSupported } from './keychain'; + +/** Where the credential store lives. Every field comes from the generated auth definition. */ +export type CredentialStoreLocation = { + readonly storeName: string; + readonly storeEnv: string; + /** + * Which store to use: the operating system's, a file, or the OS one with a file fallback. + * + * Resolved per call rather than once at module load so a test can point one command at a scratch + * file without the process having already decided. + */ + readonly backend: 'auto' | 'keychain' | 'file'; +}; + +/** Refresh metadata kept beside a stored OAuth access token. */ +export type StoredOAuth = { + // scalar-sdk-generator:custom-code revocation-metadata:start + /** Provider endpoint used for best-effort revocation on logout. */ + readonly revocationUrl?: string; + // scalar-sdk-generator:custom-code revocation-metadata:end + readonly refreshToken?: string; + /** Unix epoch milliseconds the access token stops being usable, when the server said so. */ + readonly expiresAt?: number; + /** The endpoint a new access token is requested from. */ + readonly refreshUrl?: string; + /** Legacy refresh endpoint field emitted before `refreshUrl` was kept separately. */ + readonly tokenUrl?: string; + readonly clientId?: string; +}; + +/** Credentials stored for one base URL, keyed by client-option name. */ +export type StoredProfile = { + readonly credentials?: Record; + readonly oauth?: Record; + /** + * Which store actually holds this profile's secret. + * + * Only ever set on the *file* entry, which is why it can be trusted: it is written by the same + * operation that placed the secret, so nothing later has to infer where it went. Inferring it from + * a lookup cannot work — a helper that is not running and a helper reporting "no such item" are + * the same answer, and reading the first as the second is how a `logout` comes to report success + * over a credential that is still live. + * + * Absent means the file, which is all a store written before the OS backend existed can hold. + */ + readonly backend?: StoreBackend; +}; + +export type CredentialStoreFile = { + readonly version: number; + readonly profiles: Record; +}; + +const STORE_VERSION = 1; + +const EMPTY_STORE: CredentialStoreFile = { version: STORE_VERSION, profiles: {} }; + +// A generated CLI can be invoked by two shells at once. The short synchronous critical sections +// below need a cross-process lock, not an in-memory mutex that only coordinates one invocation. +const lockPause = new Int32Array(new SharedArrayBuffer(4)); +const STORE_LOCK_STALE_MS = 30_000; +// Consecutive filesystem errors while taking the lock before the attempt is abandoned. A lost race +// produces one; a path the filesystem will not answer for produces them without end. +const STORE_LOCK_MAX_ERRORS = 100; + +/** + * The store file path. + * + * The environment override wins outright so a credential can be pointed at a scratch file (a test + * harness, a container with no writable home) without touching the real one. + */ +export const storePath = (location: CredentialStoreLocation): string => { + const override = process.env[location.storeEnv]; + if (override) return override; + return join(stateDirectory(), location.storeName, 'credentials.json'); +}; + +// Per-user state, not config: the file holds tokens the CLI rewrites on its own (a refreshed access +// token) rather than settings a user edits, which is exactly what XDG_STATE_HOME is for. +const stateDirectory = (): string => { + if (process.platform === 'win32') return process.env['APPDATA'] || join(homedir(), 'AppData', 'Roaming'); + return process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state'); +}; + +/** + * Reads the whole store, treating anything unreadable as empty. + * + * A missing file is the normal first-run case, and a corrupt one must not wedge every command in + * the CLI: the worst outcome of ignoring it is one `login` the user has to run again, whereas + * throwing would take out commands that were never going to need a stored credential at all. + */ +export const readStore = (location: CredentialStoreLocation): CredentialStoreFile => { + let text: string; + try { + text = readFileSync(storePath(location), 'utf8'); + } catch { + return EMPTY_STORE; + } + try { + const parsed: unknown = JSON.parse(text); + if (!parsed || typeof parsed !== 'object') return EMPTY_STORE; + const profiles = (parsed as { profiles?: unknown }).profiles; + if (!profiles || typeof profiles !== 'object') return EMPTY_STORE; + return { version: STORE_VERSION, profiles: profiles as Record }; + } catch { + return EMPTY_STORE; + } +}; + +/** + * Replaces the store file. + * + * Written to a sibling temp file and renamed so an interrupted write cannot leave a truncated store + * behind; the rename carries the temp file's narrow permissions onto the final path. + */ +export const writeStore = (location: CredentialStoreLocation, store: CredentialStoreFile): void => { + assertLockHeld(location); + const path = storePath(location); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + // `wx` and a random suffix together, not `writeFileSync` with a mode. That call applies the mode + // only when it creates the file and follows a symlink when it does not, so a predictable temp name + // — the pid was the only variable — let anyone who could write this directory pre-create the path + // and be handed the credentials, or leave it `0644` and read them in the window before a chmod. + // Creating it exclusively fails instead, and the descriptor is what is written, so there is no + // second lookup of the name between the check and the write. + const temp = path + '.' + randomBytes(8).toString('hex') + '.tmp'; + const descriptor = openSync(temp, 'wx', 0o600); + try { + writeFileSync(descriptor, JSON.stringify(store, null, 2) + '\n'); + } finally { + closeSync(descriptor); + } + try { + renameSync(temp, path); + } catch (error) { + // The rename is what publishes the store; a temp file left behind after it failed is a copy of + // the credentials under a name nothing will ever read again. + rmSync(temp, { force: true }); + throw error; + } +}; + +/** Deletes the store file entirely. */ +export const deleteStore = (location: CredentialStoreLocation): void => { + assertLockHeld(location); + rmSync(storePath(location), { force: true }); +}; + +/** + * Serializes index rewrites across CLI processes. + * + * Token acquisition happens before the lock. The lock spans the keychain operation and its index + * update together, so a concurrent logout cannot clear a newly written secret while it still sees + * the old index entry. Long-running keychain helpers renew the lock lease before each command. + */ +const withStoreLock = (location: CredentialStoreLocation, operation: () => T): T => { + const path = storePath(location); + const lockPath = path + '.lock'; + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + // Written into the lock file and checked back on release. A lock this process was too slow to + // renew can be reclaimed as stale by another one, and after that it is no longer this process's + // to delete: unlinking it on the way out would drop the new holder's lock and leave two processes + // rewriting the store at once, where `writeStore` replaces the file wholesale and one of the two + // profiles is simply lost. + const token = randomBytes(16).toString('hex'); + let descriptor: number | undefined; + let consecutiveErrors = 0; + while (descriptor === undefined) { + try { + descriptor = openSync(lockPath, 'wx', 0o600); + try { + writeFileSync(descriptor, token); + } catch (error) { + // An unstamped lock is worse than none: this process is about to throw, its release will + // never run, and every other CLI would wait out the full stale window on a file nobody + // holds. Take it back out first. + closeSync(descriptor); + // And give the handle back, so the loop guard still reads "not acquired". Leaving it set + // would let an error the outer catch decided to swallow fall out of the loop and run the + // operation with no lock at all, then close this handle a second time on the way out. + descriptor = undefined; + rmSync(lockPath, { force: true }); + throw error; + } + } catch (error) { + if (!(error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST')) throw error; + try { + const stale = statSync(lockPath); + if (Date.now() - stale.mtimeMs > STORE_LOCK_STALE_MS) { + // Moved aside rather than unlinked outright, and only then removed. An unconditional + // `rmSync` deletes whatever is at the path *now*, which after a pause between the stat + // and the call is a fresh lock a waiter has just taken — two processes in the critical + // section. `renameSync` moves one specific directory entry, so re-checking the inode and + // mtime afterwards establishes that what moved is the file that was found stale; anything + // else is put straight back. + const salvage = lockPath + '.' + token; + renameSync(lockPath, salvage); + const moved = statSync(salvage); + if (moved.ino === stale.ino && moved.mtimeMs === stale.mtimeMs) rmSync(salvage, { force: true }); + else renameSync(salvage, lockPath); + } + consecutiveErrors = 0; + } catch { + // Either the lock was released or reclaimed between the open and the stat — the benign race, + // which the next attempt wins — or the filesystem will not answer for this path at all, which + // retrying never fixes. Only a run of them means the second, so only a run gives up. + consecutiveErrors += 1; + if (consecutiveErrors > STORE_LOCK_MAX_ERRORS) { + throw new Error('Could not take the credential store lock at ' + lockPath + '.'); + } + } + // Every path waits, including the two that used to loop straight back to `openSync`. Without + // this a lock that cannot be stat'd or unlinked spins open+stat at full tilt with nothing on + // screen — usually for a token refresh the user never asked to watch. + Atomics.wait(lockPause, 0, 0, 10); + } + } + const previous = heldLock; + heldLock = { path: lockPath, token }; + try { + return operation(); + } finally { + heldLock = previous; + closeSync(descriptor); + try { + // Only this acquisition's own lock comes off; see `token` above. + if (readFileSync(lockPath, 'utf8') === token) rmSync(lockPath, { force: true }); + } catch { + // Already gone, which is the outcome this wanted anyway. + } + } +}; + +/** The lock this process currently holds, for {@link assertLockHeld} to check a write against. */ +let heldLock: { readonly path: string; readonly token: string } | undefined; + +/** + * Refuses a store rewrite this process no longer holds the lock for. + * + * Taking the lock is not the same as still having it. A holder stopped long enough to pass + * `STORE_LOCK_STALE_MS` — a suspended laptop, a SIGSTOP, a filesystem that blocked — has its lock + * reclaimed and another process completes a whole read-modify-write; if the first then resumes and + * writes the snapshot it read beforehand, the second's profile is gone, and under the keychain + * backend its index entry was the only record of a live keyring item. `writeStore` replaces the + * file wholesale, so there is no merge to save it. Checking the stamp again here is what makes the + * window survivable: the late writer fails loudly instead of quietly winning. + */ +const assertLockHeld = (location: CredentialStoreLocation): void => { + const lock = heldLock; + if (!lock || lock.path !== storePath(location) + '.lock') return; + let stamp: string; + try { + stamp = readFileSync(lock.path, 'utf8'); + } catch { + stamp = ''; + } + if (stamp !== lock.token) { + throw new Error( + 'The credential store changed underneath this command. Nothing was written; run it again.', + ); + } +}; + +/** Renews a held lock before a keychain helper can spend up to ten seconds waiting for the OS. */ +const refreshStoreLock = (location: CredentialStoreLocation): void => { + const now = new Date(); + try { + utimesSync(storePath(location) + '.lock', now, now); + } catch { + // The lock holder may already be unwinding after an interrupted filesystem operation. + } +}; + +/** + * The key one base URL's credentials are filed under. + * + * Keying on the URL is what keeps a sandbox token from being sent to production: a request with a + * different `--base-url` looks up a different profile and simply finds nothing. + */ +export const profileKey = (baseUrl: string): string => { + const trimmed = baseUrl.replace(/\/+$/u, ''); + if (!trimmed) return 'default'; + let url: URL; + try { + url = new URL(trimmed); + } catch { + // Not a URL this can take apart — an unusual `--base-url`, or the `default` case above. Keyed + // verbatim, which still files it consistently; it simply gets none of the normalization below. + return trimmed; + } + // Credentials never belong in the key. A `--base-url https://user:pass@host` would otherwise put + // them in the keychain account, which the Linux helper passes on its command line for any other + // process to read — the one thing this whole module is arranged to prevent. + url.username = ''; + url.password = ''; + // `URL` lower-cases the host and drops a port that is the scheme's default, so two spellings of + // one host cannot end up as two profiles — a `login` under one and a request under the other + // would look, to the user, like the sign-in simply had not taken. + url.hash = ''; + url.search = ''; + return url.toString().replace(/\/+$/u, ''); +}; + +/** Where a profile's secrets actually live. */ +export type StoreBackend = 'keychain' | 'file'; + +/** + * Whether this run may use the operating system's store at all. + * + * Nothing is probed here. Under `auto` the real operation decides: a keychain write that fails + * falls back to the file, and a keychain read that finds nothing falls through to it. That is both + * cheaper than a probe — which costs three subprocess spawns on every command — and more honest, + * since the thing that has to work is the operation itself, not a proxy for it. + */ +const mayUseKeychain = (location: CredentialStoreLocation): boolean => location.backend !== 'file'; + +/** Human-readable name for where a credential landed, for `login` and `logout` to print. */ +export const storeDescription = (location: CredentialStoreLocation, backend: StoreBackend): string => + backend === 'keychain' + ? 'your ' + osStoreName() + ' (the index of signed-in hosts is at ' + storePath(location) + ')' + : storePath(location); + +const osStoreName = (): string => { + if (process.platform === 'win32') return 'Windows Credential Manager'; + return 'system keyring'; +}; + +/** + * One stored profile. + * + * When the secret is in the OS store the file holds only the *index* — which base URLs are signed + * in, and for each one which store has it. That split is what makes `logout --all` possible: a + * keychain offers no portable way to list what is in it, so the set of profiles has to be recorded + * somewhere, and a list of hostnames is not a secret. + * + * The index also decides where to look, rather than the OS store being tried first and the file + * second. Trying in turn cannot tell an unreachable helper from an empty one, so it would answer + * with a file copy while a different, live secret sat in the OS store. + */ +export const readProfile = (location: CredentialStoreLocation, key: string): StoredProfile => + readProfileResult(location, key).profile; + +/** + * A profile read, and whether the store actually answered. + * + * `unreadable` says the marker points at the OS store and the helper never answered, so the empty + * profile beside it is the absence of an answer rather than the answer "nothing is stored". A read + * can treat the two alike — both send the user to `login` — but a write cannot: merging an update + * into an empty profile and storing the result replaces whatever was really under that key, so a + * keyring that is momentarily unreachable would cost the user every *other* credential filed there. + */ +const readProfileResult = ( + location: CredentialStoreLocation, + key: string, +): { readonly profile: StoredProfile; readonly unreadable: boolean } => { + const entry = indexEntry(location, key); + if (entry.backend !== 'keychain') { + // `keychain` exists to keep secrets out of a file, so one an earlier build left there is not a + // credential this may send — an organisation that chose the setting to forbid secrets at rest + // would otherwise keep authenticating from exactly the file it banned. It is left where it is + // rather than deleted, because a read is not the place to destroy anything; the next `login` + // replaces it. + return { profile: location.backend === 'keychain' ? {} : entry, unreadable: false }; + } + // Nothing falls through to the file: the index says the secret is not there. A helper that cannot + // run reads as "no credential", which sends the user to `login` rather than to a stale one — but + // it is reported alongside, so a *write* can refuse rather than overwrite what it could not see. + if (!mayUseKeychain(location)) { + // The configured backend is the file while the marker still says keychain. Replacing the entry + // is the intended migration here, not a loss, so this is not unreadable. + return { profile: {}, unreadable: false }; + } + const read = keychainRead(location.storeName, key); + if (read.secret === undefined) return { profile: {}, unreadable: read.unavailable }; + try { + const parsed: unknown = JSON.parse(read.secret); + if (parsed && typeof parsed === 'object') return { profile: parsed as StoredProfile, unreadable: false }; + } catch { + // An item this cannot parse is treated as absent, for the same reason a corrupt file is: one + // `login` again beats taking out every command that needed no credential. + } + return { profile: {}, unreadable: false }; +}; + +/** The file entry for one profile: the credential itself, or the marker saying where it really is. */ +const indexEntry = (location: CredentialStoreLocation, key: string): StoredProfile => { + const { profiles } = readStore(location); + // Own-property lookup only: a store carrying a key like `constructor` would otherwise resolve to + // something off `Object.prototype` that is not a profile at all. + if (!Object.prototype.hasOwnProperty.call(profiles, key)) return {}; + const profile = profiles[key]; + return profile && typeof profile === 'object' ? profile : {}; +}; + +/** Merges an update into one stored profile under the transaction lock, reporting where it landed. */ +export const writeProfile = ( + location: CredentialStoreLocation, + key: string, + update: StoredProfile, +): StoreBackend => + // The keychain write and the index write are one transaction. Locking only the file rewrite leaves + // a logout able to clear a newly stored secret while still reading the index entry that predates + // it, so both go inside the same lock. + withStoreLock(location, () => writeProfileLocked(location, key, update)); + +/** + * Merges a refreshed token into a profile that is still signed in, and does nothing when it is not. + * + * The silent refresh runs on the request path of an ordinary command: it reads the profile, spends a + * network round trip on the exchange, and only then writes the result back. A `logout` in another + * shell fits comfortably inside that window. Writing unconditionally there would file a freshly + * issued credential under a host the user has just signed out of — and under the keychain backend + * that means planting a new secret in their keyring *after* a sign-out that reported success, with + * an index entry to match. The lock serializes the two writes; this is what makes the later one + * conditional on what the earlier one left behind. + */ +export const refreshProfile = ( + location: CredentialStoreLocation, + key: string, + update: StoredProfile, +): void => { + withStoreLock(location, () => { + if (!Object.prototype.hasOwnProperty.call(readStore(location).profiles, key)) return; + // A refresh is preparation for a command, not the command, so a store that cannot be read is a + // reason to leave the credential where it is rather than to fail what the user asked for. The + // write below would refuse anyway; catching it here keeps that refusal off the request path. + if (readProfileResult(location, key).unreadable) return; + writeProfileLocked(location, key, update); + }); +}; + +/** The body of a profile write, for the callers above that have already taken the store lock. */ +const writeProfileLocked = ( + location: CredentialStoreLocation, + key: string, + update: StoredProfile, +): StoreBackend => { + const marker = indexEntry(location, key); + // Merging only after acquiring the lock preserves credentials captured by another flow while + // this flow was waiting to save its own result. + if (marker.backend === 'keychain') refreshStoreLock(location); + const existing = readProfileResult(location, key); + // Refused rather than merged into nothing. The write below replaces the whole profile, so going + // ahead on a read that never answered would file this one credential over however many were + // already there — silently, on a keyring hiccup no louder than a daemon restart. + if (existing.unreadable) { + throw new Error( + 'Could not read the credentials already saved for this host from your ' + + osStoreName() + + '. Nothing was written. Try again, or run logout for this host first.', + ); + } + const profile = mergeProfile(existing.profile, update); + if (mayUseKeychain(location)) { + refreshStoreLock(location); + if (keychainStore(location.storeName, key, JSON.stringify(withoutMarker(profile)))) { + try { + // The file records only that this base URL is signed in and which store has it — no secret + // — so `logout --all` can find it without one ever reaching disk. + writeIndex(location, key, { backend: 'keychain' }); + } catch (error) { + // The secret reached the OS store but nothing on disk points at it, and the index is the + // only thing `logout --all` can enumerate. Take it back out rather than leaving a + // credential in the user's keyring that this CLI can neither find nor remove — but only + // when this write is what put it there. `keychainStore` overwrites, so if the store was + // already holding this key the item here is the user's working credential, and clearing it + // would destroy the very thing the failed sign-in was meant to replace. + if (marker.backend !== 'keychain') { + refreshStoreLock(location); + keychainClear(location.storeName, key); + } + throw error; + } + return 'keychain'; + } + if (location.backend === 'keychain') { + throw new Error( + keychainSupported() + ? 'Could not save the credential to your ' + osStoreName() + '.' + : 'This system has no credential store ' + location.storeName + ' can use.', + ); + } + } + // Whatever this key already had in the OS store comes out before the file takes over, so the two + // cannot disagree. Only the marker decides what a later read uses, so an item left behind can no + // longer shadow this one — which is why a helper that will not answer is not worth failing the + // sign-in over. It is reported instead: wedging `login` would leave the user no way forward at + // all, since `logout` would be refusing for exactly the same reason. + if (marker.backend === 'keychain' && removeSecret(location, key) !== 'removed') { + warn( + 'Your ' + osStoreName() + ' may still hold an older credential for this host; remove it there if so.', + ); + } + writeIndex(location, key, { ...withoutMarker(profile), backend: 'file' }); + return 'file'; +}; + +const warnings: string[] = []; + +/** + * Notes something the user should know without failing what they asked for. + * + * Collected rather than printed. The same store code runs under `login` and under the silent token + * refresh that precedes an ordinary command, and writing to standard error there would interleave + * prose with the JSON document `--format-error json` puts on that same stream. Only `login` and + * `logout` drain this, so a warning reaches the one place a user is already reading sentences. + */ +const warn = (message: string): void => { + if (!warnings.includes(message)) warnings.push(message); +}; + +/** Takes the warnings collected so far, leaving none behind. */ +export const takeWarnings = (): readonly string[] => warnings.splice(0, warnings.length); + +/** + * Takes one secret out of the OS store, reporting what could be established. + * + * `keychainClear` answers `false` both for an item that is not there and for a helper that cannot + * run, so its word alone is never enough. A lookup afterwards settles the only question that + * matters: `kept` means the secret is still retrievable, and so removing the index entry would be a + * lie. Anything else means this CLI can no longer get at it, which is what being signed out is — + * `unconfirmed` says so while admitting the store may still hold something, because a helper that + * could not run cannot prove otherwise. + * + * Nothing here throws. Earlier versions failed the command when a clear did not succeed, which + * wedged `login` and `logout` together for anyone whose item had simply been removed by hand or + * whose helper had gone: both refused, and nothing the CLI offered could ever clear the entry. + */ +const removeSecret = (location: CredentialStoreLocation, key: string): 'removed' | 'unconfirmed' | 'kept' => { + refreshStoreLock(location); + if (keychainClear(location.storeName, key)) return 'removed'; + refreshStoreLock(location); + return keychainLookup(location.storeName, key) === undefined ? 'unconfirmed' : 'kept'; +}; + +/** The profile without the index's own bookkeeping, which is not part of the secret. */ +const withoutMarker = (profile: StoredProfile): StoredProfile => ({ + ...(profile.credentials ? { credentials: profile.credentials } : {}), + ...(profile.oauth ? { oauth: profile.oauth } : {}), +}); + +/** + * Removes one stored profile, reporting what became of it. + * + * A secret the OS store will not give up fails the whole removal rather than being written off. + * `readProfile` looks the OS store up by key and never through the index, so dropping the index + * entry while the secret stays behind would leave a credential that still authenticates every + * command — and that `logout --all`, which enumerates the index, could no longer even find. + */ +export const deleteProfile = (location: CredentialStoreLocation, key: string): 'removed' | 'absent' => + withStoreLock(location, () => { + const entry = indexEntry(location, key); + if (entry.backend === 'keychain') { + const outcome = removeSecret(location, key); + if (outcome === 'kept') { + // The one case worth refusing: the secret answered a lookup after the clear, so it is still + // there and still usable. Dropping the entry would report a sign-out over a live credential + // and lose the only record of where it is. + throw new Error('Could not remove the credential from your ' + osStoreName() + '.'); + } + if (outcome === 'unconfirmed') { + warn('Your ' + osStoreName() + ' may still hold a credential for this host; remove it there if so.'); + } + } + const store = readStore(location); + if (!Object.prototype.hasOwnProperty.call(store.profiles, key)) return 'absent'; + const profiles = { ...store.profiles }; + delete profiles[key]; + writeStore(location, { version: STORE_VERSION, profiles }); + return 'removed'; + }); + +/** + * Forgets everything, in both places. + * + * The keychain items go one by one, taken from the index, before the index itself does: deleting the + * file first would strand the secrets in the OS store with nothing left pointing at them — invisible + * to this CLI and never cleaned up. + */ +export const clearAll = (location: CredentialStoreLocation): void => { + const kept = withStoreLock(location, () => { + // The keychain clears and the index rewrite share one lock with login. It can hold the lock for + // several helper calls, but that is preferable to acknowledging a logout that strands a secret. + const entries = Object.entries(readStore(location).profiles); + const outcomes = entries.map( + ([key, profile]) => + [key, profile?.backend === 'keychain' ? removeSecret(location, key) : 'removed'] as const, + ); + const kept = outcomes.filter(([, outcome]) => outcome === 'kept').map(([key]) => key); + if (outcomes.some(([, outcome]) => outcome === 'unconfirmed')) { + warn('Your ' + osStoreName() + ' may still hold credentials this could not remove.'); + } + const remaining = entries.filter(([key]) => kept.includes(key)); + if (remaining.length === 0) deleteStore(location); + else writeStore(location, { version: STORE_VERSION, profiles: Object.fromEntries(remaining) }); + return kept; + }); + if (kept.length === 0) return; + // The index entry is the only record of which base URLs have a secret in the OS store, so one + // still holding a retrievable secret keeps its entry: dropping it would strand the credential + // there — still live, and invisible to this CLI for good. + throw new Error( + 'Could not remove every credential from your ' + + osStoreName() + + '; ' + + String(kept.length) + + ' still there.', + ); +}; + +/** Writes one profile into the on-disk store while the caller holds the credential-store lock. */ +const writeIndex = (location: CredentialStoreLocation, key: string, profile: StoredProfile): void => { + const store = readStore(location); + writeStore(location, { + version: STORE_VERSION, + profiles: { ...store.profiles, [key]: profile }, + }); +}; + +// Empty maps are dropped rather than written as `{}`: a store a user opens should show what is +// actually saved, and a token-only sign-in has no OAuth metadata to speak of. +const mergeProfile = (existing: StoredProfile, update: StoredProfile): StoredProfile => { + const credentials = { ...(existing.credentials ?? {}), ...(update.credentials ?? {}) }; + const oauth = { ...(existing.oauth ?? {}), ...(update.oauth ?? {}) }; + return { + ...(Object.keys(credentials).length > 0 ? { credentials } : {}), + ...(Object.keys(oauth).length > 0 ? { oauth } : {}), + }; +}; diff --git a/src/cli/keychain.ts b/src/cli/keychain.ts new file mode 100644 index 0000000..1220803 --- /dev/null +++ b/src/cli/keychain.ts @@ -0,0 +1,194 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { spawnSync } from 'node:child_process'; + +/** + * One platform's credential helper, as the three commands this needs from it. + * + * Every helper is a command-line tool the operating system ships (or, on Windows, a script run + * through the shell it ships), because the alternative is a native addon: `keytar` and friends are + * compiled per platform and per Node version, and a generated CLI is meant to stay dependency-free + * and installable as plain JavaScript. + */ +type KeychainHelper = { + readonly file: string; + /** Argv for reading a secret back. The secret is the command's standard output. */ + readonly lookup: (service: string, account: string) => readonly string[]; + /** Argv for writing. The secret never appears here; see {@link HELPERS} for where it travels. */ + readonly store: (service: string, account: string) => readonly string[]; + readonly clear: (service: string, account: string) => readonly string[]; + /** + * Script fed on standard input, for a helper driven through a shell. + * + * A helper with one reads its service, account and secret from the environment, because the shell + * has already taken standard input for the script. + */ + readonly script?: (mode: 'lookup' | 'store' | 'clear') => string; +}; + +/** + * Absolute path to Windows PowerShell. + * + * Never the bare name: on Windows a command name with no separator is resolved against the *current + * working directory* before `PATH`, so `spawn('powershell', ...)` inside a checked-out repository + * that happens to contain `powershell.exe` would run that instead — and hand it the secret. + */ +export const windowsPowerShell = (): string => + (process.env['SystemRoot'] ?? 'C:\\Windows') + '\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; + +/** + * The secret NEVER travels in argv. + * + * A process's command line is readable by other processes — `ps`, `/proc//cmdline` — so a + * credential passed as an argument is exposed to every other program running as that user, and on + * many systems to other users too. + * + * That rule is what decides which platforms are here. `secret-tool store` reads the secret from + * standard input by design. The Windows helper cannot — `powershell -Command -` reads *all* of + * standard input as the script to run, leaving nothing for the script itself to read — so its secret + * goes through the environment instead, which on Windows one process cannot read from another + * without debug privileges, unlike a command line. + * + * macOS is deliberately absent. `security add-generic-password` takes the password only as the + * argument to `-w`; with no value there it is a usage error, and with a value the secret is in the + * command line. It has no standard-input form and no interactive mode this can drive safely, so a + * Mac gets the `0600` file — the same place the widely used CLIs that do not ship a native helper + * binary keep theirs. + */ +const HELPERS: Readonly> = { + // Linux: the freedesktop Secret Service, which GNOME Keyring and KWallet both implement. + // A bare command name is safe here: only Windows resolves one against the working directory. + linux: { + file: 'secret-tool', + lookup: (service, account) => ['lookup', 'service', service, 'account', account], + store: (service, account) => ['store', '--label=' + service, 'service', service, 'account', account], + clear: (service, account) => ['clear', 'service', service, 'account', account], + }, + // Windows Credential Manager, through the PasswordVault WinRT API that Windows PowerShell exposes. + // The script arrives on standard input (`-Command -`) so neither it nor the secret reaches argv. + win32: { + file: windowsPowerShell(), + lookup: () => ['-NoProfile', '-NonInteractive', '-Command', '-'], + store: () => ['-NoProfile', '-NonInteractive', '-Command', '-'], + clear: () => ['-NoProfile', '-NonInteractive', '-Command', '-'], + script: (mode) => windowsScript(mode), + }, +}; + +/** Whether this platform has a credential helper the CLI can drive at all. */ +export const keychainSupported = (): boolean => HELPERS[process.platform] !== undefined; + +/** + * PowerShell for one Credential Manager operation. + * + * The service, the account and — for a store — the secret are all read from environment variables + * rather than pasted into the script, so a base URL can never close a quote and become PowerShell. + * Standard input is not available for any of them: the shell has already taken it for this script. + */ +const windowsScript = (mode: 'lookup' | 'store' | 'clear'): string => { + const prelude = [ + '$ErrorActionPreference = "Stop"', + // Windows PowerShell encodes `[Console]::Out` with the console output code page (an OEM page + // such as CP850) when stdout is a pipe, while `spawnSync` here decodes it as UTF-8. Without + // this, a stored credential carrying any non-ASCII character comes back mangled — and a + // credential that is wrong but still parses is worse than one that fails to, because every + // command then authenticates with it, and `login` appears to fix it while storing a correct + // value that is read back wrong again. + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]', + '$vault = New-Object Windows.Security.Credentials.PasswordVault', + '$svc = $env:SCALAR_KEYCHAIN_SERVICE', + '$acct = $env:SCALAR_KEYCHAIN_ACCOUNT', + ]; + if (mode === 'lookup') { + return [ + ...prelude, + '$item = $vault.Retrieve($svc, $acct)', + '$item.RetrievePassword()', + '[Console]::Out.Write($item.Password)', + ].join('\n'); + } + if (mode === 'clear') { + return [...prelude, '$item = $vault.Retrieve($svc, $acct)', '$vault.Remove($item)'].join('\n'); + } + return [ + ...prelude, + '$secret = $env:SCALAR_KEYCHAIN_SECRET', + 'try { $vault.Remove($vault.Retrieve($svc, $acct)) } catch { }', + '$vault.Add((New-Object Windows.Security.Credentials.PasswordCredential($svc, $acct, $secret)))', + ].join('\n'); +}; + +/** + * Reads one secret back, saying which kind of nothing it got when there is no secret. + * + * `unavailable` is the distinction a plain lookup cannot make: the helper never ran (missing, + * timed out, no session bus over ssh) rather than ran and reported no such item. A caller about to + * *replace* a profile needs it, because merging into the empty result of a failed read and writing + * that back is how the other credentials under the same key get dropped. + */ +export const keychainRead = ( + service: string, + account: string, +): { readonly secret?: string; readonly unavailable: boolean } => { + const result = run('lookup', service, account); + // `secret-tool` terminates its output with a newline the stored value never had; `security` + // does the same. Nothing this stores is meant to end in one, so a single trailing newline comes off. + if (result.ok) return { secret: result.stdout.replace(/\n$/u, ''), unavailable: false }; + return { unavailable: result.unavailable }; +}; + +/** Reads one secret back, or `undefined` when the helper has no such item (or cannot run). */ +export const keychainLookup = (service: string, account: string): string | undefined => + keychainRead(service, account).secret; + +/** Writes one secret, reporting whether the helper accepted it. */ +export const keychainStore = (service: string, account: string, secret: string): boolean => + run('store', service, account, secret).ok; + +/** + * Removes one secret, reporting whether the helper accepted the removal. + * + * A helper asked for an item it does not have reports failure, so callers establish that the item + * exists first and read this as "the secret is gone" only when it is `true`. + */ +export const keychainClear = (service: string, account: string): boolean => run('clear', service, account).ok; + +const run = ( + mode: 'lookup' | 'store' | 'clear', + service: string, + account: string, + secret?: string, +): { readonly ok: boolean; readonly stdout: string; readonly unavailable: boolean } => { + const helper = HELPERS[process.platform]; + if (!helper) return { ok: false, stdout: '', unavailable: true }; + const script = helper.script?.(mode); + // A scripted helper consumes the whole of standard input as its script, so its secret travels in + // the environment beside the service and account; a direct helper takes the secret on stdin. + const input = script ?? secret ?? ''; + try { + const result = spawnSync(helper.file, [...helper[mode](service, account)], { + input, + encoding: 'utf8', + // A helper that blocks — a locked keychain putting up a dialog, a Secret Service with no + // daemon answering — must not hang every command. The timeout turns that into a fallback. + timeout: 10000, + env: { + ...process.env, + SCALAR_KEYCHAIN_SERVICE: service, + SCALAR_KEYCHAIN_ACCOUNT: account, + ...(script !== undefined && secret !== undefined ? { SCALAR_KEYCHAIN_SECRET: secret } : {}), + }, + }); + // A spawn that reports an `error` never delivered the question — the helper is missing, or the + // timeout above fired. A non-zero status is the helper answering, which for a lookup or a clear + // is how every implementation spells "no such item". + if (result.error) return { ok: false, stdout: '', unavailable: true }; + if (result.status !== 0) return { ok: false, stdout: '', unavailable: false }; + return { ok: true, stdout: result.stdout ?? '', unavailable: false }; + } catch { + // ENOENT for a helper this machine does not have is the common case, and it is not an error: + // it is the signal to fall back. + return { ok: false, stdout: '', unavailable: true }; + } +}; diff --git a/src/cli/login.ts b/src/cli/login.ts new file mode 100644 index 0000000..92a8200 --- /dev/null +++ b/src/cli/login.ts @@ -0,0 +1,1559 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { spawn } from 'node:child_process'; +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { createServer } from 'node:http'; +import { createInterface } from 'node:readline'; +import { stderr as processStderr, stdin as processStdin } from 'node:process'; + +import { + type CredentialStoreLocation, + type StoredOAuth, + type StoredProfile, + deleteProfile, + profileKey, + readProfile, + // scalar-sdk-generator:custom-code logout-imports:start + clearAll, + readStore, + // scalar-sdk-generator:custom-code logout-imports:end + refreshProfile, + storeDescription, + writeProfile, +} from './credentials'; +import { windowsPowerShell } from './keychain'; + +/** One way `login` can obtain a credential. Mirrors the emitter's `CliAuthMethodDefinition`. */ +export type CliAuthMethodDefinition = { + readonly name: string; + readonly label: string; +} & ( + | { readonly kind: 'token'; readonly clientKey: string; readonly prompt: string } + | { readonly kind: 'basic'; readonly usernameKey: string; readonly passwordKey: string } + | { + readonly kind: 'oauth'; + readonly grant: 'authorizationCode'; + // scalar-sdk-generator:custom-code oauth-provider-contract:start + // resource-bound provider configuration + readonly resource?: string; + readonly issuer?: string; + readonly revocationUrl?: string; + // scalar-sdk-generator:custom-code oauth-provider-contract:end + readonly clientKey: string; + readonly tokenUrl: string; + readonly refreshUrl: string; + readonly authorizationUrl: string; + readonly scopes: readonly string[]; + readonly clientId: string; + readonly redirectPort: number; + } + | { + readonly kind: 'oauth'; + readonly grant: 'clientCredentials' | 'password'; + readonly clientKey: string; + readonly tokenUrl: string; + readonly refreshUrl: string; + readonly scopes: readonly string[]; + readonly clientId?: string; + } + | { + readonly kind: 'oauth'; + readonly grant: 'deviceAuthorization'; + readonly clientKey: string; + readonly deviceAuthorizationUrl: string; + readonly tokenUrl: string; + readonly refreshUrl: string; + readonly scopes: readonly string[]; + readonly clientId?: string; + } + | { + readonly kind: 'oauth'; + readonly grant: 'openIdConnect'; + readonly clientKey: string; + readonly discoveryUrl: string; + readonly scopes: readonly string[]; + readonly clientId: string; + readonly redirectPort: number; + } +); + +/** Everything `login` and `logout` need. Mirrors the emitter's `CliAuthDefinition`. */ +export type CliAuthDefinition = { + readonly loginPath: readonly string[]; + readonly logoutPath: readonly string[]; + readonly loginCommand: string; + readonly storeName: string; + readonly storeEnv: string; + readonly baseUrlEnv: string; + readonly backend: 'auto' | 'keychain' | 'file'; + readonly defaultBaseUrl: string; + readonly requirements: readonly (readonly string[])[]; + readonly envByKey: Readonly>; + readonly methods: readonly CliAuthMethodDefinition[]; +}; + +/** How long the browser flow waits for the redirect before giving the terminal back. */ +const BROWSER_FLOW_TIMEOUT_MS = 300000; + +/** The one path the loopback listener answers as a redirect; it is what `redirect_uri` registers. */ +const REDIRECT_PATH = '/callback'; + +/** + * Ceiling on how long the device grant polls, whatever `expires_in` claims. + * + * RFC 8628 leaves the lifetime to the server, and the server is named by the OpenAPI document. This + * is the same bound the browser grant has, so neither interactive sign-in can be made to wait + * indefinitely by an endpoint that answers `authorization_pending` and a very large expiry. + */ +const DEVICE_FLOW_TIMEOUT_MS = 300000; + +/** + * How long a token request may take, and how much of its answer is read. + * + * Both bounds exist because the endpoint comes from the OpenAPI document. `fetch` has no default + * timeout, and a refresh runs before every command that has no flag or environment credential, so an + * endpoint that accepts the connection and never answers would hang the whole CLI with nothing on + * screen. The size cap keeps an endless body from being buffered into memory for a response whose + * useful part is a few hundred bytes. + */ +const TOKEN_REQUEST_TIMEOUT_MS = 30000; +const TOKEN_RESPONSE_LIMIT = 65536; + +/** Refresh this far ahead of expiry so a token cannot lapse between the check and the request. */ +const REFRESH_LEEWAY_MS = 60000; + +/** + * Deadline for the refresh that runs ahead of an ordinary command, rather than the full one above. + * + * That refresh is not what the user asked for — it is preparation for a request they are waiting on + * — and its failure is swallowed, so the whole of its cost is delay they cannot see a reason for. An + * unreachable refresh endpoint would otherwise add `TOKEN_REQUEST_TIMEOUT_MS` to *every* invocation + * for the life of the stored entry. Giving up sooner costs a refresh that a slow provider might + * still have answered; the command then goes out with the credential it already had, and a 401 says + * to sign in again. An interactive `login` keeps the full deadline, because there the exchange is + * the thing the user is waiting for. + */ +const SILENT_REFRESH_TIMEOUT_MS = 5000; + +/** Lifetime assumed for a token whose endpoint did not state one. See {@link oauthMetadata}. */ +const ASSUMED_TOKEN_LIFETIME_S = 3600; + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +/** + * Shortest device-code poll interval a server can ask for. + * + * Low enough that a local authorization server still feels immediate, high enough that one asking + * for zero cannot turn the wait for authorization into a request flood. + */ +const DEVICE_POLL_FLOOR_MS = 200; + +const storeLocation = (auth: CliAuthDefinition): CredentialStoreLocation => ({ + storeName: auth.storeName, + storeEnv: auth.storeEnv, + backend: auth.backend, +}); + +/** The client option keys a method fills, which for HTTP Basic is the username and password pair. */ +const methodKeys = (method: CliAuthMethodDefinition): readonly string[] => + method.kind === 'basic' ? [method.usernameKey, method.passwordKey] : [method.clientKey]; + +/** + * Names what a request still needs when this sign-in cannot satisfy any alternative by itself. + * + * An OpenAPI `security` list is OR-of-ANDs, so an alternative can require two schemes at once — an + * API key *and* a bearer token. `login` captures one credential per run, which leaves that document + * signed in only halfway, and saying nothing would send the user off to collect a 401 whose hint + * tells them to run the command they have just run. + * + * Read back from the store rather than from what was captured, so a credential an earlier `login` + * already left behind counts towards the requirement instead of being asked for a second time. + * + * The alternative closest to complete is the one named: it is the cheapest way out for the user, + * and listing every unmet alternative would describe choices the API treats as interchangeable. + */ +const outstandingRequirement = ( + auth: CliAuthDefinition, + location: CredentialStoreLocation, + key: string, +): string | undefined => { + if (auth.requirements.length === 0) return undefined; + // The store *and* the environment, because that is what the request path counts. `applyStoredCredentials` + // treats a non-blank environment variable as an explicit value and never consults the store for + // that key, so a user who had exported one half of an AND alternative would otherwise be sent to + // obtain a credential their very next command was going to send anyway. + const held = new Set(Object.keys(readProfile(location, key).credentials ?? {})); + for (const [clientKey, name] of Object.entries(auth.envByKey)) { + if ((process.env[name] ?? '').trim()) held.add(clientKey); + } + if (auth.requirements.some((keys) => keys.every((candidate) => held.has(candidate)))) return undefined; + const missing = auth.requirements + .map((keys) => keys.filter((candidate) => !held.has(candidate))) + .filter((keys) => keys.length > 0) + .sort((left, right) => left.length - right.length)[0]; + if (!missing || missing.length === 0) return undefined; + // A key with no method behind it cannot be asked for, so it is described rather than commanded. + // Deduplicated by method, because HTTP Basic fills two keys in one sitting and would otherwise be + // offered twice for the one sign-in that satisfies both. + const names = new Set(); + const runnable: CliAuthMethodDefinition[] = []; + for (const candidate of missing) { + const method = auth.methods.find((entry) => methodKeys(entry).includes(candidate)); + if (!method || names.has(method.name)) continue; + names.add(method.name); + runnable.push(method); + } + if (runnable.length === 0) return 'Requests here also need another credential this CLI cannot obtain.'; + const commands = runnable.map((method) => auth.loginCommand + ' --flow ' + method.name); + return ( + 'Requests here also need ' + + (runnable.length === 1 ? 'one more credential' : String(runnable.length) + ' more credentials') + + '. Run ' + + commands.join(', then ') + + '.' + ); +}; + +/** + * Runs an interactive sign-in and saves the result, returning the line to print. + * + * Every prompt is written to standard error, so the confirmation this returns is the only thing on + * standard output and `login` stays usable in a pipeline. + */ +export const runLogin = async ( + auth: CliAuthDefinition, + baseUrl: string, + flow: string | undefined, +): Promise => { + const method = await chooseMethod(auth, flow); + const location = storeLocation(auth); + const key = profileKey(baseUrl); + const captured = await captureCredentials(method, baseUrl); + // Where it landed is reported by the write itself rather than predicted, so under `auto` the + // line a user reads names the store the credential actually went into. The write merges this + // captured result inside its lock, because a browser grant can sit for minutes before it arrives. + const backend = writeProfile(location, key, captured); + const line = + 'Signed in to ' + + // The base URL can be the document's own `servers[0].url` when no flag and no environment + // variable named one, which makes it spec-derived text on its way to a terminal. + safeText(baseUrl || 'the API') + + '. Credentials saved to ' + + storeDescription(location, backend) + + '.'; + const outstanding = outstandingRequirement(auth, location, key); + return outstanding ? line + '\n' + outstanding : line; +}; + +// scalar-sdk-generator:custom-code oauth-logout:start + +/** Attempts provider revocation, then clears local credentials even when offline. */ +export const runLogout = async (auth: CliAuthDefinition, baseUrl: string, all: boolean): Promise => { + const location = storeLocation(auth); + const keys = all ? Object.keys(readStore(location).profiles) : [profileKey(baseUrl)]; + const warnRevocation = () => processStderr.write( + 'Warning: OAuth revocation could not be confirmed. Clearing local credentials; the server session may remain active.\n', + ); + for (const key of keys) { + let profile: StoredProfile; + try { + profile = readProfile(location, key); + } catch { + warnRevocation(); + continue; + } + for (const [clientKey, meta] of Object.entries(profile.oauth ?? {})) { + try { + // Match Codex: prefer refresh revocation; use access only when no refresh token exists. + const token = meta?.refreshToken || profile.credentials?.[clientKey]; + if (!token) continue; + if (!meta?.revocationUrl) throw new Error('Missing revocation endpoint.'); + const url = requireSecureUrl(meta.revocationUrl, '', 'revocation endpoint'); + const { response } = await postForToken(url, { + token, + token_type_hint: meta.refreshToken ? 'refresh_token' : 'access_token', + }, 10_000); + if (!response.ok) throw new Error('Revocation failed.'); + } catch { + // Never print provider bodies or request errors: they can contain credentials. + warnRevocation(); + } + } + } + // Local deletion is independent of the network result; deletion errors still propagate. + if (all) { + clearAll(location); + return 'Signed out of all saved profiles.'; + } + const removed = deleteProfile(location, profileKey(baseUrl)) === 'removed'; + return (removed ? 'Signed out of ' : 'No stored credentials for ') + safeText(baseUrl || 'the API') + '.'; +}; +// scalar-sdk-generator:custom-code oauth-logout:end + +/** + * Stored credentials for one base URL, keyed by client-option name. + * + * An OAuth access token at or near its expiry is refreshed in place first, so a CLI left alone + * overnight keeps working without a second `login`. A refresh that fails is swallowed: the stale + * token still goes out, and the 401 that follows carries the runtime's own sign-in hint, which + * says more than a refresh error would. + * + * Refresh exchanges may overlap, but their results merge into the current profile under the same + * lock as interactive sign-in. A server that rotates refresh tokens can still reject one of two + * simultaneous exchanges; that is recoverable with one `login` and never loses another flow's key. + */ +export const storedCredentials = async ( + auth: CliAuthDefinition, + baseUrl: string, +): Promise>> => { + const location = storeLocation(auth); + const key = profileKey(baseUrl); + const profile = readProfile(location, key); + const credentials: Record = { ...(profile.credentials ?? {}) }; + for (const [clientKey, meta] of Object.entries(profile.oauth ?? {})) { + // `readProfile` checks that the stored value parses and is an object, but not the shape beneath + // it, so an entry this CLI did not write can still be `null` or a scalar. Reading `expiresAt` off + // one would throw on the request path of *every* command — the exact failure the corrupt-store + // handling exists to avoid, and worse than it, since a bad OAuth entry would also take out the + // commands that need no credential at all. Skipping it falls back to `login`, as a missing + // entry does. + if (!meta || typeof meta !== 'object') continue; + // Older generated CLIs stored this as `tokenUrl`; retaining that fallback makes their saved + // credentials refreshable after an upgrade while new entries preserve a distinct refresh URL. + const refreshUrl = meta.refreshUrl ?? meta.tokenUrl; + if (!needsRefresh(meta) || !meta.refreshToken || !refreshUrl) continue; + try { + const token = await exchangeToken( + refreshUrl, + baseUrl, + { + grant_type: 'refresh_token', + refresh_token: meta.refreshToken, + ...(meta.clientId ? { client_id: meta.clientId } : {}), + }, + SILENT_REFRESH_TIMEOUT_MS, + ); + credentials[clientKey] = token.accessToken; + // Conditional, not `writeProfile`: a `logout` that landed while the exchange was in flight + // must not be undone by the token it produced. See `refreshProfile`. + refreshProfile(location, key, { + credentials: { [clientKey]: token.accessToken }, + // scalar-sdk-generator:custom-code refresh-revocation-metadata:start + // refresh retains revocation configuration + oauth: { [clientKey]: { ...oauthMetadata(token, refreshUrl, meta.clientId, meta.refreshToken), + ...(meta.revocationUrl ? { revocationUrl: meta.revocationUrl } : {}) } }, + // scalar-sdk-generator:custom-code refresh-revocation-metadata:end + }); + } catch { + // Deliberately silent; see this function's doc comment. + } + } + return credentials; +}; + +const needsRefresh = (meta: StoredOAuth): boolean => + meta.expiresAt !== undefined && meta.expiresAt - Date.now() < REFRESH_LEEWAY_MS; + +/** + * Picks the sign-in method to run. + * + * A named flow is matched exactly; otherwise a single method runs unprompted and several are put to + * the user. The picker needs a terminal, so a non-interactive shell is told to name a flow instead + * of being left waiting on stdin that will never arrive. + */ +const chooseMethod = async ( + auth: CliAuthDefinition, + flow: string | undefined, +): Promise => { + const names = auth.methods.map((method) => method.name); + if (flow !== undefined) { + const chosen = auth.methods.find((method) => method.name === flow); + if (!chosen) + throw new UsageError("Unknown sign-in flow '" + flow + "'. Available: " + names.join(', ') + '.'); + return chosen; + } + const first = auth.methods[0]; + if (!first) throw new Error('This CLI has no sign-in flow.'); + if (auth.methods.length === 1) return first; + const index = await promptChoice( + 'How would you like to sign in?', + auth.methods.map((method) => ({ label: method.label, hint: method.name })), + ); + // The picker only ever resolves an index it drew, so `first` stands in for a state that cannot + // occur rather than an error message describing one. + return auth.methods[index] ?? first; +}; + +const captureCredentials = async ( + method: CliAuthMethodDefinition, + baseUrl: string, +): Promise => { + if (method.kind === 'token') { + const value = await promptSecret(method.prompt); + if (!value) throw new UsageError('No value entered; nothing was saved.'); + return { credentials: { [method.clientKey]: value } }; + } + if (method.kind === 'basic') { + const username = await promptLine('Username: '); + if (!username) throw new UsageError('No username entered; nothing was saved.'); + const password = await promptSecret('Password: '); + if (!password) throw new UsageError('No password entered; nothing was saved.'); + return { credentials: { [method.usernameKey]: username, [method.passwordKey]: password } }; + } + const { token, clientId, refreshUrl } = await runOauthFlow(method, baseUrl); + return { + credentials: { [method.clientKey]: token.accessToken }, + // scalar-sdk-generator:custom-code login-revocation-metadata:start + // retain the provider revocation endpoint with the grant + oauth: { [method.clientKey]: { + ...oauthMetadata(token, refreshUrl, clientId, undefined), + ...(method.grant === 'authorizationCode' && method.revocationUrl ? { revocationUrl: method.revocationUrl } : {}), + } }, + // scalar-sdk-generator:custom-code login-revocation-metadata:end + }; +}; + +const oauthMetadata = ( + token: TokenResponse, + refreshUrl: string, + clientId: string | undefined, + previousRefreshToken: string | undefined, +): StoredOAuth => { + // A refresh response may legitimately omit `refresh_token`, which means "keep using the one you + // have" rather than "you no longer have one"; dropping it would turn every refresh into the last. + const refreshToken = token.refreshToken ?? previousRefreshToken; + // `expires_in` is OPTIONAL in RFC 6749 §5.1, and an absent one used to mean no `expiresAt`, which + // `needsRefresh` reads as "never expires" — so a provider that omits it left the refresh token + // stored and never used, and the user re-ran the whole flow on every expiry. When there is + // something to refresh with, an unstated lifetime is assumed to be the hour nearly every provider + // issues; refreshing earlier than necessary costs one request, never refreshing costs the feature. + const lifetime = token.expiresIn ?? (refreshToken ? ASSUMED_TOKEN_LIFETIME_S : undefined); + return { + ...(refreshToken ? { refreshToken } : {}), + ...(lifetime === undefined ? {} : { expiresAt: Date.now() + lifetime * 1000 }), + refreshUrl, + ...(clientId ? { clientId } : {}), + }; +}; + +/** The access token an OAuth grant produced, plus what is needed to renew it. */ +type TokenResponse = { + readonly accessToken: string; + readonly refreshToken?: string; + /** Lifetime in seconds, as the token endpoint reported it. */ + readonly expiresIn?: number; +}; + +const runOauthFlow = async ( + method: Extract, + baseUrl: string, +): Promise => { + if (method.grant === 'authorizationCode') { + const result = await authorizationCodeFlow(method, baseUrl); + return { ...result, refreshUrl: method.refreshUrl }; + } + if (method.grant === 'openIdConnect') return openIdConnectFlow(method, baseUrl); + if (method.grant === 'deviceAuthorization') return deviceAuthorizationFlow(method, baseUrl); + if (method.grant === 'clientCredentials') { + const clientId = method.clientId ?? (await promptLine('Client id: ')); + if (!clientId) throw new UsageError('No client id entered; nothing was saved.'); + const clientSecret = await promptSecret('Client secret: '); + if (!clientSecret) throw new UsageError('No client secret entered; nothing was saved.'); + const token = await exchangeToken(method.tokenUrl, baseUrl, { + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + ...scopeParam(method.scopes), + }); + return { token, clientId, refreshUrl: method.refreshUrl }; + } + // Asked for the same way the client-credentials grant above asks, so a provider that requires a + // client id on this grant — which a public client's does — can be signed into without the value + // having to be baked into the generated source. It stays optional, unlike there: RFC 6749 lets a + // client that authenticates some other way leave it out, so a blank answer omits the parameter + // rather than sending an empty one, and the prompt says which it is. + const clientId = method.clientId ?? (await promptLine('Client id (optional): ')); + const username = await promptLine('Username: '); + if (!username) throw new UsageError('No username entered; nothing was saved.'); + const password = await promptSecret('Password: '); + if (!password) throw new UsageError('No password entered; nothing was saved.'); + const token = await exchangeToken(method.tokenUrl, baseUrl, { + grant_type: 'password', + username, + password, + ...(clientId ? { client_id: clientId } : {}), + ...scopeParam(method.scopes), + }); + // Persisted so the refresh exchange can send the same client id the grant was made with; a blank + // answer stores nothing, which is what an absent one already did. + return { token, clientId: clientId || undefined, refreshUrl: method.refreshUrl }; +}; + +/** OAuth result plus the endpoint needed later to refresh a persisted token. */ +type OAuthFlowResult = { + readonly token: TokenResponse; + readonly clientId: string | undefined; + readonly refreshUrl: string; +}; + +/** + * Runs the Device Authorization Grant without needing a loopback listener or a local browser. + * + * The device code is never printed: it is the credential that the token endpoint accepts. The + * user code, by contrast, is deliberately meant to be entered in a browser and is shown beside the + * verification URL. Polling follows RFC 8628's ‘authorization_pending’ and ‘slow_down’ signals + * instead of treating them as failed sign-ins. + */ +const deviceAuthorizationFlow = async ( + method: Extract, + baseUrl: string, +): Promise => { + const clientId = method.clientId ?? (await promptLine('Client id: ')); + if (!clientId) throw new UsageError('No client id entered; nothing was saved.'); + const device = await requestDeviceAuthorization( + method.deviceAuthorizationUrl, + baseUrl, + clientId, + method.scopes, + ); + processStderr.write( + 'To sign in, visit:\n\n ' + + device.verificationUrl + + (device.userCode ? '\n\nThen enter code: ' + safeText(device.userCode) : '') + + '\n\nWaiting for authorization...\n', + ); + return { + token: await pollDeviceToken(method.tokenUrl, baseUrl, clientId, device), + clientId, + refreshUrl: method.refreshUrl, + }; +}; + +/** Discovers OpenID Connect endpoints, then uses the same PKCE flow as a declared authorization code grant. */ +const openIdConnectFlow = async ( + method: Extract, + baseUrl: string, +): Promise => { + const endpoints = await discoverOpenIdConnect(method.discoveryUrl, baseUrl); + const result = await authorizationCodeFlow( + { + ...method, + grant: 'authorizationCode', + authorizationUrl: endpoints.authorizationUrl, + tokenUrl: endpoints.tokenUrl, + refreshUrl: endpoints.tokenUrl, + }, + baseUrl, + ); + return { ...result, refreshUrl: endpoints.tokenUrl }; +}; + +const scopeParam = (scopes: readonly string[]): Record => + scopes.length > 0 ? { scope: scopes.join(' ') } : {}; + +type DeviceAuthorization = { + readonly deviceCode: string; + readonly verificationUrl: string; + readonly userCode?: string; + readonly expiresAt: number; + readonly intervalMs: number; +}; + +/** Starts a device grant and validates the response before anything is printed to the terminal. */ +const requestDeviceAuthorization = async ( + endpoint: string, + baseUrl: string, + clientId: string, + scopes: readonly string[], +): Promise => { + const url = requireSecureUrl(endpoint, baseUrl, 'device authorization endpoint'); + const { response, text } = await postForToken(url, { client_id: clientId, ...scopeParam(scopes) }); + const payload = parseJson(text); + if (!response.ok) { + throw new Error( + 'The device authorization endpoint rejected the request' + tokenErrorDetail(payload, response.status), + ); + } + const deviceCode = payload?.['device_code']; + const verificationUri = payload?.['verification_uri_complete'] ?? payload?.['verification_uri']; + const userCode = payload?.['user_code']; + const expiresIn = payload?.['expires_in']; + const interval = payload?.['interval']; + if ( + typeof deviceCode !== 'string' || + !deviceCode || + typeof verificationUri !== 'string' || + !verificationUri + ) { + throw new Error('The device authorization endpoint returned an incomplete response.'); + } + if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) { + throw new Error('The device authorization endpoint returned no usable expiry.'); + } + const verificationUrl = requireSecureUrl(verificationUri, baseUrl, 'device verification URL').toString(); + return { + deviceCode, + verificationUrl, + ...(typeof userCode === 'string' && userCode ? { userCode } : {}), + // Capped at this CLI's own ceiling rather than taken as given. `expires_in` comes from a + // spec-derived endpoint, so a hostile or simply wrong one answering `1e12` would make the only + // loop guard in `pollDeviceToken` unreachable and leave `login` waiting with no way out but + // Ctrl-C — the browser grant is bounded by `BROWSER_FLOW_TIMEOUT_MS` and this is its equal. + expiresAt: Date.now() + Math.min(expiresIn * 1000, DEVICE_FLOW_TIMEOUT_MS), + // RFC 8628 defaults to five seconds. A server may shorten it — a local authorization server + // answering instantly is the case that matters — but never below `DEVICE_POLL_FLOOR_MS`, and a + // negative or non-finite value never shortens it at all. Honouring a literal zero polled with no + // delay for the whole of `expires_in`: a request flood against someone else's token endpoint and + // a pinned core here, for a grant that answers `authorization_pending` until the user acts. + intervalMs: + typeof interval === 'number' && Number.isFinite(interval) && interval >= 0 + ? Math.max(interval * 1000, DEVICE_POLL_FLOOR_MS) + : 5000, + }; +}; + +/** Polls the token endpoint until the user authorizes, declines, or the device code expires. */ +const pollDeviceToken = async ( + endpoint: string, + baseUrl: string, + clientId: string, + device: DeviceAuthorization, +): Promise => { + const url = requireSecureUrl(endpoint, baseUrl, 'token endpoint'); + let intervalMs = device.intervalMs; + while (Date.now() < device.expiresAt) { + await waitFor(Math.min(intervalMs, Math.max(0, device.expiresAt - Date.now()))); + if (Date.now() >= device.expiresAt) break; + const { response, text } = await postForToken(url, { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: device.deviceCode, + client_id: clientId, + }); + const payload = parseJson(text); + if (response.ok) return tokenResponse(payload); + const error = payload?.['error']; + if (error === 'authorization_pending') continue; + if (error === 'slow_down') { + intervalMs += 5000; + continue; + } + if (error === 'expired_token') throw new Error('The device code expired before authorization completed.'); + if (error === 'access_denied') throw new Error('The device authorization request was denied.'); + throw new Error( + 'The token endpoint rejected the device code' + tokenErrorDetail(payload, response.status), + ); + } + throw new Error('The device code expired before authorization completed.'); +}; + +/** Waits between device-code polls without blocking the event loop that owns terminal I/O. */ +const waitFor = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +/** The suffix OpenID Connect Discovery appends to an issuer to reach its configuration document. */ +const OPENID_CONFIGURATION_PATH = '/.well-known/openid-configuration'; + +/** + * The issuer a discovery URL was built from, per OpenID Connect Discovery section 4. + * + * The spec builds the URL by appending the well-known path to the issuer, so removing it again is + * what the returned `issuer` has to match. A URL spelled any other way — a document naming a + * configuration endpoint that is not derived from its issuer at all — yields nothing, and the + * caller falls back to comparing origins, which still pins the answer to the host that was asked. + */ +const discoveryIssuer = (url: URL): string | undefined => + url.pathname.endsWith(OPENID_CONFIGURATION_PATH) + ? url.origin + url.pathname.slice(0, -OPENID_CONFIGURATION_PATH.length) + : undefined; + +/** Compares two issuer spellings, where a trailing slash is the one difference that is not one. */ +const sameIssuer = (left: string, right: string): boolean => + left.replace(/\/+$/u, '') === right.replace(/\/+$/u, ''); + +/** Fetches the OpenID Provider configuration and keeps only the two endpoints PKCE needs. */ +const discoverOpenIdConnect = async ( + discoveryUrl: string, + baseUrl: string, +): Promise<{ readonly authorizationUrl: string; readonly tokenUrl: string }> => { + const url = requireSecureUrl(discoveryUrl, baseUrl, 'OpenID Connect discovery document'); + const { response, text } = await getJson(url, 'OpenID Connect discovery document'); + const payload = parseJson(text); + if (!response.ok) { + throw new Error( + 'The OpenID Connect discovery document could not be read' + tokenErrorDetail(payload, response.status), + ); + } + // Section 4.3 requires the returned issuer to be the one the URL was built from. It is the check + // that keeps a provider from naming somebody else's endpoints: without it this grant sends the + // user's browser, and then the authorization code, wherever an untrusted document says. + const issuer = payload?.['issuer']; + if (typeof issuer !== 'string' || !issuer) { + throw new Error('The OpenID Connect discovery document named no issuer.'); + } + const expected = discoveryIssuer(url); + const issuerUrl = requireSecureUrl(issuer, '', 'OpenID Connect issuer'); + const matches = expected === undefined ? issuerUrl.origin === url.origin : sameIssuer(issuer, expected); + if (!matches) { + throw new Error( + 'The OpenID Connect discovery document is issued by ' + + safeText(issuer) + + ', which is not the provider it was fetched from.', + ); + } + const authorizationUrl = payload?.['authorization_endpoint']; + const tokenUrl = payload?.['token_endpoint']; + if (typeof authorizationUrl !== 'string' || typeof tokenUrl !== 'string') { + throw new Error( + 'The OpenID Connect discovery document returned no usable authorization and token endpoints.', + ); + } + // Resolved against the issuer, never the API base URL: a relative `/authorize` belongs to the + // provider that answered, and resolving it against the API would point the browser — and the code + // exchange after it — at the API host instead. + return { + authorizationUrl: requireSecureUrl( + authorizationUrl, + issuerUrl.toString(), + 'OpenID Connect authorization endpoint', + ).toString(), + tokenUrl: requireSecureUrl(tokenUrl, issuerUrl.toString(), 'OpenID Connect token endpoint').toString(), + }; +}; + +/** + * The browser sign-in: PKCE authorization code against a loopback redirect (RFC 8252). + * + * The CLI is a public client, so it holds no secret; PKCE is what stops an intercepted code from + * being redeemed by anything but this process. The redirect listens on 127.0.0.1 rather than a + * public interface, and the authorization URL is printed before the browser is opened so a headless + * or remote shell can still complete the flow by hand. + */ +const authorizationCodeFlow = async ( + method: Extract, + baseUrl: string, +): Promise<{ readonly token: TokenResponse; readonly clientId: string | undefined }> => { + const clientId = method.clientId; + // scalar-sdk-generator:custom-code configured-browser-login:start + // public provider settings must be verified before enabling login + if (method.resource && profileKey(baseUrl) !== profileKey(method.resource)) { + throw new UsageError('Browser sign-in is unavailable for this API base URL.'); + } + if (!clientId || !method.authorizationUrl || !method.tokenUrl || (method.resource && (!method.issuer || !method.revocationUrl))) { + throw new UsageError('Browser sign-in is not configured for this release. Use an API key until the provider rollout is complete.'); + } + // scalar-sdk-generator:custom-code configured-browser-login:end + const authorizeUrl = requireSecureUrl(method.authorizationUrl, baseUrl, 'authorization endpoint'); + // scalar-sdk-generator:custom-code oauth-resource:start + // RFC 8707 resource binding + if (method.resource) authorizeUrl.searchParams.set('resource', method.resource); + // scalar-sdk-generator:custom-code oauth-resource:end + const verifier = base64Url(randomBytes(32)); + const challenge = base64Url(createHash('sha256').update(verifier).digest()); + // scalar-sdk-generator:custom-code oauth-state:start + // 256-bit callback state + const state = base64Url(randomBytes(32)); + // scalar-sdk-generator:custom-code oauth-state:end + + const server = createServer(); + // Bound first, and only then given its request handler. Registering the handler earlier meant a + // bind failure (a configured `redirectPort` already in use) rejected two promises while only + // `listen`'s was ever awaited, and the orphan took the process down with an unhandled rejection — + // losing the friendly message and the auth exit status. Nothing can reach the port in between: + // the browser has not been opened yet. + // A configured port that is already taken is the one bind failure a user can act on, and Node's + // raw `EADDRINUSE` names neither the setting that chose it nor the alternative. Port 0 lets the + // OS pick, so it cannot reach this. + const port = await listen(server, method.redirectPort ?? 0).catch((error: unknown) => { + const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined; + if (code === 'EADDRINUSE' && method.redirectPort) { + throw new Error( + 'Port ' + + String(method.redirectPort) + + ' is already in use, so the sign-in redirect could not be received. Free it, or remove the configured redirect port to let the operating system choose one.', + ); + } + throw error; + }); + // scalar-sdk-generator:custom-code callback-issuer:start + // RFC 9207 issuer binding + const redirect = awaitRedirect(server, state, REDIRECT_PATH, method.issuer); + // scalar-sdk-generator:custom-code callback-issuer:end + const redirectUri = 'http://127.0.0.1:' + String(port) + REDIRECT_PATH; + try { + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('client_id', clientId); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('code_challenge', challenge); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + if (method.scopes.length > 0) authorizeUrl.searchParams.set('scope', method.scopes.join(' ')); + const target = authorizeUrl.toString(); + processStderr.write( + 'Opening your browser to sign in. If it does not open, visit:\n\n ' + target + '\n\n', + ); + openBrowser(target); + const code = await withTimeout( + redirect, + BROWSER_FLOW_TIMEOUT_MS, + 'Timed out waiting for the browser redirect.', + ); + const token = await exchangeToken(method.tokenUrl, baseUrl, { + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: verifier, + }); + return { token, clientId }; + } finally { + server.close(); + // Belt and braces with the `connection: close` above: a connection opened but never used (a + // browser preconnecting to the redirect host) is not covered by that header and would keep the + // listener, and so the process, alive. + server.closeAllConnections(); + } +}; + +/** + * The style for the one page a generated CLI ever puts in front of a browser. + * + * Every colour is a token on `:root` so the palette can be restyled in one block, and each is + * redefined under `prefers-color-scheme: dark` rather than being left to the browser. + * + * The font stack names Inter first and then falls back to the system UI face. Naming it is free + * where it is already installed and costs nothing where it is not — what it must never do is + * *fetch* it, for the same reason the rest of this page is inline. + */ +const PAGE_STYLE = + // Values taken from the Scalar dashboard's own theme: `--scalar-background-1/2`, + // `--scalar-color-1/2`, `--scalar-border-color` and the semantic green and red, per mode. + // + // `light-dark()` rather than a `prefers-color-scheme` block, so each token states its light and + // dark value together and the palette can be read as pairs. It resolves against the + // `color-scheme` declared on the same rule, which is why that comes first. + // + // Where the function is not understood (before Firefox 120, Chrome 123, Safari 17.5) every token + // is invalid at computed-value time and the properties using them fall back to their initial + // values — black on white, with an unstyled icon and no card border. Plain, still legible, and + // still saying the sign-in worked, which is the page's whole job. + ':root{color-scheme:light dark;' + + '--bg:light-dark(#fff,#0f0f0f);' + + '--card:light-dark(#f6f6f6,#1a1a1a);' + + '--fg:light-dark(#1b1b1b,#e7e7e7);' + + '--muted:light-dark(#757575,#a4a4a4);' + + '--line:light-dark(#dfdfdf,#2d2d2d);' + + '--ok:light-dark(#069061,#00b648);' + + '--err:light-dark(#ef0006,#dc1b19)}' + + '*{box-sizing:border-box}' + + 'body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:var(--bg);color:var(--fg);' + + // Mirrors `--scalar-font`, which also leads with Inter and falls back to the system UI face. + 'font:16px/1.6 Inter,ui-sans-serif,system-ui,sans-serif;-webkit-font-smoothing:antialiased}' + + // `--scalar-radius` is 3px and `--scalar-radius-lg` doubles it; 6px is that, spelled literally + // because the page carries no token indirection of its own. + '.card{width:100%;max-width:23rem;padding:40px 32px;text-align:center;background:var(--card);' + + 'border:1px solid var(--line);border-radius:6px}' + + 'svg{width:38px;height:38px}' + + // `--scalar-bold` is 600, which is what the dashboard's own headings use. + 'h1{margin:20px 0 8px;font-size:18px;font-weight:600;letter-spacing:-.01em}' + + 'p{margin:0;color:var(--muted);font-size:14px}'; + +/** + * The tab icon, as the same mark the page shows. + * + * A data URI rather than a file, so the browser never requests `/favicon.ico` from the loopback + * listener — which would otherwise answer a 404 to a request it only receives because the page + * declared no icon. + * + * Deliberately a status mark and not a vendor logo: this page is served by every generated CLI, so + * an icon identifying the SDK's *author* would put one company's branding on another's sign-in. + * + * The colours are the dark-mode pair, which read against a light and a dark tab strip alike; + * `light-dark()` is no use here because the SVG is a separate document with no `color-scheme` of + * its own. Only `#`, `<` and `>` are escaped, which is all a data URI in an attribute needs, and + * the SVG quotes its attributes with apostrophes so the `href` can keep the double quotes. + */ +const favicon = (mark: string, ok: boolean): string => { + const svg = + "" + + ""; + return ( + '' + ); +}; + +/** + * Renders the redirect page. + * + * Inlined down to the icon, with nothing fetched: the listener answers on loopback, where the + * machine may well have no route out at all, and a callback page that reached for a stylesheet or a + * font would also tell whoever served it that a sign-in had just happened. + * + * Nothing the authorization server sent is interpolated here. The provider's own `error` goes to + * the terminal through `safeText` instead, which keeps untrusted text out of markup entirely rather + * than relying on this function to escape it. + */ +const redirectPage = (heading: string, detail: string, ok: boolean): string => { + const mark = ok ? 'm8 12.5 2.5 2.5 5.5-6' : 'm9 9 6 6m0-6-6 6'; + return ( + '' + + '' + + favicon(mark, ok) + + '' + + heading + + '
' + + '' + + '

' + + heading + + '

' + + detail + + '

' + ); +}; + +/** + * Resolves with the authorization code the redirect carries. + * + * Anything that is not the redirect — a browser probing `/favicon.ico`, a stray request to the + * port — is answered 404 and ignored, so it cannot resolve or reject the wait. The `state` is + * compared in constant time and rejected on mismatch, which is what ties the redirect back to the + * request this process started. + */ +const awaitRedirect = ( + server: ReturnType, + state: string, + path: string, + // scalar-sdk-generator:custom-code callback-issuer-parameter:start + // RFC 9207 issuer binding + issuer?: string, + // scalar-sdk-generator:custom-code callback-issuer-parameter:end +): Promise => + new Promise((resolve, reject) => { + server.on('request', (request, response) => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + const params = url.searchParams; + const send = (status: number, type: string, body: string): void => { + // `connection: close` rather than the default keep-alive: `server.close()` waits for open + // connections, and a browser holding one would leave the CLI running after it had signed in. + response.writeHead(status, { 'content-type': type + '; charset=utf-8', connection: 'close' }); + response.end(body); + }; + const finish = (status: number, heading: string, detail: string, ok: boolean): void => { + send(status, 'text/html', redirectPage(heading, detail, ok)); + }; + // The path is part of what was registered as `redirect_uri`, so anything else is not the + // redirect however it is decorated. Checked alongside the parameters rather than instead of + // them: both are cheap, and together they keep this handler from reading a request that was + // never the provider's as though it were. + if (url.pathname !== path || (!params.has('code') && !params.has('error'))) { + // Plain text, and deliberately not the page above: this answers a probe rather than a + // person — a browser asking for `/favicon.ico`, or anything else that finds the port. + send(404, 'text/plain', 'Not found.\n'); + return; + } + // scalar-sdk-generator:custom-code callback-verification:start + if (params.getAll('state').length !== 1 || !sameToken(params.get('state') ?? '', state) || + (issuer !== undefined && (params.getAll('iss').length !== 1 || params.get('iss') !== issuer))) { + // Ends the sign-in rather than waiting for a better redirect. A request that reaches here is + // on the registered path and carries a `code` or an `error`, which makes it the provider's + // redirect rather than a stray probe — the path check above is what turns those away — so a + // `state` that does not match is tampering or a stale tab, and neither is worth leaving the + // user watching a terminal for `BROWSER_FLOW_TIMEOUT_MS` on the chance that a second, + // better redirect arrives. + finish( + 400, + 'Sign-in could not be verified', + 'This page did not match the sign-in your terminal started. Return to your terminal and try again.', + false, + ); + reject(new Error('The browser redirect did not match this sign-in attempt.')); + return; + } + // scalar-sdk-generator:custom-code callback-verification:end + const failure = params.get('error'); + if (failure) { + finish( + 400, + 'Sign-in failed', + 'Your provider declined the request. Return to your terminal for details.', + false, + ); + reject(new Error('Authorization failed: ' + safeText(failure) + '.')); + return; + } + const code = params.get('code'); + if (!code) { + finish( + 400, + 'Sign-in failed', + 'The redirect carried no authorization code. Return to your terminal and try again.', + false, + ); + reject(new Error('The browser redirect carried no authorization code.')); + return; + } + finish(200, 'Signed in', 'You can close this window and return to your terminal.', true); + resolve(code); + }); + server.on('error', reject); + }); + +/** Starts the loopback listener, resolving with the port the OS actually bound. */ +const listen = (server: ReturnType, port: number): Promise => + new Promise((resolve, reject) => { + server.once('error', reject); + // 127.0.0.1 rather than every interface: the redirect is for this machine's browser alone. + server.listen(port, '127.0.0.1', () => { + const address = server.address(); + if (address && typeof address === 'object') resolve(address.port); + else reject(new Error('Could not open a local port for the sign-in redirect.')); + }); + }); + +/** + * Opens a URL in the user's browser, best effort. + * + * A missing opener is not an error: the URL was printed first precisely so the flow still completes + * when this does nothing. + * + * The URL never reaches a shell. On Unix that is automatic — `open` and `xdg-open` are spawned + * directly. Windows is the trap: `cmd /c start` would make cmd itself the shell, and Node quotes an + * argument only when it contains a space, tab or quote, so every `&` in an authorization URL would + * arrive at cmd as a command separator. That breaks the flow even on a benign document (the browser + * opens on the first query parameter alone, with no `state` or `code_challenge`) and runs arbitrary + * programs on a hostile one, since `&` survives URL normalization inside the path. So Windows gets + * PowerShell with the URL in the environment, where nothing parses it as a command line at all. + */ +const openBrowser = (url: string): void => { + const windows = process.platform === 'win32'; + const [file, ...args] = windows + ? [windowsPowerShell(), '-NoProfile', '-NonInteractive', '-Command', '-'] + : process.platform === 'darwin' + ? ['open', url] + : ['xdg-open', url]; + try { + const child = spawn(file as string, args, { + stdio: windows ? ['pipe', 'ignore', 'ignore'] : 'ignore', + detached: !windows, + ...(windows ? { env: { ...process.env, SCALAR_BROWSER_URL: url } } : {}), + }); + child.on('error', () => {}); + if (windows) { + // `-FilePath` is explicit so a URL is never read as a PowerShell parameter, and the value comes + // from the environment so it is not part of any command line. + child.stdin?.end('Start-Process -FilePath $env:SCALAR_BROWSER_URL\n'); + } + child.unref(); + } catch { + // Ignored; see this function's doc comment. + } +}; + +/** + * Exchanges a grant for an access token. + * + * The endpoint comes from the OpenAPI document, which is untrusted input, so it is resolved against + * the base URL and required to be HTTPS before a secret is put in the request body. Only the + * standard `error`/`error_description` fields of a failure response are reported: a server that + * echoed the submitted secret back would otherwise have it printed to stderr and into any CI log. + */ +const exchangeToken = async ( + tokenUrl: string, + baseUrl: string, + body: Readonly>, + timeoutMs: number = TOKEN_REQUEST_TIMEOUT_MS, +): Promise => { + const url = requireSecureUrl(tokenUrl, baseUrl, 'token endpoint'); + const { response, text } = await postForToken(url, body, timeoutMs); + const payload = parseJson(text); + if (!response.ok) + throw new Error('The token endpoint rejected the request' + tokenErrorDetail(payload, response.status)); + return tokenResponse(payload); +}; + +/** Validates the standard OAuth token response fields before a token reaches the credential store. */ +const tokenResponse = (payload: Record | undefined): TokenResponse => { + const accessToken = payload?.['access_token']; + if (typeof accessToken !== 'string' || !accessToken) { + throw new Error('The token endpoint returned no access token.'); + } + const refreshToken = payload?.['refresh_token']; + const expiresIn = payload?.['expires_in']; + return { + accessToken, + ...(typeof refreshToken === 'string' && refreshToken ? { refreshToken } : {}), + // A lifetime at or below zero is not a lifetime. Stored as one it makes `needsRefresh` true + // forever, so every command pays a full refresh round trip ahead of the request it wanted — + // on a token that is in fact perfectly good. The device grant already applies this floor to + // its own `expires_in`; treating an unusable value as unstated puts the two in step. + ...(typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0 ? { expiresIn } : {}), + }; +}; + +/** Fetches a bounded JSON document under the same timeout policy used for token endpoints. */ +const getJson = async ( + url: URL, + label: string, +): Promise<{ readonly response: Response; readonly text: string }> => { + try { + const response = await fetch(url, { + headers: { accept: 'application/json' }, + // Same reasoning as `postForToken`, for the document that *names* the endpoints rather than + // the exchange itself: only the URL handed to `fetch` was checked, so a spec-derived + // discovery endpoint that redirects would have its answer — the authorization and token URLs + // this whole grant is then pointed at — supplied by whichever origin it forwarded to. + redirect: 'error', + signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS), + }); + return { response, text: await readBounded(response) }; + } catch (error) { + const name = error instanceof Error ? error.name : ''; + throw new Error( + name === 'TimeoutError' || name === 'AbortError' + ? 'The ' + label + ' did not answer within ' + String(TOKEN_REQUEST_TIMEOUT_MS / 1000) + ' seconds.' + : 'Could not reach the ' + label + '.', + ); + } +}; + +/** + * Posts the grant and reads the answer, both under one deadline. + * + * The read is inside the same `try` as the request because the deadline covers it too: a server + * that answers its headers and then stalls aborts here, and without this the user would see a raw + * `The operation was aborted due to timeout` instead of a sentence naming the endpoint. + * + * The endpoint is spec-derived, so its own error text is never repeated beyond whether the wait ran + * out: a `fetch` failure can carry the request, and the request carries the secret. + */ +const postForToken = async ( + url: URL, + body: Readonly>, + timeoutMs: number = TOKEN_REQUEST_TIMEOUT_MS, +): Promise<{ readonly response: Response; readonly text: string }> => { + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, + body: new URLSearchParams(body).toString(), + // A 307/308 preserves the POST body. Do not let a spec-derived endpoint forward a refresh + // token, authorization code, or device code to a different (possibly insecure) origin. + redirect: 'error', + signal: AbortSignal.timeout(timeoutMs), + }); + return { response, text: await readBounded(response) }; + } catch (error) { + // A deadline reached during the request is a `TimeoutError`; one reached while the body is + // still arriving surfaces as the abort itself. + const name = error instanceof Error ? error.name : ''; + throw new Error( + name === 'TimeoutError' || name === 'AbortError' + ? 'The token endpoint did not answer within ' + String(timeoutMs / 1000) + ' seconds.' + : 'Could not reach the token endpoint.', + ); + } +}; + +/** + * Reads at most {@link TOKEN_RESPONSE_LIMIT} characters of a response body. + * + * `response.text()` would buffer whatever the endpoint chooses to send before anything could cap + * it, so the stream is read in pieces and cancelled once there is more than enough to parse. + */ +const readBounded = async (response: Response): Promise => { + const body = response.body; + if (!body) return ''; + const reader = body.getReader(); + const decoder = new TextDecoder(); + let text = ''; + try { + while (text.length < TOKEN_RESPONSE_LIMIT) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + } finally { + // Nothing is read after this point, and an abandoned body keeps the socket open. + void reader.cancel().catch(() => {}); + } + return text.slice(0, TOKEN_RESPONSE_LIMIT); +}; + +const parseJson = (text: string): Record | undefined => { + try { + const parsed: unknown = JSON.parse(text); + return parsed && typeof parsed === 'object' ? (parsed as Record) : undefined; + } catch { + return undefined; + } +}; + +const tokenErrorDetail = (payload: Record | undefined, status: number): string => { + const code = payload?.['error']; + const description = payload?.['error_description']; + const parts = [ + typeof code === 'string' ? code : undefined, + typeof description === 'string' ? description : undefined, + ] + .filter((part): part is string => !!part) + .map(safeText); + return parts.length > 0 ? ' (' + parts.join(': ') + ').' : ' with status ' + String(status) + '.'; +}; + +/** + * Resolves a spec-derived endpoint and refuses to send credentials to it in the clear. + * + * HTTPS is required, with the usual carve-out for loopback so a local development authorization + * server still works. Without this, a document naming an `http://` token endpoint would have the + * CLI post a client secret unencrypted to whatever host it named. + */ +const requireSecureUrl = (raw: string, baseUrl: string, label: string): URL => { + let url: URL; + try { + url = new URL(raw, baseUrl || undefined); + } catch { + throw new Error('The ' + label + ' is not a usable URL.'); + } + if (url.protocol === 'https:') return url; + if (url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname)) return url; + throw new Error( + 'Refusing to send credentials to a non-HTTPS ' + + label + + ' (' + + safeText(url.protocol + '//' + url.host) + + ').', + ); +}; + +/** + * Compares two opaque tokens without leaking their contents through timing. + * + * Lengths are compared first because `timingSafeEqual` throws on a mismatch; both values here are + * fixed-length base64url, so an unequal length is already a mismatch. + */ +const sameToken = (received: string, expected: string): boolean => { + const a = Buffer.from(received); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +}; + +const base64Url = (bytes: Buffer): string => bytes.toString('base64url'); + +/** + * Strips anything from a network-supplied string that a terminal would act on. + * + * An OAuth error message reaches stderr verbatim, and both the redirect query and the token + * endpoint's response body are written by whoever the document points at, so control characters + * (escape sequences that reposition the cursor or recolour the line) are removed and the result is + * capped rather than printed as received. + */ +const safeText = (value: string): string => { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point. + const stripped = value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').trim(); + return stripped.length > 200 ? stripped.slice(0, 200) + '...' : stripped; +}; + +const withTimeout = (promise: Promise, ms: number, message: string): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + }, + ); + }); + +/** + * A failure the user can fix by invoking the command differently, reported as a usage error. + * + * Distinguished from an authentication failure so a script can tell "you typed the wrong flow name" + * (retrying will not help) from "the credential was rejected" (a fresh sign-in might). + */ +export class UsageError extends Error {} + +const NO_TERMINAL = + 'Sign-in needs an interactive terminal. Run it from a terminal, pick a flow that needs no prompt with --flow (see --help), or set the credential through its flag or environment variable instead.'; + +/** Puts the terminal cursor back, for the picker's own exit paths and for a signal that skips them. */ +const restoreCursor = (): void => { + processStderr.write('\u001b[?25h'); +}; + +/** One row of the sign-in picker: what it reads as, and the `--flow` value that skips the prompt. */ +type Choice = { + readonly label: string; + readonly hint: string; +}; + +/** + * Puts a list of choices to the user and resolves the index of the one chosen. + * + * A highlight moved with the arrow keys rather than a number typed at a prompt, so the row being + * chosen is the row being read. Only the highlighted row carries its `--flow` value, which is what + * keeps a nine-line list from spelling out nine of them; the value is still on screen for whichever + * flow the user is about to pick, which is the one they would want to write down. + * + * Raw mode is what delivers a keystroke without Enter behind it, and is required rather than fallen + * back from, exactly as `promptSecret` requires it: a terminal Node reports as a TTY always offers + * it, so the alternative was a branch that could not be reached to be tested. A digit still jumps to + * its row, so the keys that answered the numbered prompt this replaces still answer this one. + * + * Drawn on stderr, like every other prompt, so stdout stays machine-readable. + */ +const promptChoice = async (heading: string, choices: readonly Choice[]): Promise => { + if (!processStdin.isTTY || typeof processStdin.setRawMode !== 'function') { + throw new UsageError(NO_TERMINAL); + } + return new Promise((resolve, reject) => { + let active = 0; + // How many rows the last draw wrote, which is how far back up the cursor has to go to redraw + // over them. Without it every keystroke would leave another copy of the list in the scrollback. + let drawn = 0; + const width = (): number => Number(processStderr.columns) || 80; + /** + * The slice of choices to draw, keeping the highlight inside it. + * + * A list taller than the terminal would scroll the region the redraw is about to move back over, + * putting every subsequent frame in the wrong place, so a long list is windowed instead. + */ + const window = (): readonly Choice[] => { + const room = Math.max(1, (Number(processStderr.rows) || 24) - 3); + if (choices.length <= room) return choices; + const start = Math.min(Math.max(0, active - (room >> 1)), choices.length - room); + return choices.slice(start, start + room); + }; + const render = (): void => { + if (drawn > 0) processStderr.write('\u001b[' + String(drawn) + 'A'); + const columns = width(); + const shown = window(); + const lines = [clip(heading, columns)]; + for (const choice of shown) { + const selected = choice === choices[active]; + const head = (selected ? '> ' : ' ') + choice.label; + const tail = ' (--flow ' + choice.hint + ')'; + const plain = clip(head + tail, columns); + // The flow name is kept on every row rather than only the highlighted one, because labels + // are not unique: an API key in a header, in a query parameter and in a cookie all read as + // `Enter your API key`, and the flow name is the only thing telling those rows apart. It is + // dimmed so the labels still read as the list, and dropped entirely from a row too narrow + // to hold it, where clipping has already eaten the closing bracket. + const row = plain === head + tail ? head + '\u001b[2m' + tail + '\u001b[22m' : plain; + lines.push(selected ? '\u001b[36m' + row + '\u001b[39m' : row); + } + const position = + shown.length < choices.length ? String(active + 1) + '/' + String(choices.length) + ', ' : ''; + lines.push(clip(' ' + position + 'arrows to move, enter to choose', columns)); + // Each row is cleared to its end before being rewritten: a shorter line would otherwise leave + // the tail of whatever longer one occupied that row in the frame before. + processStderr.write(lines.map((line) => '\u001b[2K' + line).join('\n') + '\n'); + drawn = lines.length; + }; + const move = (delta: number): void => { + active = (active + delta + choices.length) % choices.length; + render(); + }; + const jump = (index: number): void => { + if (index < 0 || index >= choices.length) return; + active = index; + render(); + }; + const onData = (chunk: string): void => { + let index = 0; + while (index < chunk.length) { + const character = chunk[index] ?? ''; + if (character === '\u001b') { + // An arrow is ESC [ A or ESC O A. The sequence is scanned to its final byte so that no + // part of it is read as a keystroke in its own right — the `[` of an Up arrow would + // otherwise be an ordinary printable character arriving between two frames. + let cursor = index + 1; + if (chunk[cursor] === '[' || chunk[cursor] === 'O') { + cursor += 1; + // A CSI sequence ends at its final byte, anything in the range `@` to `~`. + while (cursor < chunk.length) { + const byte = chunk[cursor] ?? ''; + if (byte >= '@' && byte <= '~') break; + cursor += 1; + } + const final = chunk[cursor] ?? ''; + if (final === 'A') move(-1); + else if (final === 'B') move(1); + else if (final === 'H') jump(0); + else if (final === 'F') jump(choices.length - 1); + index = cursor + 1; + continue; + } + index = cursor; + continue; + } + index += 1; + if (character === '\r' || character === '\n') { + finish(); + return; + } + // Ctrl-C and Ctrl-D: raw mode has taken the terminal's own handling away, so the cancel has + // to be honoured here or the picker could never be escaped. + if (character === '\u0003' || character === '\u0004') { + finish(new UsageError('Cancelled.')); + return; + } + // Ctrl-P and Ctrl-N beside the vim keys, so the three habits a terminal user might arrive + // with all work without anything on screen having to offer them. + if (character === 'k' || character === '\u0010') move(-1); + else if (character === 'j' || character === '\u000e') move(1); + else if (character >= '1' && character <= '9') jump(Number(character) - 1); + } + }; + const finish = (error?: Error): void => { + processStdin.off('data', onData); + processStdin.setRawMode(false); + processStdin.pause(); + // The list is wound back over and replaced by the single line saying what was chosen: a menu + // is worth reading while it is being answered and is clutter in the scrollback afterwards. + if (drawn > 0) processStderr.write('\u001b[' + String(drawn) + 'A\u001b[0J'); + process.off('exit', restoreCursor); + restoreCursor(); + if (error) { + reject(error); + return; + } + const chosen = choices[active]; + processStderr.write( + heading + ' ' + (chosen?.label ?? '') + ' (--flow ' + (chosen?.hint ?? '') + ')\n', + ); + resolve(active); + }; + // Hidden for the duration: the cursor would otherwise sit at the end of the last row drawn, + // blinking somewhere that has nothing to do with the highlight. Restoring it is registered with + // the hide rather than left to `finish`, because a signal that ends the process while the menu + // is up reaches neither the key handler nor the rejection paths, and an invisible cursor + // outlives the CLI it belonged to — the user's next shell prompt is where they find out. + process.on('exit', restoreCursor); + processStderr.write('\u001b[?25l'); + processStdin.setRawMode(true); + processStdin.resume(); + processStdin.setEncoding('utf8'); + processStdin.on('data', onData); + render(); + }); +}; + +/** + * Cuts a line to the terminal's width. + * + * A row that wrapped would occupy two of them, and the redraw counts rows rather than measuring + * them, so one wrapped label would put every following frame a line out of place. + */ +const clip = (text: string, width: number): string => + text.length <= width ? text : text.slice(0, Math.max(0, width - 3)) + '...'; + +/** Reads one visible line from the terminal. Prompts go to stderr so stdout stays machine-readable. */ +const promptLine = (label: string): Promise => + new Promise((resolve, reject) => { + if (!processStdin.isTTY) { + reject(new UsageError(NO_TERMINAL)); + return; + } + const rl = createInterface({ input: processStdin, output: processStderr }); + let answered = false; + // Closing the interface emits 'close' synchronously, so the guard is what keeps the end-of-input + // fallback from resolving ahead of the answer that was just typed. + rl.on('close', () => { + if (!answered) resolve(''); + }); + rl.question(label, (answer) => { + answered = true; + resolve(answer.trim()); + rl.close(); + }); + }); + +/** + * Reads one secret from the terminal without echoing it. + * + * Raw mode is entered directly rather than muting a readline interface: readline exposes no + * supported way to suppress its echo, and the credential would otherwise be left on screen and in + * any recorded terminal session. Nothing typed here is written back, so there is no cursor to + * manage — only the terminating newline is emitted. + */ +const promptSecret = (label: string): Promise => + new Promise((resolve, reject) => { + if (!processStdin.isTTY || typeof processStdin.setRawMode !== 'function') { + reject(new UsageError(NO_TERMINAL)); + return; + } + processStderr.write(label); + let value = ''; + // Where an escape sequence has got to: 0 outside one, 1 just after ESC, 2 inside CSI/SS3 + // parameters. Dropping the ESC alone is not enough — an Up arrow is ESC [ A, and the `[` and + // `A` are ordinary printable bytes that would otherwise land in the middle of a credential the + // user cannot see to correct. + let escape = 0; + const onData = (chunk: string): void => { + for (const character of chunk) { + if (escape === 1) { + escape = character === '[' || character === 'O' ? 2 : 0; + continue; + } + if (escape === 2) { + // A CSI sequence ends at its final byte, anything in the range `@` to `~`. + if (character >= '@' && character <= '~') escape = 0; + continue; + } + if (character === '\u001b') { + escape = 1; + continue; + } + if (character === '\r' || character === '\n') { + finish(); + return; + } + // Ctrl-C and Ctrl-D: raw mode has taken the terminal's own handling away, so the cancel has + // to be honoured here or the prompt could never be escaped. + if (character === '\u0003') { + finish(new UsageError('Cancelled.')); + return; + } + if (character === '\u0004') { + if (value) finish(); + else finish(new UsageError('Cancelled.')); + return; + } + if (character === '\u007f' || character === '\b') { + value = value.slice(0, -1); + continue; + } + // Any other control byte is dropped for the same reason. + if (character < ' ') continue; + value += character; + } + }; + const finish = (error?: Error): void => { + processStdin.off('data', onData); + processStdin.setRawMode(false); + processStdin.pause(); + processStderr.write('\n'); + if (error) reject(error); + else resolve(value); + }; + processStdin.setRawMode(true); + processStdin.resume(); + processStdin.setEncoding('utf8'); + processStdin.on('data', onData); + }); diff --git a/src/cli/runtime.ts b/src/cli/runtime.ts new file mode 100644 index 0000000..c3a807b --- /dev/null +++ b/src/cli/runtime.ts @@ -0,0 +1,1430 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { accessSync, constants as fsConstants, createReadStream, readFileSync } from 'node:fs'; +import { stdin as processStdin, stdout as processStdout } from 'node:process'; + +import as from 'ansis'; +import { Command } from 'commander'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; + +import { encodeToon } from './toon.js'; +import { takeWarnings } from './credentials'; +import { type CliAuthDefinition, UsageError, runLogin, runLogout, storedCredentials } from './login'; + +import { createDiagnostics, diagnosticScope } from '../feedback/diagnostics.js'; + +const LOGIN_COMMAND = 'dedalus login'; + +type OutputFormat = 'auto' | 'json' | 'jsonl' | 'pretty' | 'raw' | 'toon' | 'yaml'; + +export type CliValueKind = + | 'string' + | 'number' + | 'integer' + | 'boolean' + | 'object' + | 'array' + | 'unknown' + | 'file'; + +export type CliFlagDefinition = { + readonly name: string; + readonly optionKey: string; + readonly paramKey: string; + readonly location: 'path' | 'query' | 'header' | 'cookie' | 'body'; + readonly required: boolean; + readonly description?: string; + readonly valueKind: CliValueKind; + // Array-valued flag accepted as a repeatable singular switch (`--status a --status b`). + readonly repeatable?: boolean; + // Value kind of one occurrence of a repeatable flag, so `--tag ''` stays an empty string rather + // than parsing as YAML `null`, and a string id is not read as a number. + readonly itemKind?: CliValueKind; + // Wire-property path under the parent param for dotted leaf flags (e.g. `--address.city`). + readonly objectPath?: readonly string[]; +}; + +export type CliCommandDefinition = { + readonly resourcePath: readonly string[]; + readonly commandPath: readonly string[]; + readonly methodName: string; + readonly summary?: string; + readonly description?: string; + readonly transport: 'http' | 'websocket'; + readonly streaming?: 'sse' | 'jsonl'; + readonly iterable: boolean; + readonly callShape: 'options' | 'params' | 'body'; + readonly authClientKeyRequirements: readonly (readonly string[])[]; + // Param key of a body blob that is forwarded bare or spread into params, depending on callShape. + readonly bodyParamKey?: string; + readonly positional: readonly CliFlagDefinition[]; + readonly flags: readonly CliFlagDefinition[]; +}; + +export type CliCommandGroup = { + readonly commandPath: readonly string[]; + readonly description?: string; +}; + +export type CliClientOptionDefinition = { + readonly clientKey: string; + readonly sdkKey: string; + readonly name: string; + readonly optionKey: string; + readonly env?: string; + readonly description?: string; + readonly auth: boolean; + // Documented in --help only. Deliberately NOT registered as a Commander default: a default + // would make the flag always look explicitly set, and the forwarded value would shadow the + // environment variable the SDK reads before falling back to this same default itself. + readonly defaultValue?: string; +}; + +export type CreateProgramOptions = { + readonly SDK: new (...args: any[]) => unknown; + readonly binaryName: string; + readonly version: string; + readonly description: string; + readonly defaultFormat: OutputFormat; + readonly defaultErrorFormat: OutputFormat; + readonly clientOptions: readonly CliClientOptionDefinition[]; + readonly commands: readonly CliCommandDefinition[]; + // Descriptions for the resource-shaped parents commands hang under. Only described groups are + // listed; Commander already creates an undescribed parent implicitly. + readonly groups?: readonly CliCommandGroup[]; + // Completion script per shell, generated alongside the command table. Absent when the SDK + // config disables shell completions, in which case no `completion` command is registered. + readonly completions?: Readonly>; + readonly auth?: CliAuthDefinition; +}; + +type OutputOptions = { + readonly format: OutputFormat; + // Command path shown above each `pretty` card (e.g. `workers list`). + readonly title?: string; + readonly transform?: string; + readonly rawOutput?: boolean; + readonly maxItems?: number; + readonly failOnWebSocketError?: boolean; + readonly onLimit?: () => void; +}; + +export type GlobalOptions = { + readonly baseUrl?: string; + readonly timeout?: string; + readonly maxRetries?: string; + readonly format?: OutputFormat; + readonly formatError?: OutputFormat; + readonly transform?: string; + readonly transformError?: string; + readonly rawOutput?: boolean; + readonly debug?: boolean; + readonly maxItems?: string; +}; + +export const createProgram = ({ + SDK, + binaryName, + version, + description, + defaultFormat, + defaultErrorFormat, + clientOptions, + commands, + groups, + completions, + auth, +}: CreateProgramOptions): Command => { + const program = usageExitCode(new Command()); + program + .enablePositionalOptions() + .name(binaryName) + .description(description) + .version(version, '-v, --version') + .showHelpAfterError() + .option('--base-url ', 'Override the base URL for API requests') + .option('--timeout ', 'Request timeout in milliseconds') + .option('--max-retries ', 'Number of retries for retryable failures') + .option( + '--format ', + 'Output format: auto, json, jsonl, pretty, raw, toon, yaml', + defaultFormat, + ) + .option( + '--format-error ', + 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml', + defaultErrorFormat, + ) + .option('--transform ', 'Dot-path transform for data output') + .option('--transform-error ', 'Dot-path transform for error output') + .option('-r, --raw-output', 'Print transformed string values without JSON quotes') + .option('--debug', 'Enable SDK debug logging'); + + // Register configured client options (auth credentials, org headers, etc.) as global flags. + // Mirrored on each subcommand below so users can supply them either before or after the verb. + for (const option of clientOptions) { + program.option('--' + option.name + ' ', clientOptionDescription(option)); + } + + for (const definition of commands) + addGeneratedCommand(program, SDK, clientOptions, definition, groups, auth); + + if (completions) addCompletionCommand(program, binaryName, completions); + if (auth) addAuthCommands(program, auth); + + return program; +}; + +// Routes Commander's own exits through the documented status. +// +// Commander defaults every parse failure — unknown flag, missing option-argument, unknown +// command — to exit 1, which is the status reserved here for an unclassified API failure, so a +// caller branching on the status could not tell a typo from a request that actually ran and +// failed. Only a zero exit (--help, --version) is passed through unchanged; everything else +// Commander exits on is a usage error by definition. +// +// This has to be applied to every command object: `addCommand` does not copy the callback the +// way `.command()` does, so setting it on the program alone would leave the subcommands — where +// flags are actually parsed — still exiting 1. +const usageExitCode = (command: Command): Command => + command.exitOverride((error) => process.exit(error.exitCode === 0 ? 0 : 2)); + +// Prints the completion script for one shell. The scripts are generated from the same command +// table this program is built from, so they always describe the commands and flags below; nothing +// is derived from the live Commander tree, and no shell code is assembled at runtime. +const addCompletionCommand = ( + program: Command, + binaryName: string, + completions: Readonly>, +): void => { + const shells = Object.keys(completions); + program + .command('completion') + .description('Print a shell completion script (' + shells.join(', ') + ')') + .argument('', 'Shell to print a completion script for: ' + shells.join(', ')) + .addHelpText('after', completionHelpExamples(binaryName, shells)) + .action((shell: string) => { + // Own-property lookup only: a bare `completions[shell]` would resolve inherited keys like + // "constructor" or "__proto__" to something that is not a completion script. + const script = Object.prototype.hasOwnProperty.call(completions, shell) + ? completions[shell] + : undefined; + if (script === undefined) { + process.stderr.write( + "Unsupported shell '" + shell + "'. Supported shells: " + shells.join(', ') + '\n', + ); + process.exitCode = 2; + return; + } + processStdout.write(script); + }); +}; + +// Shows how to load each script, since every shell wires completions up differently. +const completionHelpExamples = (binaryName: string, shells: readonly string[]): string => { + const examples: Record = { + bash: ' eval "$(' + binaryName + ' completion bash)" # or write it to /etc/bash_completion.d', + zsh: ' eval "$(' + binaryName + ' completion zsh)" # or write it to a directory on $fpath', + fish: ' ' + binaryName + ' completion fish | source # or write it to ~/.config/fish/completions', + }; + const lines = shells.map((shell) => examples[shell]).filter((line): line is string => line !== undefined); + return lines.length > 0 ? '\nAdd one of these to your shell startup file:\n' + lines.join('\n') : ''; +}; + +// `login`/`logout` sit at the top level unless the API already claims one of those names, in +// which case they fall back under an `auth` parent. That parent is created here rather than +// from the command table, so it needs its own description or `--help` lists a bare `auth`, +// which is the very thing the group table exists to prevent. Both paths are described so +// whichever one creates the group first carries it. +const authGroups = (auth: CliAuthDefinition): readonly CliCommandGroup[] => [ + { commandPath: auth.loginPath.slice(0, -1), description: 'Sign in and out' }, + { commandPath: auth.logoutPath.slice(0, -1), description: 'Sign in and out' }, +]; + +const addAuthCommands = (program: Command, auth: CliAuthDefinition): void => { + const flows = auth.methods.map((method) => method.name).join(', '); + const login = usageExitCode(new Command(auth.loginPath.at(-1) ?? 'login')) + .description('Sign in and save credentials for later commands') + .showHelpAfterError() + .option('--base-url ', 'Sign in against this base URL instead of the default') + .option('--flow ', 'Sign-in flow to use: ' + flows) + .addHelpText('after', authFlowHelp(auth)) + .action(async (_options: unknown, command: Command) => { + const options = command.optsWithGlobals<{ baseUrl?: string; flow?: string }>(); + await runAuthCommand(() => runLogin(auth, resolvedBaseUrl(auth, options.baseUrl), options.flow)); + }); + ensureCommandPath(program, auth.loginPath.slice(0, -1), authGroups(auth)).addCommand(login); + + const logout = usageExitCode(new Command(auth.logoutPath.at(-1) ?? 'logout')) + .description('Forget saved credentials') + .showHelpAfterError() + .option('--base-url ', 'Forget the credentials saved for this base URL') + .option('--all', 'Forget every saved credential, for every base URL') + .action(async (_options: unknown, command: Command) => { + const options = command.optsWithGlobals<{ baseUrl?: string; all?: boolean }>(); + await runAuthCommand(() => + runLogout(auth, resolvedBaseUrl(auth, options.baseUrl), options.all === true), + ); + }); + ensureCommandPath(program, auth.logoutPath.slice(0, -1), authGroups(auth)).addCommand(logout); +}; + +const runAuthCommand = async (run: () => string | Promise): Promise => { + try { + processStdout.write((await run()) + '\n'); + } catch (error) { + process.stderr.write((error instanceof Error ? error.message : String(error)) + '\n'); + process.exitCode = error instanceof UsageError ? 2 : 10; + } finally { + for (const warning of takeWarnings()) process.stderr.write(warning + '\n'); + } +}; + +const resolvedBaseUrl = (auth: CliAuthDefinition, flag: string | undefined): string => + flag?.trim() || process.env[auth.baseUrlEnv]?.trim() || auth.defaultBaseUrl; + +// Lists the flows by name so `--flow` can be used without first running the picker. +const authFlowHelp = (auth: CliAuthDefinition): string => + '\nSign-in flows:\n' + auth.methods.map((method) => ' ' + method.name + ' ' + method.label).join('\n'); + +const clientOptionDescription = (option: CliClientOptionDefinition): string => { + const parts: string[] = []; + if (option.description) parts.push(option.description); + if (option.env) parts.push('(can also be set with ' + option.env + ' env var)'); + // Stated last, matching resolution order: the flag wins, then the env var, then this value. + if (option.defaultValue !== undefined) parts.push('(defaults to ' + option.defaultValue + ')'); + return parts.join(' '); +}; + +// Constructs the embedded client, restating its credential guard in command-line terms. +// +// The SDK's own guard is worded for a library caller — "instantiate the X client with an apiKey +// option, like new X({ apiKey: ... })" — which names something a CLI user cannot do. That wording +// is fixed upstream to match the reference SDKs byte for byte, so it is restated here rather than +// changed there. The environment variable is the anchor for the match: it is the one token of the +// message this runtime also knows, so an unrelated constructor failure is rethrown untouched +// instead of being reported as a missing credential. +const buildClient = ( + SDK: CreateProgramOptions['SDK'], + options: Record, + clientOptions: readonly CliClientOptionDefinition[], +): Record => { + try { + return new SDK(options) as Record; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const missing = clientOptions.find((option) => option.env !== undefined && message.includes(option.env)); + if (!missing) throw error; + throw new Error( + 'Missing credential: run `' + + LOGIN_COMMAND + + '`, pass --' + + missing.name + + ' ' + + (missing.env ? ' or set the ' + missing.env + ' environment variable' : '') + + '.', + ); + } +}; + +const addGeneratedCommand = ( + program: Command, + SDK: CreateProgramOptions['SDK'], + clientOptions: readonly CliClientOptionDefinition[], + definition: CliCommandDefinition, + groups: readonly CliCommandGroup[] | undefined, + auth: CliAuthDefinition | undefined, +): void => { + const parent = ensureCommandPath(program, definition.commandPath.slice(0, -1), groups); + const commandName = definition.commandPath.at(-1) ?? definition.methodName; + const command = usageExitCode(new Command(commandName)) + .description(definition.summary ?? definition.description ?? '') + .showHelpAfterError() + .option('--base-url ', 'Override the base URL for API requests') + .option('--timeout ', 'Request timeout in milliseconds') + .option('--max-retries ', 'Number of retries for retryable failures') + .option('--format ', 'Output format: auto, json, jsonl, pretty, raw, toon, yaml') + .option( + '--format-error ', + 'Error output format: auto, json, jsonl, pretty, raw, toon, yaml', + ) + .option('--transform ', 'Dot-path transform for data output') + .option('--transform-error ', 'Dot-path transform for error output') + .option('-r, --raw-output', 'Print transformed string values without JSON quotes') + .option('--debug', 'Enable SDK debug logging'); + + // Mirror configured client-option flags on the subcommand so they can appear before or after the verb. + for (const option of clientOptions) { + command.option('--' + option.name + ' ', clientOptionDescription(option)); + } + + if (definition.iterable) + command.option( + '--max-items ', + 'Maximum number of streamed items to print; use -1 for unlimited', + ); + + // Positionals are registered as optional Commander arguments because each one is also + // accepted as an equivalent flag (e.g. `workers retrieve wkr_1` or `workers retrieve --id + // wkr_1`); requiredness is enforced at call time once both spellings have been merged. + for (const positional of definition.positional) { + command.argument('[' + positional.name + ']', positional.description ?? ''); + } + + for (const flag of definition.flags) { + // A file flag takes a filesystem path, so it says so: the placeholder is the only hint in + // `--help` that the value is opened rather than sent verbatim. + const isPath = flag.valueKind === 'file' || flag.itemKind === 'file'; + const value = flag.valueKind === 'boolean' ? '' : isPath ? ' ' : ' '; + if (flag.name === 'send') { + command.option( + '--' + flag.name + value, + flag.description ?? '', + (value: string, previous: string[] | undefined) => [...(previous ?? []), value], + ); + continue; + } + // Array params are repeatable single-value switches (`--status a --status b`); the custom + // option-argument accumulates each occurrence so Commander does not overwrite the prior value. + if (flag.repeatable) { + command.option( + '--' + flag.name + value, + flag.description ?? '', + (value: string, previous: string[] | undefined) => [...(previous ?? []), value], + ); + continue; + } + command.option('--' + flag.name + value, flag.description ?? ''); + } + + // Flag spelling for path params (`--id wkr_1`); skipped when the name is already taken by a + // client option or generated flag so Commander does not throw on a duplicate registration. + // + // `help` is skipped on top of that scan rather than through it: Commander keeps its built-in + // help option in `_helpOption`, not in `command.options`, so the scan cannot see it and + // `--help ` would register over it — leaving ` --help` to fail with "argument + // missing" instead of printing help. The param is still accepted positionally. + for (const positional of definition.positional) { + if (positional.name === 'help') continue; + if (command.options.some((option) => option.long === '--' + positional.name)) continue; + command.option('--' + positional.name + ' ', positional.description ?? ''); + } + + command.action(async (...args: unknown[]) => { + const command = args.at(-1); + if (!(command instanceof Command)) throw new Error('Expected Commander command context'); + const positionalValues = args.slice(0, -1); + await runGeneratedCommand(SDK, clientOptions, definition, command, positionalValues, auth); + }); + + parent.addCommand(command); +}; + +const ensureCommandPath = ( + program: Command, + path: readonly string[], + groups: readonly CliCommandGroup[] | undefined, +): Command => { + let parent = program; + const walked: string[] = []; + for (const part of path) { + walked.push(part); + const existing = parent.commands.find((command) => command.name() === part); + if (existing) { + parent = existing; + continue; + } + const next = usageExitCode(new Command(part)).showHelpAfterError(); + // A group is created as the parent of its first command, so this is the only moment it can be + // described: Commander never revisits it, and an undescribed group is a bare name in --help. + const description = groupDescription(groups, walked); + if (description) next.description(description); + parent.addCommand(next); + parent = next; + } + return parent; +}; + +// Matched on the full walked path rather than the last segment, so a nested group is described by +// the resource it was actually built from and two resources sharing a leaf name cannot collide. +const groupDescription = ( + groups: readonly CliCommandGroup[] | undefined, + path: readonly string[], +): string | undefined => + groups?.find( + (group) => + group.commandPath.length === path.length && + group.commandPath.every((part, index) => part === path[index]), + )?.description; + +const runGeneratedCommand = async ( + SDK: CreateProgramOptions['SDK'], + clientOptions: readonly CliClientOptionDefinition[], + definition: CliCommandDefinition, + command: Command, + positionalValues: readonly unknown[], + auth: CliAuthDefinition | undefined, +): Promise => { + const rootOptions = command.optsWithGlobals(); + const commandOptions = command.opts(); + const maxItems = definition.iterable ? normalizeMaxItems(commandOptions.maxItems) : undefined; + const outputOptions: OutputOptions = { + format: normalizeFormat(commandOptions.format ?? rootOptions.format, 'auto'), + title: definition.commandPath.join(' '), + ...((commandOptions.transform ?? rootOptions.transform) + ? { transform: commandOptions.transform ?? rootOptions.transform } + : {}), + ...(commandOptions.rawOutput || rootOptions.rawOutput ? { rawOutput: true } : {}), + ...(maxItems !== undefined ? { maxItems } : {}), + }; + const errorOptions: OutputOptions = { + format: normalizeFormat(commandOptions.formatError ?? rootOptions.formatError, 'auto'), + ...((commandOptions.transformError ?? rootOptions.transformError) + ? { transform: commandOptions.transformError ?? rootOptions.transformError } + : {}), + ...(commandOptions.rawOutput || rootOptions.rawOutput ? { rawOutput: true } : {}), + }; + + let diagnostics: ReturnType | undefined; + try { + const clientConfig = await sdkClientOptions( + rootOptions, command, clientOptions, auth, definition.authClientKeyRequirements, + ); + const call = await callArguments( + definition, + command.opts>(), + positionalValues, + ); + const organization = call.params['X-Dedalus-Org-Id']; + const scopeOptions = + organization === undefined ? clientConfig : { ...clientConfig, dedalusOrgID: organization }; + diagnostics = createDiagnostics( + 'dedalus ' + definition.commandPath.join(' '), + diagnosticScope(scopeOptions), + ); + const client = buildClient(SDK, { ...clientConfig, fetch: diagnostics.fetch }, clientOptions); + const method = sdkMethod(client, definition); + + // Required positionals are validated here (not by Commander) because each one may also be + // supplied through its flag spelling or stdin; `call.params` has all sources merged. + for (const param of definition.positional) { + if (param.required && call.params[param.paramKey] === undefined) { + command.error("error: missing required argument '" + param.name + "'", { exitCode: 2 }); + } + } + + const result = method(...call.args); + + if (definition.transport === 'websocket') { + await handleWebSocket(result, call.params, outputOptions); + return; + } + + if (definition.iterable && !definition.streaming) { + await writePaginated(result, outputOptions); + return; + } + + const resolved = await result; + if (definition.streaming) { + await writeIterable(resolved, outputOptions); + return; + } + + await writeOutput(resolved, outputOptions); + } catch (error) { + diagnostics?.record({ kind: 'command_failure' }); + await writeError(error, errorOptions, clientOptions, SDK); + process.exitCode = errorExitCode(error, SDK); + } finally { + diagnostics?.record({ kind: 'command_complete' }); + } +}; + +// Generated commands pass operation-specific requirements. Custom commands use the API-wide requirements. +export const sdkClientOptions = async ( + options: GlobalOptions, + command: Command, + clientOptions: readonly CliClientOptionDefinition[], + auth: CliAuthDefinition | undefined, + requirements: readonly (readonly string[])[] = auth?.requirements ?? [], +): Promise> => { + // Forward configured client-option flags (auth keys, org headers, etc.) to the embedded SDK + // using the SDK-facing camelCased key. Only forward values that were explicitly set so the + // SDK's own env-var fallback keeps working when no CLI flag was passed. + const rootOptions = options as unknown as Record; + const commandOptions = command.opts>(); + const forwarded: Record = {}; + for (const option of clientOptions) { + const commandValue = commandOptions[option.optionKey]; + const value = commandValue === undefined ? rootOptions[option.optionKey] : commandValue; + if (value === undefined) continue; + // A credential is exactly the kind of value that belongs in a file rather than in shell + // history, so a client option reads `@path` like any other flag. It never reaches the + // structured decoding below it: an SDK client option is always a scalar. + forwarded[option.sdkKey] = typeof value === 'string' ? clientOptionValue(value, option) : value; + } + await applyStoredCredentials(forwarded, clientOptions, requirements, auth, options.baseUrl); + const organization = commandOptions['xDedalusOrgId'] ?? rootOptions['xDedalusOrgId'] ?? + forwarded['dedalusOrgID'] ?? process.env['DEDALUS_ORG_ID']; + if (organization !== undefined) forwarded['dedalusOrgID'] = organization; + return { + ...(options.baseUrl?.trim() ? { baseURL: options.baseUrl.trim() } : {}), + ...(options.timeout ? { timeout: Number(options.timeout) } : {}), + ...(options.maxRetries ? { maxRetries: Number(options.maxRetries) } : {}), + ...(options.debug ? { logLevel: 'debug' } : {}), + ...forwarded, + defaultHeaders: { + 'X-Request-ID': null, + ...(organization ? { 'X-Dedalus-Org-Id': organization } : {}), + 'X-Scalar-Lang': 'cli', + 'X-Scalar-Runtime': 'cli', + 'X-Scalar-CLI-Command': command.name(), + 'X-Dedalus-CLI-Command': commandPath(command), + 'User-Agent': 'Dedalus/CLI ' + rootCommand(command).version(), + }, + }; +}; + +const applyStoredCredentials = async ( + forwarded: Record, + clientOptions: readonly CliClientOptionDefinition[], + authClientKeyRequirements: readonly (readonly string[])[], + auth: CliAuthDefinition | undefined, + baseUrl: string | undefined, +): Promise => { + if (!auth) return; + if (authClientKeyRequirements.length === 0) return; + const hasExplicitValue = (option: CliClientOptionDefinition): boolean => + forwarded[option.sdkKey] !== undefined || Boolean(option.env && (process.env[option.env] ?? '').trim()); + const explicit = authClientKeyRequirements.find((keys) => + keys.every((key) => { + const option = clientOptions.find((candidate) => candidate.clientKey === key); + return option !== undefined && hasExplicitValue(option); + }), + ); + if (explicit) return; + const stored = await storedCredentials(auth, resolvedBaseUrl(auth, baseUrl)); + const isSatisfiable = (keys: readonly string[]): boolean => + keys.every((key) => { + const option = clientOptions.find((candidate) => candidate.clientKey === key); + if (!option) return false; + return hasExplicitValue(option) || (typeof stored[key] === 'string' && stored[key] !== ''); + }); + const selected = + authClientKeyRequirements.find((keys) => isSatisfiable(keys)) ?? authClientKeyRequirements[0] ?? []; + const pending = clientOptions.filter( + (option) => option.auth && selected.includes(option.clientKey) && !hasExplicitValue(option), + ); + for (const option of pending) { + // Own-property lookup only: the store is a JSON file, so a key like "constructor" + // would otherwise resolve to something off Object.prototype rather than a credential. + if (!Object.prototype.hasOwnProperty.call(stored, option.clientKey)) continue; + const value = stored[option.clientKey]; + if (typeof value === 'string' && value) forwarded[option.sdkKey] = value; + } +}; + +const commandPath = (command: Command): string => { + const parts: string[] = []; + for (let current: Command | null = command; current; current = current.parent) + parts.unshift(current.name()); + return parts.join(' '); +}; + +const rootCommand = (command: Command): Command => { + let root = command; + while (root.parent) root = root.parent; + return root; +}; + +const sdkMethod = ( + client: Record, + definition: CliCommandDefinition, +): ((...args: unknown[]) => unknown) => { + let target: unknown = client; + for (const resource of definition.resourcePath) { + target = (target as Record)[resource]; + } + const method = (target as Record)[definition.methodName]; + if (typeof method !== 'function') { + throw new Error( + 'Generated CLI could not find SDK method ' + + [...definition.resourcePath, definition.methodName].join('.'), + ); + } + return method.bind(target) as (...args: unknown[]) => unknown; +}; + +const callArguments = async ( + definition: CliCommandDefinition, + options: Record, + positionalValues: readonly unknown[], +): Promise<{ readonly args: readonly unknown[]; readonly params: Record }> => { + const positionalParams: Record = {}; + definition.positional.forEach((param, index) => { + const value = positionalValues[index] ?? options[param.optionKey]; + if (value !== undefined) + positionalParams[param.paramKey] = coerceValue(value, param.valueKind, undefined, param.name); + }); + + const flagParams: Record = {}; + for (const flag of definition.flags) { + if (flag.objectPath) continue; + const value = options[flag.optionKey]; + if (value !== undefined) + flagParams[flag.paramKey] = coerceValue(value, flag.valueKind, flag.itemKind, '--' + flag.name); + } + + // Dotted leaf flags (e.g. `--address.city`) are applied after the JSON-blob flag for the same + // param so an explicit leaf value always overrides the corresponding blob field. + for (const flag of definition.flags) { + if (!flag.objectPath || flag.objectPath.length === 0) continue; + const value = options[flag.optionKey]; + if (value === undefined) continue; + flagParams[flag.paramKey] = setNestedValue( + flagParams[flag.paramKey], + flag.objectPath, + coerceValue(value, flag.valueKind, flag.itemKind, '--' + flag.name), + ); + } + + const stdin = await readStdinValue(); + const params = mergeObjects(stdin, { ...flagParams, ...positionalParams }); + const positionalArgs = definition.positional.map((param) => params[param.paramKey]); + // A positional path param is handed to the SDK method as a leading argument, and the method + // never destructures it back out of `params` — only the path params it did *not* take + // positionally are pulled out there. Leaving it in `params` makes it fall through the + // method's `...query` / `...body` rest and reach the wire a second time, as + // `/version/1.0.0?semver=1.0.0` or as a `namespace` field in a body that has no such field. + // + // Dropping it cannot remove a field the call needs: a positional whose name collides with a + // params field is already disambiguated when the command table is built (a body `slug` beside + // a path one leaves the positional as `slug2`), so the two never share a key. + const omitted = definition.positional.map((param) => param.paramKey); + if (definition.transport === 'websocket') omitted.push('send'); + const sdkParams = omitParams(params, omitted); + + if (definition.callShape === 'options') return { args: [...positionalArgs, undefined], params }; + if (definition.callShape === 'body') + return { args: [...positionalArgs, bodyValue(sdkParams, definition), undefined], params }; + return { args: [...positionalArgs, paramsValue(sdkParams, definition), undefined], params }; +}; + +const paramsValue = ( + params: Record, + definition: CliCommandDefinition, +): unknown => { + if (definition.bodyParamKey === undefined) return params; + const body = params[definition.bodyParamKey]; + if (body === undefined) return params; + // Scoped union bodies with headers are typed as the params root. A `--body` JSON blob therefore + // needs to sit beside header/query flags, or be passed as the root when it cannot be merged. + if (!isPlainObject(body)) return body; + return mergeObjects(body, omitParams(params, [definition.bodyParamKey])); +}; + +const bodyValue = (params: Record, definition: CliCommandDefinition): unknown => { + // A non-flattenable body is forwarded as a single value: the SDK method takes that param + // directly, so return it bare. Its dotted leaf flags (`--payload.city`) share this key and have + // already been merged into the param value, so counting body flags would wrongly treat one + // logical body as many and re-wrap it under the param key. `params.body` covers a bare value + // piped via stdin. + if (definition.bodyParamKey !== undefined) { + if (params[definition.bodyParamKey] !== undefined) return params[definition.bodyParamKey]; + if (params.body !== undefined) return params.body; + } + // A flattenable body is reassembled from its per-property flags into a single object. Leaf flags + // share their parent property key, so keying by paramKey collapses them back onto that property. + const bodyFlags = definition.flags.filter( + (flag) => flag.location === 'body' && flag.paramKey !== 'send', + ); + const body: Record = {}; + for (const flag of bodyFlags) { + if (params[flag.paramKey] !== undefined) body[flag.paramKey] = params[flag.paramKey]; + } + return Object.keys(body).length > 0 ? body : params; +}; + +const readStdinValue = async (): Promise> => { + if (processStdin.isTTY) return {}; + const source = await readStdinSource(); + if (!source) return {}; + const parsed = parseStructuredValue(source); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) + return parsed as Record; + return { body: parsed }; +}; + +const readStdinSource = async (): Promise => { + const chunks: Buffer[] = []; + const done = new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + processStdin.off('data', onData); + processStdin.off('end', onEnd); + processStdin.off('error', onError); + processStdin.pause(); + }; + const onData = (chunk: Buffer | string) => { + clearTimeout(timer); + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }; + const onEnd = () => { + cleanup(); + resolve(Buffer.concat(chunks).toString('utf8').trim()); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const timer = setTimeout(() => { + cleanup(); + resolve(''); + }, 25); + processStdin.on('data', onData); + processStdin.on('end', onEnd); + processStdin.on('error', onError); + }); + processStdin.resume(); + return done; +}; + +// An empty argument is an empty STRING, not YAML's empty document. `--tag ''` asks for one +// empty tag; parsing it as YAML answers `null`, which the request builder then refuses +// ("Received null for "tags[]""). Only the empty case is special-cased: `--tag null` still +// means null, and every other value keeps the JSON-then-YAML reading. +const parseStructuredValue = (source: string): unknown => { + if (source === '') return source; + try { + return JSON.parse(source); + } catch { + return parseYaml(source); + } +}; + +const setNestedValue = ( + target: unknown, + path: readonly string[], + value: unknown, +): Record => { + const root = isPlainObject(target) ? { ...target } : {}; + let cursor = root; + for (const segment of path.slice(0, -1)) { + const existing = cursor[segment]; + const next = isPlainObject(existing) ? { ...existing } : {}; + cursor[segment] = next; + cursor = next; + } + const leaf = path.at(-1); + if (leaf !== undefined) cursor[leaf] = value; + return root; +}; + +const isPlainObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +const mergeObjects = ( + base: Record, + overlay: Record, +): Record => ({ + ...base, + ...Object.fromEntries(Object.entries(overlay).filter(([, value]) => value !== undefined)), +}); + +const omitParams = ( + params: Record, + names: readonly string[], +): Record => { + const out = { ...params }; + for (const name of names) delete out[name]; + return out; +}; + +// A file argument is spelled `@path`, the spelling curl and the reference CLIs already use, so a +// command written against one of those keeps working here. A command line carries a path but never +// the bytes behind it, and without this the path itself went on the wire as the value. +const FILE_ARG_PREFIX = '@'; + +// `@file://x` sends the bytes as text and `@data://x` sends them base64-encoded; a bare `@x` lets the +// file decide, which is what a caller who has not thought about encoding means. +const FILE_ARG_TEXT_SCHEME = 'file://'; +const FILE_ARG_DATA_SCHEME = 'data://'; + +// `\@value` is how a value that genuinely begins with `@` (an npm scope, a handle) is spelled. +const ESCAPED_FILE_ARG_PREFIX = '\\@'; + +// Where a scalar value came from, which is what decides how far it is decoded: +// - "literal": typed on the command line. A structured kind parses it, and a `@` inside the result +// names a file the same way the flag itself would. +// - "file": read out of a file the value named. A structured kind still parses it (that is the +// point of `--metadata @meta.json`), but a `@` inside the file is data rather than a second file +// argument: whoever wrote the file is not necessarily whoever typed the command. +// - "escaped": a literal whose leading `@` was escaped as `\@`. Escaping declares the value to +// be text, so it is never parsed: `@remote` is a YAML reserved character and throws. +// - "encoded": base64 of bytes that are not text. It is the value; nothing parses or expands it. +type FlagValueOrigin = 'literal' | 'file' | 'encoded' | 'escaped'; + +type FlagValue = { readonly text: string; readonly origin: FlagValueOrigin }; + +// The part after `@`, or `undefined` when the value is not a file argument at all. +const fileArgRest = (value: string): string | undefined => + value.startsWith(FILE_ARG_PREFIX) ? value.slice(FILE_ARG_PREFIX.length) : undefined; + +// Drops the escape from `\@literal`, which is the only thing a leading backslash means here. +const unescapeFileArg = (value: string): string => + value.startsWith(ESCAPED_FILE_ARG_PREFIX) ? value.slice(1) : value; + +// Resolves one scalar value: a file argument becomes the file's contents, `\@` becomes a literal `@`, +// and anything else is passed through untouched. +const flagValue = (value: string, label: string): FlagValue => { + const rest = fileArgRest(value); + if (rest === undefined) { + const unescaped = unescapeFileArg(value); + return { text: unescaped, origin: unescaped === value ? 'literal' : 'escaped' }; + } + if (rest.startsWith(FILE_ARG_TEXT_SCHEME)) { + return { + text: readFileArg(rest.slice(FILE_ARG_TEXT_SCHEME.length), label, true).toString('utf8'), + origin: 'file', + }; + } + if (rest.startsWith(FILE_ARG_DATA_SCHEME)) { + return { + text: readFileArg(rest.slice(FILE_ARG_DATA_SCHEME.length), label, true).toString('base64'), + origin: 'encoded', + }; + } + const bytes = readFileArg(rest, label, false); + // Bytes that are not text cannot travel as a JSON string, so they are base64-encoded. A caller who + // wants one encoding regardless of what the file holds spells it with `@file://` or `@data://`. + return looksBinary(bytes) + ? { text: bytes.toString('base64'), origin: 'encoded' } + : { text: bytes.toString('utf8'), origin: 'file' }; +}; + +// A `file://` or `data://` value is a URL and is percent-decoded as one; a bare `@path` is not a URL +// and is opened exactly as typed, or a file genuinely named `100%20pct.txt` would be looked up as +// `100 pct.txt`. +const readFileArg = (source: string, label: string, url: boolean): Buffer => { + const path = url ? fileUrlToPath(source) : source; + try { + return readFileSync(path); + } catch (error) { + throw new Error(fileArgFailure(label, path, error)); + } +}; + +// A missing file names the flag, the path, and the escape, because the likeliest reason `@abe` names +// nothing on disk is that it was meant as a literal value. +const fileArgFailure = (label: string, path: string, error: unknown): string => + label + + ': could not read ' + + path + + ' (' + + (error instanceof Error ? error.message : String(error)) + + '). Write \\@ to send a literal value beginning with @.'; + +// Bytes are text when they round-trip through UTF-8. A NUL byte is checked separately because it is +// legal UTF-8, so it round-trips, yet no text field is meant to carry one. +const looksBinary = (bytes: Buffer): boolean => + bytes.includes(0) || !Buffer.from(bytes.toString('utf8'), 'utf8').equals(bytes); + +// `file://./x` and `file:///abs/x` are both spelled by hand often enough to accept: the first is not +// a legal file URL, so `new URL()` is not used and the remainder is treated as a path. The slash +// before a drive letter (`file:///C:/x`) is the Windows spelling of an absolute path and is dropped, +// since `/C:/x` names nothing. +const fileUrlToPath = (rest: string): string => { + const decoded = decodeFileUrl(rest); + return /^\/[A-Za-z]:/u.test(decoded) ? decoded.slice(1) : decoded; +}; + +// Percent-decoding applies only when the value decodes cleanly. A literal `%` in a file name is +// likelier than a hand-encoded one, and letting `decodeURIComponent` throw would surface a bare +// "URI error" naming neither the flag nor the path. +const decodeFileUrl = (rest: string): string => { + try { + return decodeURIComponent(rest); + } catch { + return rest; + } +}; + +// The path a file-kinded flag names, with or without the `@`. An upload value is a path by +// definition, so the prefix is optional there and both spellings open the same file. +const uploadPath = (value: string): string => { + const rest = fileArgRest(value); + if (rest === undefined) return unescapeFileArg(value); + if (rest.startsWith(FILE_ARG_TEXT_SCHEME)) return fileUrlToPath(rest.slice(FILE_ARG_TEXT_SCHEME.length)); + if (rest.startsWith(FILE_ARG_DATA_SCHEME)) return fileUrlToPath(rest.slice(FILE_ARG_DATA_SCHEME.length)); + return rest; +}; + +// `createReadStream` defers a missing file to an asynchronous `error` event, which surfaces as an +// unhandled rejection rather than a usage error, so the path is checked before the stream is opened. +const uploadStream = (value: string, label: string): unknown => { + const path = uploadPath(value); + try { + accessSync(path, fsConstants.R_OK); + } catch (error) { + throw new Error(fileArgFailure(label, path, error)); + } + return createReadStream(path); +}; + +// `@` works inside a JSON or YAML blob too (`--metadata '{"notes": "@notes.txt"}'`), so a nested +// field names a file the same way a flag does. Only a blob typed on the command line is walked; see +// `FlagValueOrigin` for why a blob read out of a file is not. +const expandFileArgs = (value: unknown, label: string): unknown => { + if (typeof value === 'string') return flagValue(value, label).text; + if (Array.isArray(value)) return value.map((item) => expandFileArgs(item, label)); + if (isPlainObject(value)) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expandFileArgs(item, label)])); + } + return value; +}; + +// `itemKind` types one occurrence of a repeatable array flag; a definition without one (an injected +// field with no item schema) falls back to parsing each occurrence as structured text. `label` names +// the flag a file-argument failure came from, since a path names no flag on its own. +// A failing client option cannot name the path it tried to open when the option carries a +// credential: the likeliest reason `@sk-live-...` names no file is that it *is* the key, and the +// message reaches stderr and every CI log capturing it. +const clientOptionValue = (value: string, option: CliClientOptionDefinition): unknown => { + try { + return flagValue(value, '--' + option.name).text; + } catch (error) { + if (!option.auth) throw error; + throw new Error( + '--' + + option.name + + ': could not read the file it names. Write \\@ to send a literal value beginning with @.', + ); + } +}; + +const coerceValue = ( + value: unknown, + kind: CliValueKind, + itemKind?: CliValueKind, + label = 'value', +): unknown => { + if (Array.isArray(value)) { + const elementKind = kind === 'array' ? (itemKind ?? 'unknown') : kind; + return value.map((item) => coerceValue(item, elementKind, undefined, label)); + } + if (typeof value !== 'string') return value; + // A file field takes bytes, and a path is the only thing a command line can carry. The stream is + // what the SDK's multipart encoder detects (it is async iterable) and it names the part from the + // stream's `path`, so the upload keeps its file name. + if (kind === 'file') return uploadStream(value, label); + const source = flagValue(value, label); + if (kind === 'boolean') return source.text === 'true' || source.text === '1'; + if (kind === 'number' || kind === 'integer') return Number(source.text); + if (kind === 'object' || kind === 'array' || kind === 'unknown') { + // Base64 is the value itself, and an escaped literal was declared to be text: parsing + // either would read it as YAML, and `@remote` is not even legal YAML. + if (source.origin === 'encoded' || source.origin === 'escaped') return source.text; + const parsed = parseStructuredValue(source.text); + return source.origin === 'literal' ? expandFileArgs(parsed, label) : parsed; + } + return source.text; +}; + +// Iterator commands print one item at a time so pipes can consume long-running streams immediately. +// One streamed item, framed so consecutive items stay separable. +// +// Every other format self-frames per item: `jsonl` is one record per line, `json` wraps each in +// braces. A TOON object is several bare `key: value` lines, so two events written back to back +// would run together with nothing marking the boundary — recoverable only by guessing that a +// repeated key starts a new record, which fails the moment a field is optional. Encoding the item +// as a one-element list gives each record its own `[1]` header at column 0, keeps the count +// marker honest about what follows, and leaves every other format untouched. +const streamItemText = (value: unknown, options: OutputOptions): string => { + if (options.format !== 'toon') return serializeOutput(value, options); + if (options.rawOutput && typeof value === 'string') return value; + return serializeOutput([value], options); +}; + +const writeIterable = async (value: unknown, options: OutputOptions): Promise => { + if (!isAsyncIterable(value)) { + await writeOutput(value, options); + return; + } + if (options.maxItems === 0) { + options.onLimit?.(); + return; + } + let written = 0; + for await (const item of value) { + if (options.failOnWebSocketError) throwWebSocketEventError(item); + processStdout.write(streamItemText(transformValue(item, options.transform), options) + '\n'); + if (!countsTowardLimit(item, options)) continue; + written += 1; + if (options.maxItems !== undefined && options.maxItems > -1 && written >= options.maxItems) { + options.onLimit?.(); + break; + } + } +}; + +// Paginated list commands auto-page across cursors: the SDK PagePromise iterates items across +// pages. `raw` is the explicit escape hatch for the unmodified single-response envelope, so it +// stays a single request with no auto-paging. For every other format we hand the page iterator +// to writeOutput, which already makes the right split: `jsonl` streams each item as it arrives, +// while `json`/`auto`/`pretty`/`yaml` collect the fully auto-paged items into one value (honoring +// --max-items either way). Routing through writeIterable here instead would stream items for +// every format, emitting newline-delimited objects under `--format json` (invalid as a single +// JSON document) and nothing at all for an empty result. +const writePaginated = async (result: unknown, options: OutputOptions): Promise => { + if (options.format === 'raw') { + const page = await result; + const envelope = + page && typeof page === 'object' && 'body' in page ? (page as { body?: unknown }).body : undefined; + await writeOutput(envelope ?? page, options); + return; + } + if (isAsyncIterable(result)) { + await writeOutput(result, options); + return; + } + await writeOutput(await result, options); +}; + +const countsTowardLimit = (item: unknown, options: OutputOptions): boolean => { + if (!options.failOnWebSocketError) return true; + if (!item || typeof item !== 'object') return false; + const type = (item as { type?: unknown }).type; + return type === 'message' || type === 'raw'; +}; + +// WebSocket SDKs expose lifecycle events as iterator values; error events should fail CLI commands. +const handleWebSocket = async ( + socket: unknown, + params: Record, + options: OutputOptions, +): Promise => { + const closer = () => { + closeSocket(socket, 'interrupted'); + }; + process.once('SIGINT', closer); + try { + const output = writeIterable(socket, { + ...options, + failOnWebSocketError: true, + onLimit: () => closeSocket(socket, 'max-items reached'), + }); + await Promise.resolve(); + const sendValue = params.send; + if (sendValue !== undefined) sendSocketValue(socket, sendValue); + if (!processStdin.isTTY) { + const stdin = await readStdinValue(); + if (Object.keys(stdin).length > 0) sendSocketValue(socket, stdin.body ?? stdin); + } + await output; + } finally { + process.off('SIGINT', closer); + } +}; + +const closeSocket = (socket: unknown, reason: string): void => { + const close = (socket as { close?: (options?: unknown) => void }).close; + if (typeof close === 'function') close.call(socket, { code: 1000, reason }); +}; + +const sendSocketValue = (socket: unknown, value: unknown): void => { + const send = (socket as { send?: (message: unknown) => void }).send; + if (typeof send !== 'function') throw new Error('Generated CLI could not send on SDK WebSocket client'); + if (Array.isArray(value)) { + for (const item of value) send.call(socket, item); + return; + } + send.call(socket, value); +}; + +export const writeOutput = async (value: unknown, options: OutputOptions): Promise => { + if (isAsyncIterable(value)) { + if (options.format === 'jsonl') { + await writeIterable(value, options); + return; + } + await writeOutput(await collectIterable(value, options.maxItems), options); + return; + } + processStdout.write(serializeOutput(transformValue(value, options.transform), options) + '\n'); +}; + +const collectIterable = async ( + value: AsyncIterable, + maxItems?: number, +): Promise => { + const items: unknown[] = []; + for await (const item of value) { + if (maxItems === 0) break; + items.push(item); + if (maxItems !== undefined && maxItems > -1 && items.length >= maxItems) break; + } + return items; +}; + +// `auto` renders like `json` (2-space pretty-printed): `pretty` is reserved for the distinct +// human-readable card view, matching warp-style CLI defaults. +const serializeOutput = (value: unknown, options: OutputOptions): string => { + const normalized = options.format === 'auto' ? 'json' : options.format; + if (options.rawOutput && typeof value === 'string') return value; + const safeValue = value === undefined ? null : value; + if (normalized === 'raw') return typeof safeValue === 'string' ? safeValue : JSON.stringify(safeValue); + if (normalized === 'yaml') return stringifyYaml(JSON.parse(JSON.stringify(safeValue))).trimEnd(); + if (normalized === 'jsonl') return JSON.stringify(safeValue); + if (normalized === 'pretty') return prettyCard(safeValue, options); + // TOON keeps the JSON data model but drops repeated syntax, so a list response costs an agent + // markedly fewer context tokens than the same data as JSON. + if (normalized === 'toon') return encodeToon(JSON.parse(JSON.stringify(safeValue))); + return JSON.stringify(safeValue, null, 2); +}; + +// Human-readable `pretty` view: a bordered key/value card titled with the command path, +// rendering booleans as yes/no and array entries as numbered items. +const prettyCard = (value: unknown, options: OutputOptions): string => { + const lines = prettyLines(value, ''); + const width = Math.max(0, ...lines.map((line) => line.length)); + const body = lines.length > 0 ? lines : ['']; + return [ + ...(options.title ? [' ' + options.title] : []), + '\u256d' + '\u2500'.repeat(width + 2) + '\u256e', + ...body.map((line) => '\u2502 ' + line.padEnd(width, ' ') + ' \u2502'), + '\u2570' + '\u2500'.repeat(width + 2) + '\u256f', + ].join('\n'); +}; + +const prettyLines = (value: unknown, indent: string): string[] => { + if (Array.isArray(value)) { + return value.flatMap((item, index) => { + const label = indent + (index + 1) + '.'; + if (item && typeof item === 'object') return [label, ...prettyLines(item, indent + ' ')]; + return [label + ' ' + prettyScalar(item)]; + }); + } + if (value && typeof value === 'object') { + return Object.entries(value as Record).flatMap(([key, entry]) => { + if (entry && typeof entry === 'object') + return [indent + key + ':', ...prettyLines(entry, indent + ' ')]; + return [indent + key + ': ' + prettyScalar(entry)]; + }); + } + return [indent + prettyScalar(value)]; +}; + +const prettyScalar = (value: unknown): string => { + if (value === true) return 'yes'; + if (value === false) return 'no'; + if (value === null || value === undefined) return ''; + return String(value); +}; + +export const writeError = async ( + error: unknown, + options: OutputOptions, + clientOptions: readonly CliClientOptionDefinition[], + SDK: CreateProgramOptions['SDK'], +): Promise => { + const body = transformValue(errorBody(error, clientOptions, SDK), options.transform); + if (options.rawOutput && typeof body === 'string') { + process.stderr.write(body + '\n'); + return; + } + if (options.format === 'raw') { + // The hint is appended rather than dropped: before failures were classified, the auth hint + // *was* the message, so printing the message alone would lose advice `raw` used to show. + const hint = errorHintText(body); + process.stderr.write(String(errorMessage(body)) + (hint ? ' ' + hint : '') + '\n'); + return; + } + const output = options.format === 'auto' ? 'pretty' : options.format; + const safeBody = body === undefined ? null : body; + const serialized = serializeErrorBody(safeBody, output); + process.stderr.write((output === 'pretty' ? as.red(serialized) : serialized) + '\n'); +}; + +const serializeErrorBody = (body: unknown, format: OutputFormat): string => { + if (format === 'yaml') return stringifyYaml(body).trimEnd(); + if (format === 'toon') return encodeToon(JSON.parse(JSON.stringify(body))); + return JSON.stringify(body, null, format === 'jsonl' ? 0 : 2); +}; + +const errorMessage = (value: unknown): unknown => + value && typeof value === 'object' && 'message' in value ? (value as { message?: unknown }).message : value; + +/** The `hint` on an error body, when the body is one and carries a non-empty hint. */ +const errorHintText = (value: unknown): string => { + if (!value || typeof value !== 'object') return ''; + const hint = (value as { hint?: unknown }).hint; + return typeof hint === 'string' ? hint : ''; +}; + +const errorBody = ( + error: unknown, + clientOptions: readonly CliClientOptionDefinition[], + SDK: CreateProgramOptions['SDK'], +): Record => { + if (error && typeof error === 'object') { + const record = error as Record; + const hint = errorHint(record, clientOptions); + return { + name: record.name, + // Stable class identifier so a caller can branch on the kind of failure without parsing prose. + code: errorCode(record, SDK), + // The upstream message is passed through untouched; advice goes in `hint` beside it, so a + // caller logging the message never loses what the API actually said. + message: record.message ?? String(error), + status: record.status, + requestId: record.requestID ?? record.requestId, + ...(hint !== undefined ? { hint } : {}), + body: record.body ?? record.error, + }; + } + return { code: 'error', message: String(error) }; +}; + +// Stable error class for the failure, derived from the HTTP status, or from the SDK error class +// for transport failures that never reached a response. Drives both the `code` field and the +// process exit status. +const errorCode = (record: Record, SDK: CreateProgramOptions['SDK']): string => { + const status = typeof record.status === 'number' ? record.status : undefined; + if (status === 401 || status === 403) return 'auth-failed'; + if (status === 404) return 'not-found'; + if (status === 429) return 'rate-limited'; + if (status !== undefined && status >= 400 && status < 500) return 'client-error'; + if (status !== undefined && status >= 500) return 'server-error'; + if (isConnectionError(record, SDK)) return 'connection-error'; + return 'error'; +}; + +// Identifies a transport failure — one that never reached a response, so there is no status to +// classify it by. +// +// Matched by identity against the error class the SDK exposes as a static, never by class name: +// release binaries are built with `bun build --compile --minify`, which renames classes, so +// `constructor.name` there is a mangled single letter and `.name` is the inherited "Error". The +// name check stays only as a fallback for a host object that is not an SDK error at all (an +// undici `ConnectTimeoutError` surfacing through a custom fetch, say). +// Transport failure codes Node and undici set on the error, none of which are CamelCase. +const CONNECTION_ERROR_CODE = + /^(ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|EPIPE|UND_ERR)/u; + +const isConnectionError = ( + record: Record, + SDK: CreateProgramOptions['SDK'], +): boolean => { + const connectionError = (SDK as { APIConnectionError?: unknown }).APIConnectionError; + if ( + typeof connectionError === 'function' && + record instanceof (connectionError as new () => unknown) + ) + return true; + const name = typeof record.name === 'string' ? record.name : ''; + const code = typeof record.code === 'string' ? record.code : ''; + // Class names are CamelCase (`ConnectTimeoutError`) but Node/undici transport codes are + // SCREAMING_SNAKE_CASE (`ECONNREFUSED`, `UND_ERR_CONNECT_TIMEOUT`), so the two need different + // tests: a substring match on the code would never fire, leaving a host that cannot be reached + // through a custom fetch falling through to the unclassified status. + if (name.includes('Connection') || name.includes('Timeout')) return true; + return CONNECTION_ERROR_CODE.test(code); +}; + +// One exit status per error class so a caller can decide what to do next — fix credentials, back +// off, retry, give up — from the status alone. +const ERROR_EXIT_CODES: Record = { + error: 1, + 'auth-failed': 10, + 'not-found': 11, + 'rate-limited': 12, + 'client-error': 13, + 'server-error': 14, + 'connection-error': 15, +}; + +export const errorExitCode = (error: unknown, SDK: CreateProgramOptions['SDK']): number => { + if (!error || typeof error !== 'object') return 1; + return ERROR_EXIT_CODES[errorCode(error as Record, SDK)] ?? 1; +}; + +// Actionable next step for the classes where one exists, surfaced beside the upstream message. +const errorHint = ( + error: Record, + clientOptions: readonly CliClientOptionDefinition[], +): string | undefined => { + const status = error.status; + if (status === 401) return authHint(clientOptions); + // 403 is a permission failure, not a credential one: the request authenticated fine, so + // telling the caller to set the auth env var would send it round the same loop again. + if (status === 403) return 'Access denied. The credential is valid but lacks permission for this resource.'; + if (status === 404) return 'Resource not found. Check the identifier and path arguments.'; + if (status === 429) return 'Rate limited. Wait and retry, or reduce request frequency.'; + return undefined; +}; + +// Undefined when the SDK declares no authentication: there would be no variable to name, and a +// generic "set the required environment variable" would point at something that does not exist. +const authHint = (clientOptions: readonly CliClientOptionDefinition[]): string | undefined => { + const authOptions = clientOptions.filter((option) => option.auth); + if (authOptions.length === 0) return undefined; + const env = authOptions + .map((option) => option.env) + .filter((value): value is string => !!value) + .join(', '); + return ( + 'Authentication failed. Run `' + LOGIN_COMMAND + '`' + (env ? ', or set ' + env : '') + ', and try again.' + ); +}; + +// Keep transforms small and dependency-free; the CLI supports the common dot-path extraction case. +const transformValue = (value: unknown, transform: string | undefined): unknown => { + if (!transform) return value; + return transform + .split('.') + .filter(Boolean) + .reduce((current, segment) => { + if (current === undefined || current === null) return undefined; + if (Array.isArray(current) && /^\\d+$/u.test(segment)) return current[Number(segment)]; + if (typeof current === 'object') return (current as Record)[segment]; + return undefined; + }, value); +}; + +const throwWebSocketEventError = (value: unknown): void => { + if (!value || typeof value !== 'object') return; + const record = value as Record; + if (record.type !== 'error') return; + const error = record.error; + if (error instanceof Error) throw error; + throw new Error(typeof error === 'string' ? error : JSON.stringify(error ?? record)); +}; + +const normalizeFormat = (value: string | undefined, fallback: OutputFormat): OutputFormat => { + if ( + value === 'auto' || + value === 'json' || + value === 'jsonl' || + value === 'pretty' || + value === 'raw' || + value === 'toon' || + value === 'yaml' + ) { + return value; + } + return fallback; +}; + +const normalizeMaxItems = (value: string | undefined): number | undefined => { + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return undefined; + return Math.trunc(parsed); +}; + +const isAsyncIterable = (value: unknown): value is AsyncIterable => + !!value && typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function'; + +// Custom commands share generated credential, output, and error conventions. +export { normalizeFormat, usageExitCode }; diff --git a/src/cli/toon.ts b/src/cli/toon.ts new file mode 100644 index 0000000..3407419 --- /dev/null +++ b/src/cli/toon.ts @@ -0,0 +1,170 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +const INDENT = ' '; + +// Marker for an object with no fields, mirroring the `[0]` an empty array gets. Safe as a bare +// token because `needsQuotes` quotes any string containing a brace, so a literal "{}" value +// cannot be confused with it. +const EMPTY_OBJECT = '{}'; + +/** Leaf values TOON renders on a single line. */ +type ToonPrimitive = string | number | boolean | null; + +// Bare (unquoted) keys are restricted to a conservative identifier shape so a +// TOON reader can never confuse a key with structural syntax; other keys are +// emitted JSON-quoted. +const BARE_KEY = /^[A-Za-z_][A-Za-z0-9_.-]*$/; + +// Strings matching a number literal must be quoted, otherwise a reader would +// decode them as numbers and the round-trip would be lossy. +const NUMBER_LIKE = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/; + +/** + * Encodes a JSON-compatible value as TOON text. Undefined object properties + * are dropped (matching JSON.stringify); non-finite numbers become null. + */ +export const encodeToon = (value: unknown): string => encodeValueLines(value, 0).join('\n'); + +const encodeValueLines = (value: unknown, depth: number): string[] => { + if (Array.isArray(value)) return encodeArray(undefined, value, depth); + // An object with no fields encodes to no lines, which would make a successful empty response + // (a DELETE returning `{}`) indistinguishable from no output at all. `{}` is the object's + // counterpart to the `[0]` marker an empty array already gets. + if (isPlainRecord(value)) + return isEmptyRecord(value) ? [INDENT.repeat(depth) + EMPTY_OBJECT] : encodeObject(value, depth); + return [INDENT.repeat(depth) + formatPrimitive(value)]; +}; + +const encodeObject = (obj: Record, depth: number): string[] => { + const pad = INDENT.repeat(depth); + const lines: string[] = []; + for (const [key, value] of Object.entries(obj)) { + // Dropped like JSON.stringify drops them: absent and undefined fields serialize identically. + if (value === undefined) continue; + if (Array.isArray(value)) { + lines.push(...encodeArray(key, value, depth)); + } else if (isPlainRecord(value)) { + // Same reasoning as the top level: a bare `key:` with nothing under it reads as a missing + // value rather than an empty one. + if (isEmptyRecord(value)) lines.push(pad + formatKey(key) + ': ' + EMPTY_OBJECT); + else { + lines.push(pad + formatKey(key) + ':'); + lines.push(...encodeObject(value, depth + 1)); + } + } else { + lines.push(pad + formatKey(key) + ': ' + formatPrimitive(value)); + } + } + return lines; +}; + +// Arrays pick the densest legal layout: inline for all-primitive elements, +// tabular (one header, one row per element) for flat objects, and an explicit +// dash list otherwise. The [N] length marker always appears so agents get a +// definitive count even for empty results. +const encodeArray = (key: string | undefined, values: readonly unknown[], depth: number): string[] => { + const pad = INDENT.repeat(depth); + const prefix = key === undefined ? '' : formatKey(key); + if (values.length === 0) return [pad + prefix + '[0]:']; + if (values.every(isToonPrimitive)) { + return [pad + prefix + '[' + values.length + ']: ' + values.map(formatPrimitive).join(',')]; + } + const fields = tabularFields(values); + if (fields) { + const rowPad = INDENT.repeat(depth + 1); + return [ + pad + prefix + '[' + values.length + ']{' + fields.map(formatKey).join(',') + '}:', + ...values.map((row) => rowPad + fields.map((field) => formatPrimitive(cell(row, field))).join(',')), + ]; + } + return [ + pad + prefix + '[' + values.length + ']:', + ...values.flatMap((item) => encodeListItem(item, depth + 1)), + ]; +}; + +// One dash-list element: objects put their first field on the dash line and +// align the rest underneath it, per the TOON list layout. +const encodeListItem = (item: unknown, depth: number): string[] => { + const pad = INDENT.repeat(depth); + if (isToonPrimitive(item)) return [pad + '- ' + formatPrimitive(item)]; + if (Array.isArray(item)) { + const [header, ...rest] = encodeArray(undefined, item, depth + 1); + return [pad + '- ' + (header ?? '').trimStart(), ...rest]; + } + const lines = encodeObject(item as Record, depth + 1); + if (lines.length === 0) return [pad + '- ' + EMPTY_OBJECT]; + const [first, ...rest] = lines; + return [pad + '- ' + (first ?? '').trimStart(), ...rest]; +}; + +// Field list (union of keys, first-seen order) when every element is a flat +// object with only primitive values. Elements missing a field render an +// explicit null cell, so mostly-uniform API rows (e.g. an optional field on +// some items) still collapse into the dense tabular form. +const tabularFields = (values: readonly unknown[]): string[] | undefined => { + const fields: string[] = []; + for (const item of values) { + if (!isPlainRecord(item)) return undefined; + for (const [key, value] of Object.entries(item)) { + if (value !== undefined && !isToonPrimitive(value)) return undefined; + if (!fields.includes(key)) fields.push(key); + } + } + return fields.length > 0 ? fields : undefined; +}; + +// One tabular cell. Own properties only: a bare `row[field]` resolves through Object.prototype, +// so a row that simply lacks a field named `toString`, `constructor`, or `valueOf` would render +// the function source of that builtin into the cell instead of the intended `null`. The field +// list comes from own enumerable keys, so an inherited hit is always a missing value. +const cell = (row: unknown, field: string): unknown => + isPlainRecord(row) && Object.hasOwn(row, field) ? row[field] : undefined; + +const isPlainRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const isEmptyRecord = (value: Record): boolean => + Object.values(value).every((entry) => entry === undefined); + +const isToonPrimitive = (value: unknown): value is ToonPrimitive => + value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; + +// Control characters, DEL, and the Unicode line/paragraph separators. API strings are +// untrusted, and these reach the terminal verbatim in bare form: a lone ESC or backspace can +// repaint or erase output that has already been printed, so the value would no longer say what +// the API returned. Quoting routes them through the escaping in `quote` below. +const UNSAFE_RAW = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u; + +// JSON string syntax, then the characters JSON.stringify still leaves raw: it escapes only +// below U+0020, so DEL, the C1 block, and the line/paragraph separators need escaping here. +const quote = (text: string): string => + JSON.stringify(text).replace( + /[\u007f-\u009f\u2028\u2029]/gu, + (character) => '\\u' + character.charCodeAt(0).toString(16).padStart(4, '0'), + ); + +const formatKey = (key: string): string => (BARE_KEY.test(key) ? key : quote(key)); + +const formatPrimitive = (value: unknown): string => { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + // Non-finite numbers have no TOON literal, so mirror JSON.stringify and emit null. + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null'; + const text = String(value); + return needsQuotes(text) ? quote(text) : text; +}; + +// Quotes on the safe side: anything a TOON reader could mistake for structure +// (delimiters, brackets, a leading dash item marker), a literal (number, bool, +// null), whitespace that bare form would silently trim, or a control character +// the terminal would act on instead of print. +const needsQuotes = (text: string): boolean => { + if (text === '') return true; + if (/^\s|\s$/.test(text)) return true; + if (/[",:\\\n\r\t[\]{}]/.test(text)) return true; + if (UNSAFE_RAW.test(text)) return true; + if (text.startsWith('- ') || text === '-') return true; + if (text === 'true' || text === 'false' || text === 'null') return true; + return NUMBER_LIKE.test(text); +}; diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..2ab71d6 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,919 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import type { Command } from 'commander'; +import SDK from '../sdk/index'; +import { + createProgram, + type CliClientOptionDefinition, + type CliCommandDefinition, + type CliCommandGroup, +} from '../cli/runtime'; +import { completions } from '../cli/completions'; +import type { CliAuthDefinition } from '../cli/login'; +import { addMachineAliases } from '../custom/machines.js'; +import { registerFeedbackCommands } from '../feedback/command.js'; + +const clientOptions = [ + { + clientKey: 'apiKey', + sdkKey: 'apiKey', + name: 'api-key', + optionKey: 'apiKey', + env: 'DEDALUS_API_KEY', + description: + 'Dedalus API key or short-lived delegated access token in Authorization: Bearer .', + auth: true, + }, + { + clientKey: 'xAPIKey', + sdkKey: 'xAPIKey', + name: 'x-api-key', + optionKey: 'xApiKey', + env: 'DEDALUS_X_API_KEY', + description: 'Dedalus API key. Alternative to Bearer token.', + auth: true, + }, + { + clientKey: 'dedalusOrgID', + sdkKey: 'dedalusOrgID', + name: 'dedalus-org-id', + optionKey: 'dedalusOrgId', + env: 'DEDALUS_ORG_ID', + description: + 'Optional organization assertion. Must match the organization identified by the API credential.', + auth: false, + }, +] as const satisfies readonly CliClientOptionDefinition[]; + +const commands = [ + { + resourcePath: ['machines'], + commandPath: ['machines', 'list'], + methodName: 'list', + summary: 'List machines', + transport: 'http', + iterable: true, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'limit', + optionKey: 'limit', + paramKey: 'limit', + location: 'query', + required: false, + valueKind: 'integer', + }, + { + name: 'cursor', + optionKey: 'cursor', + paramKey: 'cursor', + location: 'query', + required: false, + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines'], + commandPath: ['machines', 'create'], + methodName: 'create', + summary: 'Create machine', + transport: 'http', + iterable: false, + callShape: 'body', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'autosleep', + optionKey: 'autosleep', + paramKey: 'autosleep', + location: 'body', + required: false, + description: + 'Idle window before autosleep. Accepts fixed duration units like 30s, 30m, 2h, 7d3h4s, or 1w3d, raw seconds ("1800"), or never to disable.', + valueKind: 'string', + }, + { + name: 'memory-mib', + optionKey: 'memoryMib', + paramKey: 'memory_mib', + location: 'body', + required: false, + description: 'Memory in MiB.', + valueKind: 'integer', + }, + { + name: 'storage-gib', + optionKey: 'storageGib', + paramKey: 'storage_gib', + location: 'body', + required: false, + description: 'Storage in GiB.', + valueKind: 'integer', + }, + { + name: 'vcpu', + optionKey: 'vcpu', + paramKey: 'vcpu', + location: 'body', + required: false, + description: 'CPU in vCPUs.', + valueKind: 'number', + }, + ], + }, + { + resourcePath: ['machines'], + commandPath: ['machines', 'retrieve'], + methodName: 'retrieve', + summary: 'Get machine', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines'], + commandPath: ['machines', 'update'], + methodName: 'update', + summary: 'Update machine', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'autosleep', + optionKey: 'autosleep', + paramKey: 'autosleep', + location: 'body', + required: false, + description: + 'Idle window before autosleep. Accepts fixed duration units like 30s, 30m, 2h, 7d3h4s, or 1w3d, raw seconds ("1800"), or never to disable.', + valueKind: 'string', + }, + { + name: 'memory-mib', + optionKey: 'memoryMib', + paramKey: 'memory_mib', + location: 'body', + required: false, + description: 'Memory in MiB.', + valueKind: 'integer', + }, + { + name: 'storage-gib', + optionKey: 'storageGib', + paramKey: 'storage_gib', + location: 'body', + required: false, + description: 'Storage in GiB.', + valueKind: 'integer', + }, + { + name: 'vcpu', + optionKey: 'vcpu', + paramKey: 'vcpu', + location: 'body', + required: false, + description: 'CPU in vCPUs.', + valueKind: 'number', + }, + ], + }, + { + resourcePath: ['machines'], + commandPath: ['machines', 'delete'], + methodName: 'delete', + summary: 'Destroy machine', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines'], + commandPath: ['machines', 'sleep'], + methodName: 'sleep', + summary: 'Sleep a running machine', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines'], + commandPath: ['machines', 'wake'], + methodName: 'wake', + summary: 'Wake a sleeping machine', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines'], + commandPath: ['machines', 'reboot'], + methodName: 'reboot', + summary: 'Reboot a machine with fresh memory', + description: + 'Checkpoints files and replaces the runtime. The machine ID and filesystem are preserved. RAM, processes, and temporary mounts are cleared. Poll the machine until its phase is running. Retry the same Idempotency-Key after a lost response.', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'force', + optionKey: 'force', + paramKey: 'force', + location: 'query', + required: false, + description: + 'Recover from the last committed filesystem checkpoint without guest cooperation. Unpublished file writes are lost. The default checkpoints files before rebooting.', + valueKind: 'boolean', + }, + ], + }, + { + resourcePath: ['machines', 'ssh'], + commandPath: ['machines:ssh', 'list'], + methodName: 'list', + summary: 'List SSH sessions', + transport: 'http', + iterable: true, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'limit', + optionKey: 'limit', + paramKey: 'limit', + location: 'query', + required: false, + valueKind: 'integer', + }, + { + name: 'cursor', + optionKey: 'cursor', + paramKey: 'cursor', + location: 'query', + required: false, + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines', 'ssh'], + commandPath: ['machines:ssh', 'create'], + methodName: 'create', + summary: 'Create SSH session', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'public-key', + optionKey: 'publicKey', + paramKey: 'public_key', + location: 'body', + required: true, + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines', 'ssh'], + commandPath: ['machines:ssh', 'retrieve'], + methodName: 'retrieve', + summary: 'Get SSH session', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'session-id', + optionKey: 'sessionId', + paramKey: 'session_id', + location: 'path', + required: true, + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines', 'ssh'], + commandPath: ['machines:ssh', 'delete'], + methodName: 'delete', + summary: 'Delete SSH session', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'session-id', + optionKey: 'sessionId', + paramKey: 'session_id', + location: 'path', + required: true, + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines', 'executions'], + commandPath: ['machines:executions', 'list'], + methodName: 'list', + summary: 'List executions', + transport: 'http', + iterable: true, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'limit', + optionKey: 'limit', + paramKey: 'limit', + location: 'query', + required: false, + valueKind: 'integer', + }, + { + name: 'cursor', + optionKey: 'cursor', + paramKey: 'cursor', + location: 'query', + required: false, + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines', 'executions'], + commandPath: ['machines:executions', 'create'], + methodName: 'create', + summary: 'Create execution', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'command', + optionKey: 'command', + paramKey: 'command', + location: 'body', + required: true, + valueKind: 'array', + }, + { + name: 'cwd', + optionKey: 'cwd', + paramKey: 'cwd', + location: 'body', + required: false, + valueKind: 'string', + }, + { + name: 'env', + optionKey: 'env', + paramKey: 'env', + location: 'body', + required: false, + valueKind: 'object', + }, + { + name: 'stdin', + optionKey: 'stdin', + paramKey: 'stdin', + location: 'body', + required: false, + valueKind: 'string', + }, + { + name: 'timeout-ms', + optionKey: 'timeoutMs', + paramKey: 'timeout_ms', + location: 'body', + required: false, + valueKind: 'integer', + }, + ], + }, + { + resourcePath: ['machines', 'executions'], + commandPath: ['machines:executions', 'retrieve'], + methodName: 'retrieve', + summary: 'Get execution', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'execution-id', + optionKey: 'executionId', + paramKey: 'execution_id', + location: 'path', + required: true, + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines', 'executions'], + commandPath: ['machines:executions', 'delete'], + methodName: 'delete', + summary: 'Delete execution', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'execution-id', + optionKey: 'executionId', + paramKey: 'execution_id', + location: 'path', + required: true, + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines', 'executions'], + commandPath: ['machines:executions', 'output'], + methodName: 'output', + summary: 'Get execution output', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'execution-id', + optionKey: 'executionId', + paramKey: 'execution_id', + location: 'path', + required: true, + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines', 'executions'], + commandPath: ['machines:executions', 'events'], + methodName: 'events', + summary: 'List execution events', + transport: 'http', + iterable: true, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'execution-id', + optionKey: 'executionId', + paramKey: 'execution_id', + location: 'path', + required: true, + valueKind: 'string', + }, + { + name: 'limit', + optionKey: 'limit', + paramKey: 'limit', + location: 'query', + required: false, + valueKind: 'integer', + }, + { + name: 'cursor', + optionKey: 'cursor', + paramKey: 'cursor', + location: 'query', + required: false, + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines', 'executions', 'logs'], + commandPath: ['machines:executions:logs', 'retrieve'], + methodName: 'retrieve', + summary: 'Get execution log status', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'execution-id', + optionKey: 'executionId', + paramKey: 'execution_id', + location: 'path', + required: true, + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines', 'executions', 'logs'], + commandPath: ['machines:executions:logs', 'reauthorize'], + methodName: 'reauthorize', + summary: 'Reauthorize execution log publication', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'execution-id', + optionKey: 'executionId', + paramKey: 'execution_id', + location: 'path', + required: true, + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines', 'executions', 'logs'], + commandPath: ['machines:executions:logs', 'create-token'], + methodName: 'createToken', + summary: 'Create execution log read token', + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'execution-id', + optionKey: 'executionId', + paramKey: 'execution_id', + location: 'path', + required: true, + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines', 'autoresizing'], + commandPath: ['machines:autoresizing', 'retrieve'], + methodName: 'retrieve', + summary: "Read this machine's RAM autoresizing settings", + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + ], + }, + { + resourcePath: ['machines', 'autoresizing'], + commandPath: ['machines:autoresizing', 'update'], + methodName: 'update', + summary: "Set this machine's RAM autoresizing settings", + transport: 'http', + iterable: false, + callShape: 'params', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'machine-id', + optionKey: 'machineId', + paramKey: 'machine_id', + location: 'path', + required: true, + description: 'Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged.', + valueKind: 'string', + }, + { + name: 'enabled', + optionKey: 'enabled', + paramKey: 'enabled', + location: 'body', + required: true, + valueKind: 'boolean', + }, + ], + }, + { + resourcePath: ['organization', 'autoresizing'], + commandPath: ['organization:autoresizing', 'retrieve'], + methodName: 'retrieve', + summary: 'Read organization RAM autoresizing policy', + transport: 'http', + iterable: false, + callShape: 'options', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [], + }, + { + resourcePath: ['organization', 'autoresizing'], + commandPath: ['organization:autoresizing', 'update'], + methodName: 'update', + summary: 'Set organization RAM autoresizing policy', + transport: 'http', + iterable: false, + callShape: 'body', + authClientKeyRequirements: [['xAPIKey'], ['apiKey']], + positional: [], + flags: [ + { + name: 'enabled', + optionKey: 'enabled', + paramKey: 'enabled', + location: 'body', + required: true, + description: + 'Allow automatic RAM increases for all organization machines. Disabling preserves applied RAM and already admitted resizes.', + valueKind: 'boolean', + }, + ], + }, +] as const satisfies readonly CliCommandDefinition[]; + +const groups = [] as const satisfies readonly CliCommandGroup[]; + +export const auth = { + loginPath: ['login'], + logoutPath: ['logout'], + loginCommand: 'dedalus login', + storeName: 'dedalus', + storeEnv: 'DEDALUS_CREDENTIALS_FILE', + baseUrlEnv: 'DEDALUS_BASE_URL', + backend: 'auto', + defaultBaseUrl: 'https://dcs.dedaluslabs.ai', + requirements: [['xAPIKey'], ['apiKey']], + envByKey: { + apiKey: 'DEDALUS_API_KEY', + xAPIKey: 'DEDALUS_X_API_KEY', + }, + methods: [ + // scalar-sdk-generator:custom-code configured-browser-method:start + // configured browser login pending the public provider rollout + { + name: 'browser', + label: 'Sign in with your browser', + kind: 'oauth', + grant: 'authorizationCode', + clientKey: 'apiKey', + clientId: 'dedalus-cli', + authorizationUrl: '', + tokenUrl: '', + refreshUrl: '', + resource: 'https://dcs.dedaluslabs.ai', + issuer: '', + revocationUrl: '', + scopes: ['dedalus:cli', 'offline_access'], + redirectPort: 0, + }, + // scalar-sdk-generator:custom-code configured-browser-method:end + { + name: 'api-key', + label: 'Enter your access token', + kind: 'token', + clientKey: 'apiKey', + prompt: 'Access token: ', + }, + { + name: 'x-api-key', + label: 'Enter your API key', + kind: 'token', + clientKey: 'xAPIKey', + prompt: 'API key: ', + }, + ], +} as const satisfies CliAuthDefinition; + +export const getProgram = (): Command => + registerFeedbackCommands(addMachineAliases(createProgram({ + SDK, + binaryName: 'dedalus', + version: '0.7.0', // x-release-please-version + description: 'CLI for Dedalus', + defaultFormat: 'auto', + defaultErrorFormat: 'auto', + clientOptions, + // Feedback owns a positional message and attachment selection instead of generated flags. + commands: commands.filter((definition: CliCommandDefinition) => definition.resourcePath[0] !== 'feedback'), + groups, + completions, + auth, + }), clientOptions, { auth }), clientOptions, auth); diff --git a/src/custom/README.md b/src/custom/README.md new file mode 100644 index 0000000..de39bee --- /dev/null +++ b/src/custom/README.md @@ -0,0 +1,24 @@ +# SSH shortcut + +`dedalus ssh` opens a searchable machine picker in an interactive terminal. +Filter by machine ID or lifecycle status, then use the arrow keys and Enter to +connect. Escape and Ctrl-C cancel, including while machines are loading. +Destroyed machines are excluded. + +For scripts, use `dedalus ssh ` with a bare UUID. Omitting the ID +without a terminal is a usage error. SSH polling uses the machine ID returned +by the server and waits through wake and SSH setup states. + +```sh +dedalus ssh 12345678-1234-4234-8234-123456789abc +``` + +Validation: + +```sh +npm run typecheck +npm run build +node --test tests/machine-aliases.test.mjs +node --test tests/ssh-paths.test.mjs +python3 tests/ssh-picker-pty.py +``` diff --git a/src/custom/machines.ts b/src/custom/machines.ts new file mode 100644 index 0000000..8341b8d --- /dev/null +++ b/src/custom/machines.ts @@ -0,0 +1,104 @@ +/** SSH selection that shares the CLI's authentication and error settings. */ + +import { Command, Option } from 'commander' +import SDK from '../sdk/index.js' +import type { CliAuthDefinition } from '../cli/login.js' +import { + type CliClientOptionDefinition, type GlobalOptions, + sdkClientOptions, writeError, errorExitCode, normalizeFormat, usageExitCode, +} from '../cli/runtime.js' +import { pickSSHMachine } from './ssh-picker.js' +import { connectMachine, type SSHAPI } from './ssh.js' + +export type MachineAPI = SSHAPI & { + readonly listMachines: (cursor: string | undefined, signal: AbortSignal) => Promise +} + +type AliasOptions = { + readonly auth?: CliAuthDefinition + readonly api?: (client: SDK) => MachineAPI + readonly connect?: (api: SSHAPI, machineID: string) => Promise + readonly pick?: (api: MachineAPI) => Promise + readonly interactive?: () => boolean +} + +export const createMachineAPI = (client: SDK): MachineAPI => ({ + listMachines: (cursor, signal) => client.get('/v1/machines', { + query: cursor === undefined ? {} : { cursor }, signal, + }), + createSSHSession: (machineID, publicKey) => client.machines.ssh.create({ + machine_id: machineID, public_key: publicKey, + }), + getMachineSSHSession: (machineID, sessionID) => client.machines.ssh.retrieve({ + machine_id: machineID, session_id: sessionID, + }), +}) + +export const addMachineAliases = ( + program: Command, + clientOptions: readonly CliClientOptionDefinition[], + options: AliasOptions = {}, +): Command => { + const makeAPI = options.api ?? createMachineAPI + const connect = options.connect ?? connectMachine + const pick = options.pick ?? pickSSHMachine + const interactive = options.interactive ?? (() => Boolean( + process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY, + )) + const ssh = aliasCommand(program, 'ssh') + .description('Choose a machine and connect over SSH, or supply its UUID') + .argument('[machine-id]', 'Machine UUID; omitted to open the interactive picker') + .action(async (target: string | undefined, _flags: unknown, command: Command) => { + if (target !== undefined && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(target)) { + command.error('machine ID must be a bare UUID', { exitCode: 2 }) + } + if (!target && !interactive()) { + command.error('machine ID is required without an interactive terminal; usage: dedalus ssh ', { exitCode: 2 }) + } + await runAlias(command, clientOptions, async (client) => { + const api = makeAPI(client) + const machineID = target ?? await pick(api) + if (machineID !== undefined) await connect(api, machineID) + }, options.auth) + }) + program.addCommand(ssh) + return program +} + +const aliasCommand = (program: Command, name: string): Command => { + if (program.commands.some((command) => command.name() === name)) { + throw new Error(`the reserved '${name}' command is already registered`) + } + const command = usageExitCode(new Command(name)).showHelpAfterError() + // Mirror global flags so both `--api-key … ssh` and `ssh --api-key …` work. + for (const option of program.options) { + if (option.long !== '--version') command.addOption(new Option(option.flags, option.description)) + } + const organization = program.commands.find((entry) => entry.name() === 'machines') + ?.commands.find((entry) => entry.name() === 'retrieve') + ?.options.find((option) => option.long === '--x-dedalus-org-id') + if (organization && !command.options.some((option) => option.long === organization.long)) { + command.addOption(new Option(organization.flags, organization.description)) + } + return command +} + +const runAlias = async ( + command: Command, + clientOptions: readonly CliClientOptionDefinition[], + action: (client: SDK) => Promise, + auth: CliAuthDefinition | undefined, +): Promise => { + const flags = command.optsWithGlobals() + try { + const client = new SDK(await sdkClientOptions(flags, command, clientOptions, auth)) + await action(client) + } catch (error) { + await writeError(error, { + format: normalizeFormat(command.opts().formatError ?? flags.formatError, 'auto'), + ...(flags.transformError ? { transform: flags.transformError } : {}), + ...(flags.rawOutput ? { rawOutput: true } : {}), + }, clientOptions, SDK) + process.exitCode = errorExitCode(error, SDK) + } +} diff --git a/src/custom/ssh-picker.ts b/src/custom/ssh-picker.ts new file mode 100644 index 0000000..295241a --- /dev/null +++ b/src/custom/ssh-picker.ts @@ -0,0 +1,153 @@ +/** Search machine names while retaining stable IDs for the SSH request. */ + +import { emitKeypressEvents, type Key } from 'node:readline' +import type { MachineAPI } from './machines.js' + +export type MachineChoice = { + readonly id: string + readonly name: string | null + readonly status: string +} + +export type PickerState = { + readonly machines: readonly MachineChoice[] + readonly query: string + readonly cursor: number +} + +export const matchingMachines = (state: PickerState): readonly MachineChoice[] => { + const query = state.query.toLowerCase().trim() + return state.machines.filter((machine) => + `${machine.name ?? ''} ${machine.id} ${machine.status}`.toLowerCase().includes(query)) +} + +export const updatePicker = (state: PickerState, text: string, key: Key): PickerState => { + const count = matchingMachines(state).length + if (key.name === 'up') return { ...state, cursor: Math.max(0, state.cursor - 1) } + if (key.name === 'down') return { ...state, cursor: Math.max(0, Math.min(count - 1, state.cursor + 1)) } + if (key.name === 'backspace') return { ...state, query: [...state.query].slice(0, -1).join(''), cursor: 0 } + if (key.ctrl && key.name === 'u') return { ...state, query: '', cursor: 0 } + if (!key.ctrl && !key.meta && text && !/[\x00-\x1f\x7f-\x9f]/u.test(text)) { + return { ...state, query: state.query + text, cursor: 0 } + } + return state +} + +export const renderPicker = (state: PickerState, rows: number, columns: number): string => { + const matches = matchingMachines(state) + const count = Math.max(1, Math.floor((rows - 8) / 2)) + const start = Math.max(0, state.cursor - count + 1) + const lines = ['Select a machine for SSH', '', `/ ${state.query}`, ''] + for (let index = start; index < Math.min(matches.length, start + count); index += 1) { + const machine = matches[index]! + lines.push(`${index === state.cursor ? '>' : ' '} ${machine.name ?? 'Unnamed machine'} [${machine.status}]`) + lines.push(` ${machine.id}`) + } + if (matches.length === 0) lines.push('No matching machines') + lines.push('', `${matches.length} of ${state.machines.length} machines · ↑/↓ select · Enter connect · Esc cancel`) + return lines.map((line) => terminalText(line).slice(0, Math.max(1, columns - 1))).join('\n') + '\n' +} + +export const loadMachineChoices = async (api: MachineAPI, signal: AbortSignal): Promise => { + const machines: MachineChoice[] = [] + const cursors = new Set() + let cursor: string | undefined + do { + const page = record(await api.listMachines(cursor, signal), 'machine list') + if (!Array.isArray(page.items)) throw new Error('machine list response omitted items') + for (const value of page.items) { + const machine = record(value, 'machine') + if (machine.desired_state === 'destroyed' || machine.phase === 'destroyed') continue + if (typeof machine.machine_id !== 'string' || !machine.machine_id) { + throw new Error('machine list response omitted machine_id') + } + if (typeof machine.phase !== 'string' || !machine.phase) { + throw new Error(`machine ${machine.machine_id} response omitted lifecycle status`) + } + if (machine.name !== undefined && machine.name !== null && typeof machine.name !== 'string') { + throw new Error(`machine ${machine.machine_id} response returned an invalid name`) + } + machines.push({ id: machine.machine_id, name: machine.name as string | null | undefined ?? null, status: machine.phase }) + } + if (page.next_cursor === null || page.next_cursor === undefined || page.next_cursor === '') break + if (typeof page.next_cursor !== 'string' || cursors.has(page.next_cursor)) { + throw new Error('machine list response returned an invalid or repeated cursor') + } + cursor = page.next_cursor + cursors.add(cursor) + } while (!signal.aborted) + signal.throwIfAborted() + if (machines.length === 0) throw new Error('No machines available for SSH. Create one with dedalus machines create.') + return machines +} + +export const pickSSHMachine = (api: MachineAPI): Promise => { + const input = process.stdin + const output = process.stderr + if (!input.isTTY || !process.stdout.isTTY || !output.isTTY) { + return Promise.reject(new Error('machine ID is required without an interactive terminal; usage: dedalus ssh ')) + } + return new Promise((resolve, reject) => { + const controller = new AbortController() + const wasRaw = input.isRaw + const wasFlowing = input.readableFlowing + let done = false + let loading = true + let state: PickerState = { machines: [], query: '', cursor: 0 } + const render = (): void => { + const view = loading ? 'Loading machines…\n\nEsc cancel\n' : renderPicker(state, output.rows || 24, output.columns || 80) + output.write('\x1b[H\x1b[2J' + view) + } + const finish = (id?: string, error?: unknown): void => { + if (done) return + done = true + controller.abort() + input.off('keypress', onKey) + input.off('error', onError) + input.off('end', onEnd) + output.off('resize', render) + input.setRawMode(wasRaw) + if (!wasFlowing) input.pause() + output.write('\x1b[?25h\x1b[?1049l') + if (error) reject(error) + else resolve(id) + } + const onKey = (text: string, key: Key): void => { + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) return finish() + if (loading) return + if (key.name === 'return') { + const selected = matchingMachines(state)[state.cursor] + if (selected) finish(selected.id) + return + } + state = updatePicker(state, text, key) + render() + } + const onError = (error: Error): void => finish(undefined, error) + const onEnd = (): void => finish() + emitKeypressEvents(input) + input.setRawMode(true) + input.on('keypress', onKey) + input.once('error', onError) + input.once('end', onEnd) + output.on('resize', render) + input.resume() + output.write('\x1b[?1049h\x1b[?25l') + render() + void loadMachineChoices(api, controller.signal).then((machines) => { + if (done) return + loading = false + state = { ...state, machines } + render() + }, (error: unknown) => finish(undefined, error)) + }) +} + +const record = (value: unknown, label: string): Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} response must be an object`) + } + return value as Record +} + +const terminalText = (text: string): string => text.replace(/[\x00-\x1f\x7f-\x9f]/gu, '') diff --git a/src/custom/ssh.ts b/src/custom/ssh.ts new file mode 100644 index 0000000..8ae983f --- /dev/null +++ b/src/custom/ssh.ts @@ -0,0 +1,195 @@ +/** Connect with an ephemeral key and the server's SSH certificate and host CA. */ + +import { spawn } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +export type SSHAPI = { + readonly createSSHSession: (machineID: string, publicKey: string) => Promise + readonly getMachineSSHSession: (machineID: string, sessionID: string) => Promise +} + +const pollIntervalMilliseconds = 500 +const pollLimit = 120 + +type SSHConnection = { + readonly endpoint: string + readonly hostPattern: string + readonly hostPublicKey: string + readonly port: number + readonly sshUsername: string + readonly userCertificate: string +} + +type SSHSession = { + readonly machine_id?: unknown + readonly connection?: unknown + readonly error_code?: unknown + readonly error_message?: unknown + readonly retry_after_ms?: unknown + readonly session_id?: unknown + readonly status?: unknown +} + +export const connectMachine = async (api: SSHAPI, machineID: string): Promise => { + const directory = await mkdtemp(join(tmpdir(), 'dedalus-ssh-')) + try { + const keyPath = join(directory, 'key') + await runProcess('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', '', '-f', keyPath], 'ignore') + const publicKey = (await readFile(`${keyPath}.pub`, 'utf8')).trim() + if (!publicKey) throw new Error('ssh-keygen returned an empty public key') + + const session = await awaitSSHSession(api, machineID, publicKey) + const connection = readyConnection(session) + const certificatePath = `${keyPath}-cert.pub` + const knownHostsPath = join(directory, 'known_hosts') + await writeFile(certificatePath, `${connection.userCertificate.trim()}\n`, { mode: 0o600 }) + await writeFile(knownHostsPath, + `@cert-authority ${connection.hostPattern} ${connection.hostPublicKey}\n`, { mode: 0o600 }) + + process.stderr.write( + `connecting to ${connection.sshUsername}@${connection.endpoint}:${connection.port}\n`, + ) + const code = await runProcess('ssh', [ + '-i', keyPath, + '-o', `CertificateFile=${quoteSSHPath(certificatePath)}`, + '-o', `UserKnownHostsFile=${quoteSSHPath(knownHostsPath)}`, + '-o', 'GlobalKnownHostsFile=/dev/null', + '-o', 'StrictHostKeyChecking=yes', + '-o', 'IdentitiesOnly=yes', + '-p', String(connection.port), + '--', `${connection.sshUsername}@${connection.endpoint}`, + ], 'inherit') + if (code !== 0) process.exitCode = code + } finally { + await rm(directory, { recursive: true, force: true }) + } +} + +export const awaitSSHSession = async ( + api: SSHAPI, + machineID: string, + publicKey: string, +): Promise => { + let session = sessionFrom(await api.createSSHSession(machineID, publicKey)) + // Pin polling to the machine ID returned with the session. + machineID = requiredString(session.machine_id, 'SSH session response omitted machine_id') + for (let poll = 0; poll <= pollLimit; poll += 1) { + const sessionID = requiredString(session.session_id, 'SSH session response omitted session_id') + const status = requiredString(session.status, `SSH session ${sessionID} omitted status`) + process.stderr.write(`ssh ${sessionID}: ${status}\n`) + switch (status) { + case 'ready': + return session + case 'wake_in_progress': + case 'ssh_in_progress': + if (poll === pollLimit) { + throw new Error(`SSH session did not become ready after ${pollLimit} polls`) + } + break + case 'failed': + throw new Error( + `SSH session failed: ${optionalString(session.error_message) ?? 'no detail from server'}` + + ` (error_code=${optionalString(session.error_code) ?? 'unknown'})`, + ) + case 'expired': + throw new Error('SSH session expired before it became ready; try again with a fresh session') + case 'closed': + throw new Error('SSH session was closed before it became ready') + default: + throw new Error(`SSH session ${sessionID} returned unknown status '${status}'`) + } + + await sleep(retryDelay(session.retry_after_ms)) + session = sessionFrom(await api.getMachineSSHSession(machineID, sessionID)) + } + throw new Error('SSH session polling ended without a terminal state') +} + +const readyConnection = (session: SSHSession): SSHConnection => { + const sessionID = requiredString(session.session_id, 'SSH session response omitted session_id') + if (!session.connection || typeof session.connection !== 'object') { + throw new Error(`SSH session ${sessionID} is ready but the server returned no connection`) + } + const connection = session.connection as Record + const trust = connection.host_trust + if (!trust || typeof trust !== 'object') { + throw new Error(`SSH session ${sessionID} is ready but the server returned no host trust`) + } + const hostTrust = trust as Record + const endpoint = singleLine(connection.endpoint, 'endpoint', sessionID) + const sshUsername = singleLine(connection.ssh_username, 'SSH username', sessionID) + const userCertificate = singleLine(connection.user_certificate, 'user certificate', sessionID) + const hostPattern = singleLine(hostTrust.host_pattern, 'host pattern', sessionID) + const hostPublicKey = singleLine(hostTrust.public_key, 'host CA public key', sessionID) + if (/\s/u.test(hostPattern)) { + throw new Error(`SSH session ${sessionID} returned an invalid host pattern`) + } + const port = connection.port + if (!Number.isSafeInteger(port) || (port as number) < 1 || (port as number) > 65_535) { + throw new Error(`SSH session ${sessionID} returned an invalid port`) + } + return { endpoint, hostPattern, hostPublicKey, port: port as number, sshUsername, userCertificate } +} + +const sessionFrom = (value: unknown): SSHSession => { + if (!value || typeof value !== 'object') throw new Error('server returned an empty SSH session') + return value as SSHSession +} + +const singleLine = (value: unknown, name: string, sessionID: string): string => { + const result = requiredString(value, `SSH session ${sessionID} is ready but returned no ${name}`) + if (/[\r\n\0]/u.test(result)) { + throw new Error(`SSH session ${sessionID} returned an invalid ${name}`) + } + return result +} + +const requiredString = (value: unknown, message: string): string => { + if (typeof value !== 'string' || !value) throw new Error(message) + return value +} + +const optionalString = (value: unknown): string | undefined => + typeof value === 'string' && value ? value : undefined + +const retryDelay = (value: unknown): number => { + if (value === undefined || value === 0) return pollIntervalMilliseconds + if (!Number.isSafeInteger(value) || (value as number) <= 0 || (value as number) > 60_000) { + throw new Error('SSH session returned an invalid retry_after_ms') + } + return value as number +} + +const sleep = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)) + +// OpenSSH parses -o values as configuration text even when spawn receives an argv array. +const quoteSSHPath = (path: string): string => `"${path.replace(/[\\"]/gu, '\\$&')}"` + +const runProcess = ( + command: string, + args: readonly string[], + stdio: 'ignore' | 'inherit', +): Promise => new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio }) + child.once('error', (error) => { + reject(new Error(`${command} is required but was not found in PATH`, { cause: error })) + }) + child.once('close', (code, signal) => { + if (signal) { + reject(new Error(`${command} terminated by ${signal}`)) + return + } + if (code === null) { + reject(new Error(`${command} exited without a status`)) + return + } + if (command === 'ssh-keygen' && code !== 0) { + reject(new Error(`ssh-keygen exited with status ${code}`)) + return + } + resolve(code) + }) +}) diff --git a/src/feedback/README.md b/src/feedback/README.md new file mode 100644 index 0000000..7a79b73 --- /dev/null +++ b/src/feedback/README.md @@ -0,0 +1,31 @@ +# Feedback diagnostics + +`createDiagnostics` records command names, route templates, HTTP status, duration, +and server-issued request IDs. It excludes arguments, headers, message bodies, +terminal output, and workspace files. Recording failures report an error to +stderr and stop local recording without interrupting the API command. + +`diagnosticScope` separates API hosts, credentials, and organization overrides. +`selectDiagnostics` returns a failed command from the last 15 minutes in that +scope, selected by its terminal `command_failure` event. Attempts that recover +before `command_complete` do not qualify. A failed command retains the final +response's valid receipt even if a successful HTTP response could not be processed; +failure status metadata requires the final HTTP response to be an error. +Selection rebuilds permitted fields when reading and excludes feedback commands +and symlinked files. The credential digest stays in local filenames. + +The default directory is `~/.dedalus/debug`. Each process records at most 1,000 +events and 10 MiB. New writes prune files older than 10 days and keep the directory +under 50 MiB. Run `pnpm build && pnpm test` to verify these boundaries. + +`buildFeedbackBundle` selects files for the explicit `auto`, `true`, or `false` +log-inclusion mode. Opt-out sends no files while retaining a recent server +receipt as metadata. Auto mode attaches only recent failures in the same scope. +Explicit inclusion adds a runtime and proxy-presence report, even without logs. +Proxy values are omitted. Each attachment carries its exact byte count and +SHA-256 digest; the bundle contains at most four files and 1 MiB compressed. + +Generated HTTP commands record request starts, responses, failures, and command +completion through the SDK fetch hook. Scope includes a per-call organization +override. The CLI sends its command name without arguments and never forwards +a saved server receipt as an outbound `X-Request-ID`. diff --git a/src/feedback/bundle.ts b/src/feedback/bundle.ts new file mode 100644 index 0000000..59949e5 --- /dev/null +++ b/src/feedback/bundle.ts @@ -0,0 +1,110 @@ +// @custom start +// Build bounded feedback attachments from the permitted diagnostic fields. +// First-party feedback attachments, constructed from allowlisted diagnostic fields. + +import { createHash } from 'node:crypto'; +import { arch, platform } from 'node:os'; +import { gzipSync } from 'node:zlib'; + +import { debugDirectory, selectDiagnostics, type DiagnosticSelection } from './diagnostics.js'; + +export type IncludeLogs = 'auto' | 'true' | 'false'; +export type DebugFile = { + filename: + | 'dedalus-feedback.log' + | 'dedalus-doctor-report.json' + | 'dedalus-connectivity-diagnostics.txt' + | 'dedalus-operation.jsonl.gz'; + format: 'text/plain' | 'application/json' | 'application/gzip'; + sha256: string; + size_bytes: number; +}; +export type Attachment = { manifest: DebugFile; bytes: Uint8Array }; + +export const doctorReport = (baseURL: string, version: string) => ({ + client: { name: 'dedalus-cli', version }, + runtime: { os: platform(), arch: arch(), node: process.versions.node }, + api_origin: new URL(baseURL).origin, + auth_mode: 'api_key', + install_method: 'node-package', + proxy_environment: [ + 'HTTPS_PROXY', + 'HTTP_PROXY', + 'ALL_PROXY', + 'NO_PROXY', + 'https_proxy', + 'http_proxy', + 'all_proxy', + 'no_proxy', + ] + .filter((key) => !!process.env[key]) + .map((key) => ({ name: key, configured: true })), + collection: { + request_bodies: false, + response_bodies: false, + terminal_output: false, + workspace_files: false, + }, +}); + +const attachment = ( + filename: DebugFile['filename'], + format: DebugFile['format'], + bytes: Uint8Array, +): Attachment => ({ + manifest: { + filename, + format, + sha256: createHash('sha256').update(bytes).digest('hex'), + size_bytes: bytes.byteLength, + }, + bytes, +}); + +export const buildFeedbackBundle = ( + mode: IncludeLogs, + scope: string, + baseURL: string, + version: string, + directory = debugDirectory(), +): { selection: DiagnosticSelection; attachments: Attachment[] } => { + let selection: DiagnosticSelection; + try { + selection = selectDiagnostics(scope, directory); + } catch { + process.stderr.write( + 'Recent local diagnostics could not be read; no saved logs will be attached.\n', + ); + selection = { events: [] }; + } + if (mode === 'false') return { selection: { ...selection, events: [] }, attachments: [] }; + if (mode === 'auto' && selection.events.length === 0) return { selection, attachments: [] }; + const doctor = Buffer.from(JSON.stringify(doctorReport(baseURL, version), null, 2)); + const connectivity = Buffer.from( + 'Connectivity diagnostics\nProxy values are omitted; configured variable names are listed in the doctor report.\n', + ); + const attachments = [ + attachment('dedalus-doctor-report.json', 'application/json', doctor), + attachment('dedalus-connectivity-diagnostics.txt', 'text/plain', connectivity), + ]; + // Preserve the newest complete rows. Both the plain log and gzip count toward the upload cap. + const rows: string[] = []; + let used = doctor.byteLength + connectivity.byteLength; + for (const event of [...selection.events].reverse()) { + const row = JSON.stringify(event) + '\n'; + const size = Buffer.byteLength(row); + if (used + size > 450 * 1024) break; + rows.unshift(row); + used += size; + } + if (rows.length > 0) { + const log = Buffer.from(rows.join('')); + attachments.push(attachment('dedalus-feedback.log', 'text/plain', log)); + attachments.push(attachment('dedalus-operation.jsonl.gz', 'application/gzip', gzipSync(log))); + } + if (attachments.reduce((sum, file) => sum + file.bytes.byteLength, 0) > 1024 * 1024) { + throw new Error('scrubbed feedback attachments exceed the upload limit'); + } + return { selection, attachments }; +}; +// @custom end diff --git a/src/feedback/command.ts b/src/feedback/command.ts new file mode 100644 index 0000000..c1f1a98 --- /dev/null +++ b/src/feedback/command.ts @@ -0,0 +1,122 @@ +// @custom start +// Provide the feedback and doctor commands around the generated SDK transport. +// Custom CLI feedback workflow; API transport and retries use the embedded SDK. + +import { randomBytes } from 'node:crypto'; +import { Command, Option } from 'commander'; + +import SDK from '../sdk/index.js'; +import type { CliAuthDefinition } from '../cli/login.js'; +import { VERSION } from '../sdk/version.js'; +import { + errorExitCode, + sdkClientOptions, + writeError, + writeOutput, + type CliClientOptionDefinition, + type GlobalOptions, +} from '../cli/runtime.js'; +import { buildFeedbackBundle, doctorReport, type IncludeLogs } from './bundle.js'; +import { debugDirectory, diagnosticScope } from './diagnostics.js'; + +export const feedbackIdempotencyKey = (): string => { + // UUIDv7: bytes 0..5 hold Unix milliseconds; byte 6 holds version 7; + // byte 8 holds the RFC variant. Remaining bits are random. + const bytes = randomBytes(16); + bytes.writeUIntBE(Date.now(), 0, 6); + bytes[6] = (bytes[6]! & 0x0f) | 0x70; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + return bytes.toString('hex'); +}; + +export const registerFeedbackCommands = ( + program: Command, + clientOptions: readonly CliClientOptionDefinition[], + auth: CliAuthDefinition | undefined, + directory = debugDirectory(), +): Command => { + const feedback = program + .command('feedback ') + .description('Send feedback with optional scrubbed CLI diagnostics') + .addOption( + new Option('--include-logs ', 'Include recent first-party diagnostics') + .choices(['auto', 'true', 'false']) + .default('auto'), + ) + .option('--base-url ', 'Override the base URL for API requests') + .option('--x-dedalus-org-id ', 'Organization for this feedback report') + .option('--timeout ', 'Request timeout in milliseconds') + .option('--max-retries ', 'Number of retries for retryable failures') + .option('--format ', 'Output format: auto, json, jsonl, pretty, raw, toon, yaml'); + for (const option of clientOptions) + feedback.option('--' + option.name + ' ', option.description ?? ''); + feedback.action(async (message: string, _: unknown, command: Command) => { + if (message.trim().length === 0 || [...message].length > 10000) { + command.error('message must contain 1 to 10000 characters', { exitCode: 2 }); + } + const options = command.optsWithGlobals(); + try { + const clientOptionsForRequest = await sdkClientOptions(options, command, clientOptions, auth); + const client = new SDK({ ...clientOptionsForRequest, logLevel: 'off' }); + const bundle = buildFeedbackBundle( + options.includeLogs, + diagnosticScope(clientOptionsForRequest), + client.baseURL, + VERSION, + directory, + ); + const metadata = { + message, + source: 'cli', + ...(bundle.selection.command ? { command: bundle.selection.command } : {}), + ...(bundle.selection.receipt ? { reported_request_id: bundle.selection.receipt } : {}), + ...(bundle.selection.failure + ? { diagnostics: { last_api_failure: bundle.selection.failure } } + : {}), + client: { name: 'dedalus-cli', version: VERSION }, + debug: { + included: bundle.attachments.length > 0, + files: bundle.attachments.map((file) => file.manifest), + }, + }; + let body: unknown = metadata; + if (bundle.attachments.length > 0) { + const form = new FormData(); + form.set('metadata', JSON.stringify(metadata)); + for (const file of bundle.attachments) { + form.append( + 'debug_files', + new Blob([new Uint8Array(file.bytes)], { type: file.manifest.format }), + file.manifest.filename, + ); + } + body = form; + } + const result = await client.post('/v1/feedback', { + body, + headers: { 'Idempotency-Key': feedbackIdempotencyKey() }, + }); + await writeOutput(result, { format: options.format ?? 'auto', title: 'Feedback accepted' }); + } catch (error) { + await writeError(error, { format: options.formatError ?? 'auto' }, clientOptions, SDK); + process.exitCode = errorExitCode(error, SDK); + } + }); + if (!program.commands.some((command) => command.name() === 'doctor')) { + program + .command('doctor') + .description('Show local runtime and redacted connectivity information') + .option('--json', 'Print the doctor report as JSON') + .option('--base-url ', 'API base URL to describe') + .action(async (_: unknown, command: Command) => { + const options = command.optsWithGlobals(); + const report = doctorReport( + options.baseUrl ?? process.env['DEDALUS_BASE_URL'] ?? 'https://dcs.dedaluslabs.ai', + VERSION, + ); + await writeOutput(report, { format: options.json ? 'json' : 'pretty', title: 'Doctor' }); + }); + } + return program; +}; +// @custom end diff --git a/src/feedback/diagnostics.ts b/src/feedback/diagnostics.ts new file mode 100644 index 0000000..fb5ecb1 --- /dev/null +++ b/src/feedback/diagnostics.ts @@ -0,0 +1,314 @@ +// @custom start +// Record and select privacy-filtered local diagnostics for feedback submissions. + +import { createHash, randomUUID } from 'node:crypto'; +import { + appendFileSync, + constants, + existsSync, + lstatSync, + mkdirSync, + openSync, + closeSync, + readFileSync, + readdirSync, + statSync, + unlinkSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export const debugDirectory = (): string => join(homedir(), '.dedalus', 'debug'); +const retentionMS = 10 * 24 * 60 * 60 * 1000; +const directoryLimit = 50 * 1024 * 1024; +const partitionLimit = 10 * 1024 * 1024; +const recentMS = 15 * 60 * 1000; +const logName = /^[0-9a-f]{64}\.[0-9]+\.[0-9a-f-]{36}\.jsonl$/; +const receiptPattern = /^[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}$/; +const kinds = [ + 'command_start', + 'request_start', + 'response', + 'transport_failure', + 'command_failure', + 'command_complete', +] as const; +const commandWords = new Set([ + 'dedalus', + 'machines', + 'networks', + 'usage', + 'list', + 'create', + 'retrieve', + 'update', + 'delete', + 'watch', + 'sleep', + 'wake', + 'network', + 'artifacts', + 'ports', + 'ssh', + 'executions', + 'events', + 'output', + 'logs', + 'token', + 'reauthorize', + 'terminals', + 'connect', + 'machine-compute', + 'machine-storage', + 'feedback', +]); + +export type DiagnosticEvent = { + ts: string; + kind: (typeof kinds)[number]; + command: string; + route?: string; + request_id?: string; + status_code?: number; + duration_ms?: number; +}; + +export type DiagnosticSelection = { + events: DiagnosticEvent[]; + command?: string; + receipt?: string; + failure?: { status_code: number; duration_ms: number; route: string }; +}; + +// The credential digest stays in local filenames and is never included in uploads. +// A receipt from another API host, organization override, or API key cannot become a candidate. +export const diagnosticScope = (options: Record): string => + createHash('sha256') + .update( + JSON.stringify([ + options.baseURL ?? process.env['DEDALUS_BASE_URL'] ?? 'https://dcs.dedaluslabs.ai', + options.bearerAuth ?? process.env['DEDALUS_BEARER_AUTH'] ?? '', + options.apiKey ?? process.env['DEDALUS_API_KEY'] ?? '', + options.xAPIKey ?? process.env['DEDALUS_X_API_KEY'] ?? '', + options.dedalusOrgID ?? process.env['DEDALUS_ORG_ID'] ?? '', + process.env['DEDALUS_CUSTOM_HEADERS'] ?? '', + ]), + ) + .digest('hex'); + +const files = (directory: string): { path: string; name: string; size: number; time: number }[] => { + if (!existsSync(directory)) return []; + if (!lstatSync(directory).isDirectory() || lstatSync(directory).isSymbolicLink()) { + throw new Error('diagnostic directory must be a real directory'); + } + return readdirSync(directory) + .filter((name) => logName.test(name)) + .flatMap((name) => { + const path = join(directory, name); + const info = lstatSync(path); + return info.isFile() && !info.isSymbolicLink() + ? [{ path, name, size: info.size, time: info.mtimeMs }] + : []; + }); +}; + +export const pruneDiagnostics = (directory = debugDirectory(), now = Date.now()): void => { + const entries = files(directory).sort((a, b) => a.time - b.time); + let total = entries.reduce((sum, entry) => sum + entry.size, 0); + for (const entry of entries) { + if (entry.time >= now - retentionMS && total <= directoryLimit) continue; + unlinkSync(entry.path); + total -= entry.size; + } +}; + +// Reconstruct only the allowlisted event fields, including when reading local files. +// No request bodies, command arguments, headers, error messages, or environment values enter logs. +const parseEvent = (value: unknown): DiagnosticEvent | undefined => { + if (!value || typeof value !== 'object') return undefined; + const row = value as Record; + if (typeof row['kind'] !== 'string' || !kinds.some((kind) => kind === row['kind'])) + return undefined; + if (typeof row['command'] !== 'string' || !/^dedalus(?: [a-z-]+){1,5}$/.test(row['command'])) + return undefined; + if (row['command'].split(' ').some((word) => !commandWords.has(word))) return undefined; + if (typeof row['ts'] !== 'string' || !Number.isFinite(Date.parse(row['ts']))) return undefined; + const result: DiagnosticEvent = { + ts: new Date(row['ts']).toISOString(), + kind: row['kind'] as DiagnosticEvent['kind'], + command: row['command'], + }; + if ( + typeof row['route'] === 'string' && + /^\/v1(?:\/(?:[a-z-]+|\{[a-z_]+\})){1,9}$/.test(row['route']) + ) + result.route = row['route']; + if (typeof row['request_id'] === 'string' && receiptPattern.test(row['request_id'])) + result.request_id = row['request_id']; + if ( + typeof row['status_code'] === 'number' && + Number.isInteger(row['status_code']) && + row['status_code'] >= 100 && + row['status_code'] <= 599 + ) + result.status_code = row['status_code']; + if ( + typeof row['duration_ms'] === 'number' && + Number.isSafeInteger(row['duration_ms']) && + row['duration_ms'] >= 0 + ) + result.duration_ms = row['duration_ms']; + return result; +}; + +const routeTemplate = (input: RequestInfo | URL): string => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const parts = url.pathname.split('/'); + if (parts[1] !== 'v1' || parts[2] !== 'machines') return '/v1/{resource}'; + if (parts.length > 3) parts[3] = '{machine_id}'; + const resources = new Set([ + 'executions', + 'ssh', + 'ports', + 'artifacts', + 'terminals', + 'sleep', + 'wake', + 'network', + 'status', + ]); + if (parts[4] && !resources.has(parts[4])) return '/v1/machines/{machine_id}/{resource}'; + if (parts[5] && parts[4] !== 'status') parts[5] = '{resource_id}'; + if ( + parts + .slice(6) + .some( + (part) => !['events', 'logs', 'output', 'token', 'reauthorize', 'stream'].includes(part), + ) + ) + return '/v1/machines/{machine_id}/{resource}'; + return parts.join('/'); +}; + +export const createDiagnostics = (command: string, scope: string, directory = debugDirectory()) => { + let count = 0; + let disabled = false; + const path = join(directory, `${scope}.${Date.now()}.${randomUUID()}.jsonl`); + const record = (event: Omit): void => { + if (disabled || count >= 1000) return; + try { + const safe = parseEvent({ ...event, command, ts: new Date().toISOString() }); + if (!safe) throw new Error('invalid diagnostic event'); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (lstatSync(directory).isSymbolicLink()) + throw new Error('diagnostic directory is a symlink'); + const line = JSON.stringify(safe) + '\n'; + if (existsSync(path) && statSync(path).size + Buffer.byteLength(line) > partitionLimit) + return; + const fd = openSync( + path, + constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW, + 0o600, + ); + try { + appendFileSync(fd, line); + } finally { + closeSync(fd); + } + count++; + pruneDiagnostics(directory); + } catch { + disabled = true; + process.stderr.write('Local diagnostic recording is unavailable.\n'); + } + }; + record({ kind: 'command_start' }); + const fetchWithDiagnostics: typeof fetch = async (input, init) => { + const route = routeTemplate(input); + const start = performance.now(); + record({ kind: 'request_start', route }); + try { + const response = await fetch(input, init); + const receipt = response.headers.get('x-request-id'); + record({ + kind: 'response', + route, + status_code: response.status, + duration_ms: Math.max(0, Math.round(performance.now() - start)), + ...(receipt ? { request_id: receipt } : {}), + }); + return response; + } catch (error) { + record({ + kind: 'transport_failure', + route, + duration_ms: Math.max(0, Math.round(performance.now() - start)), + }); + throw error; + } + }; + return { fetch: fetchWithDiagnostics, record }; +}; + +export const selectDiagnostics = ( + scope: string, + directory = debugDirectory(), + now = Date.now(), +): DiagnosticSelection => { + const candidates = files(directory) + .filter( + (file) => + file.name.startsWith(scope + '.') && + file.time >= now - recentMS && + file.size <= partitionLimit, + ) + .sort((a, b) => b.time - a.time); + for (const file of candidates) { + const fd = openSync(file.path, constants.O_RDONLY | constants.O_NOFOLLOW); + let text: string; + try { + text = readFileSync(fd, 'utf8'); + } finally { + closeSync(fd); + } + const events = text + .split('\n') + .filter(Boolean) + .slice(-1000) + .flatMap((line) => { + try { + const event = parseEvent(JSON.parse(line)); + return event && + event.command !== 'dedalus feedback' && + Date.parse(event.ts) >= now - recentMS + ? [event] + : []; + } catch { + return []; + } + }); + const outcome = events.findLast((row) => row.kind === 'command_failure'); + if (!outcome) continue; + const response = events.findLast((row) => row.kind === 'response'); + const receipt = response?.request_id; + return { + events, + command: outcome.command, + ...(receipt ? { receipt } : {}), + ...(response?.status_code !== undefined && + response.status_code >= 400 && + response.duration_ms !== undefined && + response.route + ? { + failure: { + status_code: response.status_code, + duration_ms: response.duration_ms, + route: response.route, + }, + } + : {}), + }; + } + return { events: [] }; +}; +// @custom end diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..46664c3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,9 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { getProgram } from './commands/index'; + +export { getProgram }; + +export const run = async (argv: readonly string[] = process.argv): Promise => { + await getProgram().parseAsync(argv); +}; diff --git a/src/sdk/api-promise.ts b/src/sdk/api-promise.ts new file mode 100644 index 0000000..7ae484c --- /dev/null +++ b/src/sdk/api-promise.ts @@ -0,0 +1,81 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import type { Dedalus } from './client'; +import type { FinalRequestOptions } from './internal/request-options'; + +export type APIResponseProps = { + readonly response: Response; + readonly options: FinalRequestOptions; + readonly controller: AbortController; + readonly requestLogID?: string | undefined; + readonly retryOfRequestLogID?: string | undefined; + readonly startTime?: number | undefined; +}; + +export type ParseResponse = (client: Dedalus, props: APIResponseProps) => T | Promise; + +export const defaultParseResponse = async (_client: unknown, props: APIResponseProps): Promise => { + const { response } = props; + if (response.status === 204) return null as T; + if (props.options.__binaryResponse) return response as T; + const contentType = response.headers.get('content-type'); + const mediaType = contentType?.split(';')[0]?.trim(); + const isJson = mediaType?.includes('application/json') || mediaType?.endsWith('+json'); + if (isJson && response.headers.get('content-length') === '0') return undefined as T; + if (isJson) return (await response.json()) as T; + return (await response.text()) as unknown as T; +}; + +/** A Promise subclass providing SDK response helpers. */ +export class APIPromise extends Promise { + private parsedPromise: Promise | undefined; + + constructor( + private readonly client: Dedalus, + private readonly responsePromise: Promise, + private readonly parseResponse: ParseResponse = defaultParseResponse, + ) { + super((resolve) => { + resolve(undefined as T); + }); + } + + _thenUnwrap(transform: (data: T, props: APIResponseProps) => U): APIPromise { + return new APIPromise(this.client, this.responsePromise, async (client, props) => + transform(await this.parseResponse(client, props), props), + ); + } + + asResponse(): Promise { + return this.responsePromise.then((props) => props.response); + } + + async withResponse(): Promise<{ data: T; response: Response }> { + const [data, response] = await Promise.all([this.parse(), this.asResponse()]); + return { data, response }; + } + + private parse(): Promise { + if (!this.parsedPromise) { + this.parsedPromise = this.responsePromise.then((props) => this.parseResponse(this.client, props)); + } + return this.parsedPromise; + } + + override then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | undefined | null, + ): Promise { + return this.parse().then(onfulfilled, onrejected); + } + + override catch( + onrejected?: ((reason: unknown) => TResult | PromiseLike) | undefined | null, + ): Promise { + return this.parse().catch(onrejected); + } + + override finally(onfinally?: (() => void) | undefined | null): Promise { + return this.parse().finally(onfinally); + } +} diff --git a/src/sdk/client.ts b/src/sdk/client.ts new file mode 100644 index 0000000..05ce6e9 --- /dev/null +++ b/src/sdk/client.ts @@ -0,0 +1,971 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { APIPromise, type APIResponseProps } from './api-promise'; +import { PagePromise, type AbstractPage, type CursorPageParams, CursorPageResponse } from './core/pagination'; +import * as Pagination from './core/pagination'; +import * as Errors from './error'; +import { uuid4 } from './internal/utils/uuid'; +import { validatePositiveInteger, isAbsoluteURL, safeJSON, isEmptyObj } from './internal/utils/values'; +import { sleep } from './internal/utils/sleep'; +import { castToError, isAbortError } from './internal/errors'; +import { getPlatformHeaders } from './internal/detect-platform'; +import * as Shims from './internal/shims'; +import * as Opts from './internal/request-options'; +import { readEnv } from './internal/utils/env'; +import { + formatRequestDetails, + loggerFor, + parseLogLevel, + redactUrl, + type LogLevel, + type Logger, +} from './internal/utils/log'; +export type { Logger, LogLevel } from './internal/utils/log'; +import type { RequestInit, RequestInfo, BodyInit, Fetch } from './internal/builtin-types'; +import { buildHeaders, type HeadersLike, type NullableHeaders } from './internal/headers'; +import type { FinalRequestOptions, RequestOptions } from './internal/request-options'; +import type { HTTPMethod, FinalizedRequestInit, MergedRequestInit, PromiseOrValue } from './internal/types'; +import { stringifyQuery } from './internal/utils/query'; +import { toFile } from './core/uploads'; +import { VERSION } from './version'; +import { + Machines, + type Machine, + type MachineList, + type MachineListItem, + type CreateParams, + type UpdateParams, + type LifecycleStatus, + type MachineListItemsCursorPage, + type MachineRetrieveResponse, + type MachineListParams, + type MachineCreateParams, + type MachineRetrieveParams, + type MachineUpdateParams, + type MachineDeleteParams, + type MachineSleepParams, + type MachineWakeParams, + type MachineRebootParams, +} from './resources/machines/machines'; +import { Organization } from './resources/organization/organization'; + +export type AuthTokenProvider = () => string | Promise; + +export interface ClientOptions { + /** + * Dedalus API key for Bearer token authentication. + */ + apiKey?: string | AuthTokenProvider | null | undefined; + + /** + * Dedalus API key for X-API-Key header authentication. + */ + xAPIKey?: string | AuthTokenProvider | null | undefined; + + /** + * Optional organization assertion. Must match the organization identified by the API credential. + */ + dedalusOrgID?: string | null | undefined; + + /** + * MCP Authorization Server URL. + */ + asBaseURL?: string | null | undefined; + + /** + * Override the default base URL for the API, e.g., "https://api.example.com/v2/" + * + * Defaults to process.env["DEDALUS_BASE_URL"]. + */ + baseURL?: string | null | undefined; + + /** + * The maximum amount of time (in milliseconds) that the client should wait for a response + * from the server before timing out a single request. + * + * Note that request timeouts are retried by default, so in a worst-case scenario you may wait + * much longer than this timeout before the promise succeeds or fails. + * + * @unit milliseconds + */ + timeout?: number | undefined; + + /** + * Additional `RequestInit` options to be passed to `fetch` calls. + * Properties will be overridden by per-request `fetchOptions`. + */ + fetchOptions?: MergedRequestInit | undefined; + + /** + * Specify a custom `fetch` function implementation. + * + * If not provided, we expect that `fetch` is defined globally. + */ + fetch?: Fetch | undefined; + + /** + * The maximum number of times that the client will retry a request in case of a + * temporary failure, like a network error or a 5XX error from the server. + * + * @default 2 + */ + maxRetries?: number | undefined; + + /** + * Default headers to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * header to `null` in request options. + */ + defaultHeaders?: HeadersLike | undefined; + + /** + * Default query parameters to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * param to `undefined` in request options. + */ + defaultQuery?: Record | undefined; + + /** + * Set the log level. + * + * Defaults to process.env["DEDALUS_LOG"] or 'warn' if it isn't set. + */ + logLevel?: LogLevel | undefined; + + /** + * Set the logger. + * + * Defaults to globalThis.console. + */ + logger?: Logger | undefined; +} + +export type DedalusOptions = ClientOptions; + +/** + * API Client for interfacing with the Dedalus API. + */ +export class Dedalus { + apiKey: string | AuthTokenProvider | null; + xAPIKey: string | AuthTokenProvider | null; + dedalusOrgID: string | null; + asBaseURL: string | null; + + baseURL: string; + maxRetries: number; + timeout: number; + logger: Logger; + logLevel: LogLevel | undefined; + fetchOptions: MergedRequestInit | undefined; + private fetch: Fetch; + #encoder: Opts.RequestEncoder; + protected idempotencyHeader?: string; + private _baseURLOverridden: boolean; + private _defaultBaseURL: string; + private _options: ClientOptions; + + /** + * API Client for interfacing with the Dedalus API. + * + * @param {string | AuthTokenProvider | null | undefined} [opts.apiKey=process.env["DEDALUS_API_KEY"] ?? null] + * @param {string | AuthTokenProvider | null | undefined} [opts.xAPIKey=process.env["DEDALUS_X_API_KEY"] ?? null] + * @param {string | null | undefined} [opts.dedalusOrgID=process.env["DEDALUS_ORG_ID"] ?? null] + * @param {string | null | undefined} [opts.asBaseURL=process.env["DEDALUS_AS_URL"] ?? "https://as.dedaluslabs.ai"] + * @param {string} [opts.baseURL=process.env["DEDALUS_BASE_URL"] ?? https://dcs.dedaluslabs.ai] - Override the default base URL for the API. + * @param {number} [opts.timeout=1 minute] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out. + * @param {MergedRequestInit} [opts.fetchOptions] - Additional `RequestInit` options to be passed to `fetch` calls. + * @param {Fetch} [opts.fetch] - Specify a custom `fetch` function implementation. + * @param {number} [opts.maxRetries=2] - The maximum number of times the client will retry a request. + * @param {HeadersLike} opts.defaultHeaders - Default headers to include with every request to the API. + * @param {Record} opts.defaultQuery - Default query parameters to include with every request to the API. + */ + constructor({ + baseURL = readEnv('DEDALUS_BASE_URL'), + apiKey = readEnv('DEDALUS_API_KEY') ?? null, + xAPIKey = readEnv('DEDALUS_X_API_KEY') ?? null, + dedalusOrgID = readEnv('DEDALUS_ORG_ID') ?? null, + asBaseURL = readEnv('DEDALUS_AS_URL') ?? 'https://as.dedaluslabs.ai', + ...opts + }: ClientOptions = {}) { + const options: ClientOptions = { + apiKey, + xAPIKey, + dedalusOrgID, + asBaseURL, + ...opts, + baseURL: baseURL || 'https://dcs.dedaluslabs.ai', + }; + const baseURLOverridden = baseURL !== null && baseURL !== undefined && baseURL !== ''; + const defaultBaseURL = 'https://dcs.dedaluslabs.ai'; + this.baseURL = options.baseURL || defaultBaseURL; + this.timeout = options.timeout ?? Dedalus.DEFAULT_TIMEOUT /* 1 minute */; + this.logger = options.logger ?? console; + const defaultLogLevel = 'warn'; + // Set default logLevel early so that we can log a warning in parseLogLevel. + this.logLevel = defaultLogLevel; + this.logLevel = + parseLogLevel(options.logLevel, 'ClientOptions.logLevel', this) ?? + parseLogLevel(readEnv('DEDALUS_LOG'), 'process.env["DEDALUS_LOG"]', this) ?? + defaultLogLevel; + this.fetchOptions = options.fetchOptions; + this.maxRetries = options.maxRetries ?? 2; + this.fetch = options.fetch ?? Shims.getDefaultFetch(); + this.#encoder = Opts.FallbackEncoder; + + const customHeadersEnv = readEnv('DEDALUS_CUSTOM_HEADERS'); + if (customHeadersEnv) { + const parsed: Record = {}; + for (const line of customHeadersEnv.split('\n')) { + const colon = line.indexOf(':'); + if (colon >= 0) { + parsed[line.substring(0, colon).trim()] = line.substring(colon + 1).trim(); + } + } + options.defaultHeaders = { ...parsed, ...options.defaultHeaders }; + } + + this._options = { ...options, baseURL: baseURLOverridden ? this.baseURL : undefined }; + this._baseURLOverridden = baseURLOverridden; + this._defaultBaseURL = defaultBaseURL; + this.idempotencyHeader = 'Idempotency-Key'; + + this.apiKey = apiKey; + this.xAPIKey = xAPIKey; + this.dedalusOrgID = dedalusOrgID; + this.asBaseURL = asBaseURL; + } + + withOptions(options: Partial): this { + const client = new (this.constructor as new (props: ClientOptions) => this)({ + ...this._options, + ...(this.#baseURLOverridden() ? { baseURL: this.baseURL } : {}), + maxRetries: this.maxRetries, + timeout: this.timeout, + logger: this.logger, + logLevel: this.logLevel, + fetch: this.fetch, + fetchOptions: this.fetchOptions, + apiKey: this.apiKey, + xAPIKey: this.xAPIKey, + dedalusOrgID: this.dedalusOrgID, + asBaseURL: this.asBaseURL, + ...options, + }); + return client; + } + + #baseURLOverridden(): boolean { + // A named environment selects a default URL; only explicit overrides should bypass per-request defaults. + return this._baseURLOverridden || this.baseURL !== this._defaultBaseURL; + } + + protected defaultQuery(): Record | undefined { + return this._options.defaultQuery; + } + + protected stringifyQuery(query: object | Record): string { + return stringifyQuery(query); + } + + private getUserAgent(): string { + return `Dedalus/JS ${VERSION}`; + } + + protected defaultIdempotencyKey(): string { + // @custom start + // Use the API's UUIDv7 format. Automatic retries reuse this value. + return `${Date.now().toString(16).padStart(12, '0')}7${uuid4().replace(/-/g, '').slice(13)}`; + // @custom end + } + + protected makeStatusError( + status: number, + error: object | undefined, + message: string | undefined, + headers: Headers, + ): Errors.APIError { + return Errors.APIError.generate(status, error, message, headers); + } + + buildURL( + path: string, + query: Record | null | undefined, + defaultBaseURL?: string | undefined, + ): string { + const baseURL = (!this.#baseURLOverridden() && defaultBaseURL) || this.baseURL; + // Guarantee exactly one "/" between baseURL and path so that bases without a trailing slash + // and paths without a leading slash do not fuse into a malformed URL (e.g. ".../v1" + "widgets"). + const url = isAbsoluteURL(path) + ? new URL(path) + : new URL( + (baseURL.endsWith('/') ? baseURL : baseURL + '/') + (path.startsWith('/') ? path.slice(1) : path), + ); + + const defaultQuery = this.defaultQuery(); + const pathQuery = Object.fromEntries(url.searchParams); + if (!isEmptyObj(defaultQuery) || !isEmptyObj(pathQuery)) { + query = { ...pathQuery, ...defaultQuery, ...query }; + } + + if (typeof query === 'object' && query && !Array.isArray(query)) { + url.search = this.stringifyQuery(query); + } + + return url.toString(); + } + + /** + * Used as a callback for mutating the given `FinalRequestOptions` object. + */ + protected async prepareOptions(options: FinalRequestOptions): Promise {} + + /** + * Used as a callback for mutating the given `RequestInit` object. + * + * This is useful for cases where you want to add certain headers based off of + * the request properties, e.g. `method` or `url`. + */ + protected async prepareRequest( + request: RequestInit, + { url, options }: { url: string; options: FinalRequestOptions }, + ): Promise {} + + get(path: string, opts?: PromiseOrValue): APIPromise { + return this.methodRequest('get', path, opts); + } + + post(path: string, opts?: PromiseOrValue): APIPromise { + return this.methodRequest('post', path, opts); + } + + patch(path: string, opts?: PromiseOrValue): APIPromise { + return this.methodRequest('patch', path, opts); + } + + put(path: string, opts?: PromiseOrValue): APIPromise { + return this.methodRequest('put', path, opts); + } + + delete(path: string, opts?: PromiseOrValue): APIPromise { + return this.methodRequest('delete', path, opts); + } + + private methodRequest( + method: HTTPMethod, + path: string, + opts?: PromiseOrValue, + ): APIPromise { + return this.request( + Promise.resolve(opts).then((opts) => { + return { method, path, ...opts } as FinalRequestOptions; + }), + ); + } + + request( + options: PromiseOrValue, + remainingRetries: number | null = null, + ): APIPromise { + return new APIPromise(this, this.makeRequest(options, remainingRetries, undefined)); + } + + private async makeRequest( + optionsInput: PromiseOrValue, + retriesRemaining: number | null, + retryOfRequestLogID: string | undefined, + ): Promise { + const options = await optionsInput; + const maxRetries = options.maxRetries ?? this.maxRetries; + if (retriesRemaining == null) { + retriesRemaining = maxRetries; + } + + await this.prepareOptions(options); + + const { req, url, timeout } = await this.buildRequest(options, { + retryCount: maxRetries - retriesRemaining, + }); + + await this.prepareRequest(req, { url, options }); + + /** Not an API request ID, just for correlating local log entries. */ + const requestLogID = 'log_' + ((Math.random() * (1 << 24)) | 0).toString(16).padStart(6, '0'); + const retryLogStr = retryOfRequestLogID === undefined ? '' : `, retryOf: ${retryOfRequestLogID}`; + const startTime = Date.now(); + + loggerFor(this).debug( + `[${requestLogID}] sending request`, + formatRequestDetails({ + retryOfRequestLogID, + method: options.method, + url, + options, + headers: req.headers, + }), + ); + + if (options.signal?.aborted) { + throw new Errors.APIUserAbortError(); + } + + const controller = new AbortController(); + const response = await this.fetchWithTimeout(url, req, timeout, controller).catch(castToError); + const headersTime = Date.now(); + + if (response instanceof globalThis.Error) { + const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; + if (options.signal?.aborted) { + throw new Errors.APIUserAbortError(); + } + // detect native connection timeout errors + // deno throws "TypeError: error sending request for url (https://example/): client error (Connect): tcp connect error: Operation timed out (os error 60): Operation timed out (os error 60)" + // undici throws "TypeError: fetch failed" with cause "ConnectTimeoutError: Connect Timeout Error (attempted address: example:443, timeout: 1ms)" + // others do not provide enough information to distinguish timeouts from other connection errors + const isTimeout = + isAbortError(response) || + /timed? ?out/i.test(String(response) + ('cause' in response ? String(response.cause) : '')); + if (retriesRemaining) { + loggerFor(this).info( + `[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - ${retryMessage}`, + ); + loggerFor(this).debug( + `[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (${retryMessage})`, + formatRequestDetails({ + retryOfRequestLogID, + url, + durationMs: headersTime - startTime, + message: response.message, + }), + ); + return this.retryRequest(options, retriesRemaining, retryOfRequestLogID ?? requestLogID); + } + loggerFor(this).info( + `[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - error; no more retries left`, + ); + loggerFor(this).debug( + `[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (error; no more retries left)`, + formatRequestDetails({ + retryOfRequestLogID, + url, + durationMs: headersTime - startTime, + message: response.message, + }), + ); + if (isTimeout) { + throw new Errors.APIConnectionTimeoutError(); + } + throw new Errors.APIConnectionError({ cause: response }); + } + + const responseInfo = `[${requestLogID}${retryLogStr}] ${req.method} ${redactUrl(url)} ${ + response.ok ? 'succeeded' : 'failed' + } with status ${response.status} in ${headersTime - startTime}ms`; + + if (!response.ok) { + const shouldRetry = await this.shouldRetry(response); + if (retriesRemaining && shouldRetry) { + const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; + + // We don't need the body of this response. + await Shims.CancelReadableStream(response.body); + loggerFor(this).info(`${responseInfo} - ${retryMessage}`); + loggerFor(this).debug( + `[${requestLogID}] response error (${retryMessage})`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + headers: response.headers, + durationMs: headersTime - startTime, + }), + ); + return this.retryRequest( + options, + retriesRemaining, + retryOfRequestLogID ?? requestLogID, + response.headers, + ); + } + + const retryMessage = shouldRetry ? `error; no more retries left` : `error; not retryable`; + + loggerFor(this).info(`${responseInfo} - ${retryMessage}`); + + const errText = await response.text().catch((err: any) => castToError(err).message); + const errJSON = safeJSON(errText) as any; + const errMessage = errJSON ? undefined : errText; + + loggerFor(this).debug( + `[${requestLogID}] response error (${retryMessage})`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + headers: response.headers, + message: errMessage, + durationMs: Date.now() - startTime, + }), + ); + + const err = this.makeStatusError(response.status, errJSON, errMessage, response.headers); + throw err; + } + + loggerFor(this).info(responseInfo); + loggerFor(this).debug( + `[${requestLogID}] response start`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + headers: response.headers, + durationMs: headersTime - startTime, + }), + ); + + return { response, options, controller, requestLogID, retryOfRequestLogID, startTime }; + } + + // Public escape hatch for list endpoints the spec does not describe: the page type parameter + // defaults so `getAPIList(path, MyPage)` needs only the item type, and the page argument + // is a bare constructor so a hand-rolled page class need not restate the runtime page + // constructor's parameter types. + getAPIList = AbstractPage>( + path: string, + Page: new (...args: any[]) => Page, + options?: PromiseOrValue, + method: FinalRequestOptions['method'] = 'get', + ): PagePromise { + // List endpoints are usually GET, but a body-located cursor scheme rides a POST body, so the + // caller passes the operation's actual verb. The method is preserved across auto-advanced + // pages because `nextPageRequestOptions` spreads the stored request options (incl. `method`). + const requestOptions = Promise.resolve(options).then((opts) => ({ ...opts, method, path })); + return this.requestAPIList(Page, requestOptions); + } + + requestAPIList = AbstractPage>( + Page: new (...args: ConstructorParameters) => Page, + options: PromiseOrValue, + ): PagePromise { + // `Item` is passed explicitly because the page constructor carries no slot to infer it from. + return new PagePromise(this, this.makeRequest(options, null, undefined), Page); + } + + async fetchWithTimeout( + url: RequestInfo, + init: RequestInit | undefined, + ms: number, + controller: AbortController, + ): Promise { + const { signal, method, ...options } = init || {}; + const abort = this._makeAbort(controller); + if (signal?.aborted) abort(); + else if (signal) signal.addEventListener('abort', abort, { once: true }); + + const timeout = setTimeout(abort, ms); + + const isReadableBody = + ((globalThis as any).ReadableStream && options.body instanceof (globalThis as any).ReadableStream) || + (typeof options.body === 'object' && options.body !== null && Symbol.asyncIterator in options.body); + + const fetchOptions: RequestInit = { + signal: controller.signal as any, + ...(isReadableBody ? { duplex: 'half' } : {}), + method: 'GET', + ...options, + }; + if (method) { + // Custom methods like 'patch' need to be uppercased + // See https://github.com/nodejs/undici/issues/2294 + fetchOptions.method = method.toUpperCase(); + } + + try { + // use undefined this binding; fetch errors if bound to something else in browser/cloudflare + return await this.fetch.call(undefined, url, fetchOptions); + } finally { + clearTimeout(timeout); + } + } + + private async shouldRetry(response: Response): Promise { + // Note this is not a standard header. + const shouldRetryHeader = response.headers.get('x-should-retry'); + + // If the server explicitly says whether or not to retry, obey. + if (shouldRetryHeader === 'true') return true; + if (shouldRetryHeader === 'false') return false; + + // Retry on request timeouts. + if (response.status === 408) return true; + + // Retry on lock timeouts. + if (response.status === 409) return true; + + // Retry on rate limits. + if (response.status === 429) return true; + + // Retry internal errors. + if (response.status >= 500) return true; + + return false; + } + + private async retryRequest( + options: FinalRequestOptions, + retriesRemaining: number, + requestLogID: string, + responseHeaders?: Headers | undefined, + ): Promise { + let timeoutMillis: number | undefined; + + // Note the `retry-after-ms` header may not be standard, but is a good idea and we'd like proactive support for it. + const retryAfterMillisHeader = responseHeaders?.get('retry-after-ms'); + if (retryAfterMillisHeader) { + const timeoutMs = parseFloat(retryAfterMillisHeader); + if (!Number.isNaN(timeoutMs)) { + timeoutMillis = timeoutMs; + } + } + + // About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After + const retryAfterHeader = responseHeaders?.get('retry-after'); + if (retryAfterHeader && !timeoutMillis) { + const timeoutSeconds = parseFloat(retryAfterHeader); + if (!Number.isNaN(timeoutSeconds)) { + timeoutMillis = timeoutSeconds * 1000; + } else { + timeoutMillis = Date.parse(retryAfterHeader) - Date.now(); + } + } + + // If the API asks us to wait a certain amount of time, just do what it says, + // but cap server-provided delays at 60s so an oversized or malformed Retry-After + // (e.g. `retry-after-ms: 999999999`, a past HTTP-date, or a value that Date.parse + // failed on) cannot block retries for an unbounded amount of time. Otherwise fall + // back to the default exponential-backoff calculation. + const maxRetryAfterMillis = 60 * 1000; + if ( + timeoutMillis === undefined || + !Number.isFinite(timeoutMillis) || + timeoutMillis <= 0 || + timeoutMillis > maxRetryAfterMillis + ) { + const maxRetries = options.maxRetries ?? this.maxRetries; + timeoutMillis = this.calculateDefaultRetryTimeoutMillis(retriesRemaining, maxRetries); + } + await sleep(timeoutMillis); + + return this.makeRequest(options, retriesRemaining - 1, requestLogID); + } + + private calculateDefaultRetryTimeoutMillis(retriesRemaining: number, maxRetries: number): number { + const initialRetryDelay = 0.5; + const maxRetryDelay = 8.0; + + const numRetries = maxRetries - retriesRemaining; + + // Apply exponential backoff, but not more than the max. + const sleepSeconds = Math.min(initialRetryDelay * Math.pow(2, numRetries), maxRetryDelay); + + // Apply some jitter, take up to at most 25 percent of the retry time. + const jitter = 1 - Math.random() * 0.25; + + return sleepSeconds * jitter * 1000; + } + + async buildRequest( + inputOptions: FinalRequestOptions, + { retryCount = 0 }: { retryCount?: number } = {}, + ): Promise<{ req: FinalizedRequestInit; url: string; timeout: number }> { + const options = { ...inputOptions }; + const { method, path, query, defaultBaseURL } = options; + + const url = this.buildURL(path!, query as Record, defaultBaseURL); + if ('timeout' in options) validatePositiveInteger('timeout', options.timeout); + options.timeout = options.timeout ?? this.timeout; + const { bodyHeaders, body } = this.buildBody({ options }); + // Headers read the caller's own options, not the copy defaulted above: `X-Scalar-Timeout` + // reports an explicit per-request timeout, and the idempotency key written back here has to + // land where the retry can see it. + const reqHeaders = await this.buildHeaders({ + options: inputOptions, + method, + bodyHeaders, + retryCount, + url, + }); + + const req: FinalizedRequestInit = { + method, + headers: reqHeaders, + ...(options.signal && { signal: options.signal }), + ...((globalThis as any).ReadableStream && + body instanceof (globalThis as any).ReadableStream && { duplex: 'half' }), + // `buildBody` already collapses no-body into `undefined`; here we only need to drop that + // sentinel. A truthiness spread would also strip an intentional empty-string body. + ...(body !== undefined && { body }), + ...((this.fetchOptions as any) ?? {}), + ...((options.fetchOptions as any) ?? {}), + }; + return { req, url, timeout: options.timeout }; + } + + private async buildHeaders({ + options, + method, + bodyHeaders, + retryCount, + url, + }: { + options: FinalRequestOptions; + method: HTTPMethod; + bodyHeaders: HeadersLike; + retryCount: number; + url: string; + }): Promise { + let idempotencyHeaders: HeadersLike = {}; + if (this.idempotencyHeader && method !== 'get') { + if (!options.idempotencyKey) options.idempotencyKey = this.defaultIdempotencyKey(); + idempotencyHeaders[this.idempotencyHeader] = options.idempotencyKey; + } + + const headers = buildHeaders([ + idempotencyHeaders, + { + Accept: 'application/json', + 'User-Agent': this.getUserAgent(), + 'X-Scalar-Retry-Count': String(retryCount), + ...(options.timeout ? { 'X-Scalar-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}), + ...getPlatformHeaders(), + ...{ 'X-SDK-Version': '1.0.0' }, + 'X-Dedalus-Org-Id': this.dedalusOrgID, + }, + await this.authHeaders(options), + this._options.defaultHeaders, + bodyHeaders, + options.headers, + ]); + appendAuthCookies(headers.values, await this.authCookiesAsync()); + + this.validateAuth(url, headers.values, options); + + return headers.values; + } + + private _makeAbort(controller: AbortController) { + // note: we can't just inline this method inside `fetchWithTimeout()` because then the closure + // would capture all request options, and cause a memory leak. + return () => controller.abort(); + } + + private buildBody({ options: { body, headers: rawHeaders } }: { options: FinalRequestOptions }): { + bodyHeaders: HeadersLike; + body: BodyInit | undefined; + } { + // Skip only `null`/`undefined` so an intentional empty-string (or 0/false) payload still + // reaches the encoder. A plain `!body` check would silently drop those falsy-but-valid bodies, + // and `null` must be excluded here too because the iterator check below uses `in`, which + // throws on null. + if (body == null) { + return { bodyHeaders: undefined, body: undefined }; + } + const headers = buildHeaders([rawHeaders]); + if ( + // Pass raw type verbatim + ArrayBuffer.isView(body) || + body instanceof ArrayBuffer || + body instanceof DataView || + // Always pass strings through verbatim. The previous guard required a caller-set + // `content-type` and otherwise fell through to `FallbackEncoder`, which JSON.stringifies + // the value and labels it `application/json` — silently quoting plain-text payloads and + // mislabeling them as JSON. fetch defaults a string body to `text/plain;charset=UTF-8` + // when no `content-type` is set, which is a safer default than misclaiming JSON. + typeof body === 'string' || + // `Blob` is superset of `File` + ((globalThis as any).Blob && body instanceof (globalThis as any).Blob) || + // `FormData` -> `multipart/form-data` + body instanceof FormData || + // `URLSearchParams` -> `application/x-www-form-urlencoded` + body instanceof URLSearchParams || + // Send chunked stream (each chunk has own `length`) + ((globalThis as any).ReadableStream && body instanceof (globalThis as any).ReadableStream) + ) { + return { bodyHeaders: undefined, body: body as BodyInit }; + } else if ( + typeof body === 'object' && + (Symbol.asyncIterator in body || + (Symbol.iterator in body && 'next' in body && typeof body.next === 'function')) + ) { + return { bodyHeaders: undefined, body: Shims.ReadableStreamFrom(body as AsyncIterable) }; + } else if ( + typeof body === 'object' && + headers.values.get('content-type') === 'application/x-www-form-urlencoded' + ) { + return { + bodyHeaders: { 'content-type': 'application/x-www-form-urlencoded' }, + body: this.stringifyQuery(body), + }; + } else { + return this.#encoder({ body, headers }); + } + } + + protected validateAuth(url: string, headers: Headers, options: FinalRequestOptions): void { + if (headers.has('Authorization')) return; + if (headerExplicitlyOmitted(options.headers, 'Authorization')) return; + if (headers.has('x-api-key')) return; + if (headerExplicitlyOmitted(options.headers, 'x-api-key')) return; + throw new Errors.AuthenticationError( + 401, + undefined, + 'Could not resolve authentication method. Expected either apiKey or xAPIKey to be set. Or for one of the "Authorization" or "x-api-key" headers to be explicitly omitted', + headers, + ); + } + + authHeadersSync(): Record { + const headers: Record = {}; + const apiKey = this.resolveAuthOptionSync('apiKey', this.apiKey); + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; + const xAPIKey = this.resolveAuthOptionSync('xAPIKey', this.xAPIKey); + if (xAPIKey) headers['x-api-key'] = xAPIKey; + return headers; + } + + webSocketAuthHeaders(): Record { + const apiKey = this.resolveAuthOptionSync('apiKey', this.apiKey); + if (apiKey) return { Authorization: `Bearer ${apiKey}` }; + const xAPIKey = this.resolveAuthOptionSync('xAPIKey', this.xAPIKey); + if (xAPIKey) return { 'x-api-key': xAPIKey }; + return {}; + } + + protected async authHeaders(opts: FinalRequestOptions): Promise { + return buildHeaders([await this.bearerAuth(opts), await this.apiKeyAuth(opts)]); + } + + protected async bearerAuth(opts: FinalRequestOptions): Promise { + const apiKey = await this.resolveAuthOption('apiKey', this.apiKey); + if (apiKey == null) { + return undefined; + } + return buildHeaders([{ Authorization: `Bearer ${apiKey}` }]); + } + + protected async apiKeyAuth(opts: FinalRequestOptions): Promise { + const xAPIKey = await this.resolveAuthOption('xAPIKey', this.xAPIKey); + if (xAPIKey == null) { + return undefined; + } + return buildHeaders([{ 'x-api-key': xAPIKey }]); + } + + private async authQueryAsync(): Promise> { + const query: Record = {}; + return query; + } + + private async authCookiesAsync(): Promise> { + const cookies: Record = {}; + return cookies; + } + + private async resolveAuthOption( + optionName: string, + value: string | AuthTokenProvider | null | undefined, + ): Promise { + if (value == null) return undefined; + const token = typeof value === 'function' ? await value() : value; + if (!token) throw new Errors.DedalusError(`Expected '${optionName}' to resolve to a non-empty string.`); + return token; + } + + private resolveAuthOptionSync( + optionName: string, + value: string | AuthTokenProvider | null | undefined, + ): string | undefined { + if (value == null) return undefined; + const token = typeof value === 'function' ? value() : value; + if (typeof token !== 'string' || !token) + throw new Errors.DedalusError(`Expected '${optionName}' to resolve to a non-empty string.`); + return token; + } + + static Dedalus = this; + static DEFAULT_TIMEOUT = 60000; // 1 minute + + static DedalusError = Errors.DedalusError; + static APIError = Errors.APIError; + static APIConnectionError = Errors.APIConnectionError; + static APIConnectionTimeoutError = Errors.APIConnectionTimeoutError; + static APIUserAbortError = Errors.APIUserAbortError; + static NotFoundError = Errors.NotFoundError; + static ConflictError = Errors.ConflictError; + static RateLimitError = Errors.RateLimitError; + static BadRequestError = Errors.BadRequestError; + static AuthenticationError = Errors.AuthenticationError; + static InternalServerError = Errors.InternalServerError; + static PermissionDeniedError = Errors.PermissionDeniedError; + static UnprocessableEntityError = Errors.UnprocessableEntityError; + + static toFile = toFile; + + machines: Machines = new Machines(this); + organization: Organization = new Organization(this); +} + +Dedalus.Machines = Machines; +Dedalus.Organization = Organization; + +export declare namespace Dedalus { + export type RequestOptions = Opts.RequestOptions; + + export import CursorPage = Pagination.CursorPage; + export { type CursorPageParams as CursorPageParams, type CursorPageResponse as CursorPageResponse }; + + export { + Machines as Machines, + type Machine as Machine, + type MachineList as MachineList, + type MachineListItem as MachineListItem, + type CreateParams as CreateParams, + type UpdateParams as UpdateParams, + type LifecycleStatus as LifecycleStatus, + type MachineListItemsCursorPage as MachineListItemsCursorPage, + type MachineRetrieveResponse as MachineRetrieveResponse, + type MachineListParams as MachineListParams, + type MachineCreateParams as MachineCreateParams, + type MachineRetrieveParams as MachineRetrieveParams, + type MachineUpdateParams as MachineUpdateParams, + type MachineDeleteParams as MachineDeleteParams, + type MachineSleepParams as MachineSleepParams, + type MachineWakeParams as MachineWakeParams, + type MachineRebootParams as MachineRebootParams, + }; + + export { Organization as Organization }; +} + +const headerExplicitlyOmitted = (source: HeadersLike | undefined, name: string): boolean => { + if (!source || Array.isArray(source) || source instanceof Headers) return false; + const target = name.toLowerCase(); + return Object.entries(source).some(([key, value]) => key.toLowerCase() === target && value === null); +}; + +const appendAuthCookies = (headers: Headers, cookies: Record): void => { + for (const [name, value] of Object.entries(cookies)) { + if (cookieHeaderHas(headers.get('Cookie'), name)) continue; + const cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value); + const existing = headers.get('Cookie'); + headers.set('Cookie', existing ? existing + '; ' + cookie : cookie); + } +}; + +const cookieHeaderHas = (value: string | null, name: string): boolean => { + if (!value) return false; + const target = encodeURIComponent(name) + '='; + return value.split(';').some((cookie) => cookie.trim().startsWith(target)); +}; diff --git a/src/sdk/core/api-promise.ts b/src/sdk/core/api-promise.ts new file mode 100644 index 0000000..c2994e2 --- /dev/null +++ b/src/sdk/core/api-promise.ts @@ -0,0 +1,4 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export { APIPromise, defaultParseResponse } from '../api-promise'; +export type { APIResponseProps, ParseResponse } from '../api-promise'; diff --git a/src/sdk/core/error.ts b/src/sdk/core/error.ts new file mode 100644 index 0000000..847319b --- /dev/null +++ b/src/sdk/core/error.ts @@ -0,0 +1,146 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { castToError } from '../internal/errors'; + +export class DedalusError extends Error {} + +export class APIError< + TStatus extends number | undefined = number | undefined, + THeaders extends Headers | undefined = Headers | undefined, + TError extends Object | undefined = Object | undefined, +> extends DedalusError { + /** HTTP status for the response that caused the error */ + readonly status: TStatus; + /** HTTP headers for the response that caused the error */ + readonly headers: THeaders; + /** JSON body of the response that caused the error */ + readonly error: TError; + + constructor(status: TStatus, error: TError, message: string | undefined, headers: THeaders) { + super(`${APIError.makeMessage(status, error, message)}`); + this.status = status; + this.headers = headers; + this.error = error; + } + + private static makeMessage(status: number | undefined, error: any, message: string | undefined) { + const msg = error?.message + ? typeof error.message === 'string' + ? error.message + : JSON.stringify(error.message) + : error + ? JSON.stringify(error) + : message; + + if (status && msg) { + return `${status} ${msg}`; + } + if (status) { + return `${status} status code (no body)`; + } + if (msg) { + return msg; + } + return '(no status code or body)'; + } + + static generate( + status: number | undefined, + errorResponse: Object | undefined, + message: string | undefined, + headers: Headers | undefined, + ): APIError { + if (!status || !headers) { + return new APIConnectionError({ message, cause: castToError(errorResponse) }); + } + + const error = errorResponse as Record; + + if (status === 400) { + return new BadRequestError(status, error, message, headers); + } + + if (status === 401) { + return new AuthenticationError(status, error, message, headers); + } + + if (status === 403) { + return new PermissionDeniedError(status, error, message, headers); + } + + if (status === 404) { + return new NotFoundError(status, error, message, headers); + } + + if (status === 409) { + return new ConflictError(status, error, message, headers); + } + + if (status === 422) { + return new UnprocessableEntityError(status, error, message, headers); + } + + if (status === 429) { + return new RateLimitError(status, error, message, headers); + } + + if (status >= 500) { + return new InternalServerError(status, error, message, headers); + } + + return new APIError(status, error, message, headers); + } +} + +export class APIUserAbortError extends APIError { + constructor({ message }: { message?: string } = {}) { + super(undefined, undefined, message || 'Request was aborted.', undefined); + } +} + +export class APIConnectionError extends APIError { + constructor({ message, cause }: { message?: string | undefined; cause?: Error | undefined }) { + super(undefined, undefined, message || 'Connection error.', undefined); + // in some environments the 'cause' property is already declared + // @ts-ignore + if (cause) this.cause = cause; + } +} + +export class APIConnectionTimeoutError extends APIConnectionError { + constructor({ message }: { message?: string } = {}) { + super({ message: message ?? 'Request timed out.' }); + } +} + +export class BadRequestError extends APIError<400, Headers> {} + +export class AuthenticationError extends APIError<401, Headers> {} + +export class PermissionDeniedError extends APIError<403, Headers> {} + +export class NotFoundError extends APIError<404, Headers> {} + +export class ConflictError extends APIError<409, Headers> {} + +export class UnprocessableEntityError extends APIError<422, Headers> {} + +export class RateLimitError extends APIError<429, Headers> {} + +export class InternalServerError extends APIError {} + +// Each class names itself, so a caught error reports its real class rather than the inherited +// `Error` — what a log line, a `switch (error.name)`, and an error reporter grouping by name read. +DedalusError.prototype.name = 'DedalusError'; +APIError.prototype.name = 'APIError'; +APIUserAbortError.prototype.name = 'APIUserAbortError'; +APIConnectionError.prototype.name = 'APIConnectionError'; +APIConnectionTimeoutError.prototype.name = 'APIConnectionTimeoutError'; +BadRequestError.prototype.name = 'BadRequestError'; +AuthenticationError.prototype.name = 'AuthenticationError'; +PermissionDeniedError.prototype.name = 'PermissionDeniedError'; +NotFoundError.prototype.name = 'NotFoundError'; +ConflictError.prototype.name = 'ConflictError'; +UnprocessableEntityError.prototype.name = 'UnprocessableEntityError'; +RateLimitError.prototype.name = 'RateLimitError'; +InternalServerError.prototype.name = 'InternalServerError'; diff --git a/src/sdk/core/pagination.ts b/src/sdk/core/pagination.ts new file mode 100644 index 0000000..b9c0e5e --- /dev/null +++ b/src/sdk/core/pagination.ts @@ -0,0 +1,139 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { APIPromise, defaultParseResponse, type APIResponseProps } from '../api-promise'; +import type { Dedalus } from '../client'; +import { DedalusError } from './error'; +import type { FinalRequestOptions } from '../internal/request-options'; +import { maybeObj } from '../internal/utils/values'; + +export type PageRequestOptions = Pick; + +export abstract class AbstractPage implements AsyncIterable { + protected readonly client: Dedalus; + protected readonly options: FinalRequestOptions; + protected readonly response: Response; + protected readonly body: unknown; + + constructor(client: Dedalus, response: Response, body: unknown, options: FinalRequestOptions) { + this.client = client; + this.response = response; + this.body = body; + // An idempotency key belongs to the one request it was sent with. A page only ever uses these + // options to build the *next* request, so the key is dropped here and each page gets its own. + const { idempotencyKey: _idempotencyKey, ...pageOptions } = options; + this.options = pageOptions; + } + + abstract nextPageRequestOptions(): PageRequestOptions | null; + abstract getPaginatedItems(): readonly Item[]; + + hasNextPage(): boolean { + const items = this.getPaginatedItems(); + if (!items.length) return false; + return this.nextPageRequestOptions() !== null; + } + + async getNextPage(): Promise { + const nextOptions = this.nextPageRequestOptions(); + if (!nextOptions) + throw new DedalusError( + 'No next page expected; please check `.hasNextPage()` before calling `.getNextPage()`.', + ); + return await this.client.requestAPIList(this.constructor as PageConstructor, nextOptions); + } + + async *iterPages(): AsyncGenerator { + let page: this = this; + yield page; + while (page.hasNextPage()) { + page = await page.getNextPage(); + yield page; + } + } + + async *[Symbol.asyncIterator](): AsyncGenerator { + for await (const page of this.iterPages()) { + for (const item of page.getPaginatedItems()) yield item; + } + } +} + +export type PageConstructor, Item> = new ( + client: Dedalus, + response: Response, + body: unknown, + options: FinalRequestOptions, +) => Page; + +export class PagePromise< + Page extends AbstractPage, + Item = ReturnType[number], + > + extends APIPromise + implements AsyncIterable +{ + constructor(client: Dedalus, request: Promise, Page: PageConstructor) { + super( + client, + request, + async (client, props) => + new Page(client, props.response, await defaultParseResponse(client, props), props.options), + ); + } + + async *[Symbol.asyncIterator](): AsyncGenerator { + const page = await this; + for await (const item of page) yield item; + } +} + +export interface CursorPageParams { + cursor?: string; + + limit?: number; +} + +export interface CursorPageResponse { + items: Array; + next_cursor: string | null; +} + +export class CursorPage extends AbstractPage implements CursorPageResponse { + items: Array; + + next_cursor: string | null; + + constructor(client: Dedalus, response: Response, body: unknown, options: FinalRequestOptions) { + super(client, response, body, options); + const parsed = body as CursorPageResponse; + + this.items = parsed.items || []; + this.next_cursor = parsed.next_cursor ?? null; + } + + getPaginatedItems(): Item[] { + const items = this.items; + return Array.isArray(items) ? items : []; + } + + nextPageRequestOptions(): PageRequestOptions | null { + const cursor = this.next_cursor; + if (!hasNextCursor(cursor)) { + return null; + } + + return { + ...this.options, + query: { + ...maybeObj(this.options.query), + cursor: cursor, + }, + }; + } +} + +const hasNextCursor = (cursor: unknown): boolean => { + if (typeof cursor === 'string') return cursor.length > 0; + if (typeof cursor === 'number') return Number.isFinite(cursor); + return false; +}; diff --git a/src/sdk/core/resource.ts b/src/sdk/core/resource.ts new file mode 100644 index 0000000..b71ddf0 --- /dev/null +++ b/src/sdk/core/resource.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export { APIResource } from '../resource'; diff --git a/src/sdk/core/uploads.ts b/src/sdk/core/uploads.ts new file mode 100644 index 0000000..536cfe6 --- /dev/null +++ b/src/sdk/core/uploads.ts @@ -0,0 +1,4 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export { type Uploadable } from '../internal/uploads'; +export { toFile, type ToFileInput } from '../internal/to-file'; diff --git a/src/sdk/error.ts b/src/sdk/error.ts new file mode 100644 index 0000000..834f539 --- /dev/null +++ b/src/sdk/error.ts @@ -0,0 +1,4 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +/** @deprecated Import from ./core/error instead */ +export * from './core/error'; diff --git a/src/sdk/index.ts b/src/sdk/index.ts new file mode 100644 index 0000000..d2830b3 --- /dev/null +++ b/src/sdk/index.ts @@ -0,0 +1,23 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export { Dedalus as default } from './client.js'; + +export { type Uploadable, toFile } from './core/uploads'; +export { APIPromise } from './api-promise'; +export { PagePromise } from './core/pagination'; +export { Dedalus, type ClientOptions, type AuthTokenProvider } from './client.js'; +export { + DedalusError, + APIError, + APIConnectionError, + APIConnectionTimeoutError, + APIUserAbortError, + NotFoundError, + ConflictError, + RateLimitError, + BadRequestError, + AuthenticationError, + InternalServerError, + PermissionDeniedError, + UnprocessableEntityError, +} from './error'; diff --git a/src/sdk/internal/README.md b/src/sdk/internal/README.md new file mode 100644 index 0000000..3ef5a25 --- /dev/null +++ b/src/sdk/internal/README.md @@ -0,0 +1,3 @@ +# `internal` + +The modules in this directory are not importable outside this package and will change between releases. diff --git a/src/sdk/internal/builtin-types.ts b/src/sdk/internal/builtin-types.ts new file mode 100644 index 0000000..40d03c4 --- /dev/null +++ b/src/sdk/internal/builtin-types.ts @@ -0,0 +1,93 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise; + +/** + * An alias to the builtin `RequestInit` type so we can + * easily alias it in import statements if there are name clashes. + * + * https://developer.mozilla.org/docs/Web/API/RequestInit + */ +type _RequestInit = RequestInit; + +/** + * An alias to the builtin `Response` type so we can + * easily alias it in import statements if there are name clashes. + * + * https://developer.mozilla.org/docs/Web/API/Response + */ +type _Response = Response; + +/** + * The type for the first argument to `fetch`. + * + * https://developer.mozilla.org/docs/Web/API/Window/fetch#resource + */ +type _RequestInfo = Request | URL | string; + +/** + * The type for constructing `RequestInit` Headers. + * + * https://developer.mozilla.org/docs/Web/API/RequestInit#setting_headers + */ +type _HeadersInit = RequestInit['headers']; + +/** + * The type for constructing `RequestInit` body. + * + * https://developer.mozilla.org/docs/Web/API/RequestInit#body + */ +type _BodyInit = RequestInit['body']; + +/** + * An alias to the builtin `Array` type so we can + * easily alias it in import statements if there are name clashes. + */ +type _Array = Array; + +/** + * An alias to the builtin `Record` type so we can + * easily alias it in import statements if there are name clashes. + */ +type _Record = Record; + +export type { + _Array as Array, + _BodyInit as BodyInit, + _HeadersInit as HeadersInit, + _Record as Record, + _RequestInfo as RequestInfo, + _RequestInit as RequestInit, + _Response as Response, +}; + +/** + * A copy of the builtin `EndingType` type as it isn't fully supported in certain + * environments and attempting to reference the global version will error. + * + * https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L27941 + */ +type EndingType = 'native' | 'transparent'; + +/** + * A copy of the builtin `BlobPropertyBag` type as it isn't fully supported in certain + * environments and attempting to reference the global version will error. + * + * https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L154 + * https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob#options + */ +export interface BlobPropertyBag { + endings?: EndingType; + type?: string; +} + +/** + * A copy of the builtin `FilePropertyBag` type as it isn't fully supported in certain + * environments and attempting to reference the global version will error. + * + * https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L503 + * https://developer.mozilla.org/en-US/docs/Web/API/File/File#options + */ +export interface FilePropertyBag extends BlobPropertyBag { + lastModified?: number; +} diff --git a/src/sdk/internal/detect-platform.ts b/src/sdk/internal/detect-platform.ts new file mode 100644 index 0000000..3954ec3 --- /dev/null +++ b/src/sdk/internal/detect-platform.ts @@ -0,0 +1,196 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { VERSION } from '../version'; + +export const isRunningInBrowser = () => { + return ( + // @ts-ignore + typeof window !== 'undefined' && + // @ts-ignore + typeof window.document !== 'undefined' && + // @ts-ignore + typeof navigator !== 'undefined' + ); +}; + +type DetectedPlatform = 'deno' | 'node' | 'edge' | 'unknown'; + +/** + * Note this does not detect 'browser'; for that, use getBrowserInfo(). + */ +function getDetectedPlatform(): DetectedPlatform { + if (typeof Deno !== 'undefined' && Deno.build != null) { + return 'deno'; + } + if (typeof EdgeRuntime !== 'undefined') { + return 'edge'; + } + if ( + Object.prototype.toString.call( + typeof (globalThis as any).process !== 'undefined' ? (globalThis as any).process : 0, + ) === '[object process]' + ) { + return 'node'; + } + return 'unknown'; +} + +declare const Deno: any; +declare const EdgeRuntime: any; +type Arch = 'x32' | 'x64' | 'arm' | 'arm64' | `other:${string}` | 'unknown'; +type PlatformName = + | 'MacOS' + | 'Linux' + | 'Windows' + | 'FreeBSD' + | 'OpenBSD' + | 'iOS' + | 'Android' + | `Other:${string}` + | 'Unknown'; +type Browser = 'ie' | 'edge' | 'chrome' | 'firefox' | 'safari'; +type PlatformProperties = { + 'X-Scalar-Lang': 'js'; + 'X-Scalar-Package-Version': string; + 'X-Scalar-OS': PlatformName; + 'X-Scalar-Arch': Arch; + 'X-Scalar-Runtime': 'node' | 'deno' | 'edge' | `browser:${Browser}` | 'unknown'; + 'X-Scalar-Runtime-Version': string; +}; +const getPlatformProperties = (): PlatformProperties => { + const detectedPlatform = getDetectedPlatform(); + if (detectedPlatform === 'deno') { + return { + 'X-Scalar-Lang': 'js', + 'X-Scalar-Package-Version': VERSION, + 'X-Scalar-OS': normalizePlatform(Deno.build.os), + 'X-Scalar-Arch': normalizeArch(Deno.build.arch), + 'X-Scalar-Runtime': 'deno', + 'X-Scalar-Runtime-Version': + typeof Deno.version === 'string' ? Deno.version : (Deno.version?.deno ?? 'unknown'), + }; + } + if (typeof EdgeRuntime !== 'undefined') { + return { + 'X-Scalar-Lang': 'js', + 'X-Scalar-Package-Version': VERSION, + 'X-Scalar-OS': 'Unknown', + 'X-Scalar-Arch': `other:${EdgeRuntime}`, + 'X-Scalar-Runtime': 'edge', + 'X-Scalar-Runtime-Version': (globalThis as any).process.version, + }; + } + // Check if Node.js + if (detectedPlatform === 'node') { + return { + 'X-Scalar-Lang': 'js', + 'X-Scalar-Package-Version': VERSION, + 'X-Scalar-OS': normalizePlatform((globalThis as any).process.platform ?? 'unknown'), + 'X-Scalar-Arch': normalizeArch((globalThis as any).process.arch ?? 'unknown'), + 'X-Scalar-Runtime': 'node', + 'X-Scalar-Runtime-Version': (globalThis as any).process.version ?? 'unknown', + }; + } + + const browserInfo = getBrowserInfo(); + if (browserInfo) { + return { + 'X-Scalar-Lang': 'js', + 'X-Scalar-Package-Version': VERSION, + 'X-Scalar-OS': 'Unknown', + 'X-Scalar-Arch': 'unknown', + 'X-Scalar-Runtime': `browser:${browserInfo.browser}`, + 'X-Scalar-Runtime-Version': browserInfo.version, + }; + } + + // TODO add support for Cloudflare workers, etc. + return { + 'X-Scalar-Lang': 'js', + 'X-Scalar-Package-Version': VERSION, + 'X-Scalar-OS': 'Unknown', + 'X-Scalar-Arch': 'unknown', + 'X-Scalar-Runtime': 'unknown', + 'X-Scalar-Runtime-Version': 'unknown', + }; +}; + +type BrowserInfo = { + browser: Browser; + version: string; +}; + +declare const navigator: { userAgent: string } | undefined; + +// Note: modified from https://github.com/JS-DevTools/host-environment/blob/b1ab79ecde37db5d6e163c050e54fe7d287d7c92/src/isomorphic.browser.ts +function getBrowserInfo(): BrowserInfo | null { + if (typeof navigator === 'undefined' || !navigator) { + return null; + } + + // NOTE: The order matters here! + const browserPatterns = [ + { key: 'edge' as const, pattern: /Edge(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'ie' as const, pattern: /MSIE(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'ie' as const, pattern: /Trident(?:.*rv\:(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'chrome' as const, pattern: /Chrome(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'firefox' as const, pattern: /Firefox(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'safari' as const, pattern: /(?:Version\W+(\d+)\.(\d+)(?:\.(\d+))?)?(?:\W+Mobile\S*)?\W+Safari/ }, + ]; + + // Find the FIRST matching browser + for (const { key, pattern } of browserPatterns) { + const match = pattern.exec(navigator.userAgent); + if (match) { + const major = match[1] || 0; + const minor = match[2] || 0; + const patch = match[3] || 0; + + return { browser: key, version: `${major}.${minor}.${patch}` }; + } + } + + return null; +} + +const normalizeArch = (arch: string): Arch => { + // Node docs: + // - https://nodejs.org/api/process.html#processarch + // Deno docs: + // - https://doc.deno.land/deno/stable/~/Deno.build + if (arch === 'x32') return 'x32'; + if (arch === 'x86_64' || arch === 'x64') return 'x64'; + if (arch === 'arm') return 'arm'; + if (arch === 'aarch64' || arch === 'arm64') return 'arm64'; + if (arch) return `other:${arch}`; + return 'unknown'; +}; + +const normalizePlatform = (platform: string): PlatformName => { + // Node platforms: + // - https://nodejs.org/api/process.html#processplatform + // Deno platforms: + // - https://doc.deno.land/deno/stable/~/Deno.build + // - https://github.com/denoland/deno/issues/14799 + + platform = platform.toLowerCase(); + + // NOTE: this iOS check is untested and may not work + // Node does not work natively on IOS, there is a fork at + // https://github.com/nodejs-mobile/nodejs-mobile + // however it is unknown at the time of writing how to detect if it is running + if (platform.includes('ios')) return 'iOS'; + if (platform === 'android') return 'Android'; + if (platform === 'darwin') return 'MacOS'; + if (platform === 'win32') return 'Windows'; + if (platform === 'freebsd') return 'FreeBSD'; + if (platform === 'openbsd') return 'OpenBSD'; + if (platform === 'linux') return 'Linux'; + if (platform) return `Other:${platform}`; + return 'Unknown'; +}; + +let _platformHeaders: PlatformProperties; +export const getPlatformHeaders = () => { + return (_platformHeaders ??= getPlatformProperties()); +}; diff --git a/src/sdk/internal/errors.ts b/src/sdk/internal/errors.ts new file mode 100644 index 0000000..fed0efd --- /dev/null +++ b/src/sdk/internal/errors.ts @@ -0,0 +1,33 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export function isAbortError(err: unknown) { + return ( + typeof err === 'object' && + err !== null && + // Spec-compliant fetch implementations + (('name' in err && (err as any).name === 'AbortError') || + // Expo fetch + ('message' in err && String((err as any).message).includes('FetchRequestCanceledException'))) + ); +} + +export const castToError = (err: any): Error => { + if (err instanceof Error) return err; + if (typeof err === 'object' && err !== null) { + try { + if (Object.prototype.toString.call(err) === '[object Error]') { + // @ts-ignore - not all envs have native support for cause yet + const error = new Error(err.message, err.cause ? { cause: err.cause } : {}); + if (err.stack) error.stack = err.stack; + // @ts-ignore - not all envs have native support for cause yet + if (err.cause && !error.cause) error.cause = err.cause; + if (err.name) error.name = err.name; + return error; + } + } catch {} + try { + return new Error(JSON.stringify(err)); + } catch {} + } + return new Error(err); +}; diff --git a/src/sdk/internal/headers.ts b/src/sdk/internal/headers.ts new file mode 100644 index 0000000..411434e --- /dev/null +++ b/src/sdk/internal/headers.ts @@ -0,0 +1,97 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { isReadonlyArray } from './utils/values'; + +type HeaderValue = string | undefined | null; +export type HeadersLike = + | Headers + | readonly HeaderValue[][] + | Record + | undefined + | null + | NullableHeaders; + +const brand_privateNullableHeaders = /* @__PURE__ */ Symbol('brand.privateNullableHeaders'); + +/** + * @internal + * Users can pass explicit nulls to unset default headers. When we parse them + * into a standard headers type we need to preserve that information. + */ +export type NullableHeaders = { + /** Brand check, prevent users from creating a NullableHeaders. */ + [brand_privateNullableHeaders]: true; + /** Parsed headers. */ + values: Headers; + /** Set of lowercase header names explicitly set to null. */ + nulls: Set; +}; + +function* iterateHeaders(headers: HeadersLike): IterableIterator { + if (!headers) return; + + if (brand_privateNullableHeaders in headers) { + const { values, nulls } = headers; + yield* values.entries(); + for (const name of nulls) { + yield [name, null]; + } + return; + } + + let shouldClear = false; + let iter: Iterable; + if (headers instanceof Headers) { + iter = headers.entries(); + } else if (isReadonlyArray(headers)) { + iter = headers; + } else { + shouldClear = true; + iter = Object.entries(headers ?? {}); + } + for (let row of iter) { + const name = row[0]; + if (typeof name !== 'string') throw new TypeError('expected header name to be a string'); + const values = isReadonlyArray(row[1]) ? row[1] : [row[1]]; + let didClear = false; + for (const value of values) { + if (value === undefined) continue; + + // Objects keys always overwrite older headers, they never append. + // Yield a null to clear the header before adding the new values. + if (shouldClear && !didClear) { + didClear = true; + yield [name, null]; + } + yield [name, value]; + } + } +} + +export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { + const targetHeaders = new Headers(); + const nullHeaders = new Set(); + for (const headers of newHeaders) { + const seenHeaders = new Set(); + for (const [name, value] of iterateHeaders(headers)) { + const lowerName = name.toLowerCase(); + if (!seenHeaders.has(lowerName)) { + targetHeaders.delete(name); + seenHeaders.add(lowerName); + } + if (value === null) { + targetHeaders.delete(name); + nullHeaders.add(lowerName); + } else { + targetHeaders.append(name, value); + nullHeaders.delete(lowerName); + } + } + } + return { [brand_privateNullableHeaders]: true, values: targetHeaders, nulls: nullHeaders }; +}; + +export const isEmptyHeaders = (headers: HeadersLike) => { + for (const _ of iterateHeaders(headers)) return false; + return true; +}; diff --git a/src/sdk/internal/parse.ts b/src/sdk/internal/parse.ts new file mode 100644 index 0000000..a657cc5 --- /dev/null +++ b/src/sdk/internal/parse.ts @@ -0,0 +1,4 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export { defaultParseResponse } from '../api-promise'; +export type { APIResponseProps, ParseResponse } from '../api-promise'; diff --git a/src/sdk/internal/qs/LICENSE.md b/src/sdk/internal/qs/LICENSE.md new file mode 100644 index 0000000..3fda157 --- /dev/null +++ b/src/sdk/internal/qs/LICENSE.md @@ -0,0 +1,13 @@ +BSD 3-Clause License + +Copyright (c) 2014, Nathan LaFreniere and other [contributors](https://github.com/puruvj/neoqs/graphs/contributors) All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/sdk/internal/qs/README.md b/src/sdk/internal/qs/README.md new file mode 100644 index 0000000..67ae04e --- /dev/null +++ b/src/sdk/internal/qs/README.md @@ -0,0 +1,3 @@ +# qs + +This is a vendored version of [neoqs](https://github.com/PuruVJ/neoqs) which is a TypeScript rewrite of [qs](https://github.com/ljharb/qs), a query string library. diff --git a/src/sdk/internal/qs/formats.ts b/src/sdk/internal/qs/formats.ts new file mode 100644 index 0000000..e76a742 --- /dev/null +++ b/src/sdk/internal/qs/formats.ts @@ -0,0 +1,10 @@ +import type { Format } from './types'; + +export const default_format: Format = 'RFC3986'; +export const default_formatter = (v: PropertyKey) => String(v); +export const formatters: Record string> = { + RFC1738: (v: PropertyKey) => String(v).replace(/%20/g, '+'), + RFC3986: default_formatter, +}; +export const RFC1738 = 'RFC1738'; +export const RFC3986 = 'RFC3986'; diff --git a/src/sdk/internal/qs/index.ts b/src/sdk/internal/qs/index.ts new file mode 100644 index 0000000..c3a3620 --- /dev/null +++ b/src/sdk/internal/qs/index.ts @@ -0,0 +1,13 @@ +import { default_format, formatters, RFC1738, RFC3986 } from './formats'; + +const formats = { + formatters, + RFC1738, + RFC3986, + default: default_format, +}; + +export { stringify } from './stringify'; +export { formats }; + +export type { DefaultDecoder, DefaultEncoder, Format, ParseOptions, StringifyOptions } from './types'; diff --git a/src/sdk/internal/qs/stringify.ts b/src/sdk/internal/qs/stringify.ts new file mode 100644 index 0000000..66ef9a4 --- /dev/null +++ b/src/sdk/internal/qs/stringify.ts @@ -0,0 +1,384 @@ +import { encode, is_buffer, maybe_map, has } from './utils'; +import { default_format, default_formatter, formatters } from './formats'; +import type { NonNullableProperties, StringifyOptions } from './types'; +import { isArray } from '../utils/values'; + +const array_prefix_generators = { + brackets(prefix: PropertyKey) { + return String(prefix) + '[]'; + }, + comma: 'comma', + indices(prefix: PropertyKey, key: string) { + return String(prefix) + '[' + key + ']'; + }, + repeat(prefix: PropertyKey) { + return String(prefix); + }, +}; + +const push_to_array = function (arr: any[], value_or_array: any) { + Array.prototype.push.apply(arr, isArray(value_or_array) ? value_or_array : [value_or_array]); +}; + +let toISOString; + +const defaults = { + addQueryPrefix: false, + allowDots: false, + allowEmptyArrays: false, + arrayFormat: 'indices', + charset: 'utf-8', + charsetSentinel: false, + delimiter: '&', + encode: true, + encodeDotInKeys: false, + encoder: encode, + encodeValuesOnly: false, + format: default_format, + formatter: default_formatter, + /** @deprecated */ + indices: false, + serializeDate(date) { + return (toISOString ??= Function.prototype.call.bind(Date.prototype.toISOString))(date); + }, + skipNulls: false, + strictNullHandling: false, +} as NonNullableProperties; + +function is_non_nullish_primitive(v: unknown): v is string | number | boolean | symbol | bigint { + return ( + typeof v === 'string' || + typeof v === 'number' || + typeof v === 'boolean' || + typeof v === 'symbol' || + typeof v === 'bigint' + ); +} + +const sentinel = {}; + +function inner_stringify( + object: any, + prefix: PropertyKey, + generateArrayPrefix: StringifyOptions['arrayFormat'] | ((prefix: string, key: string) => string), + commaRoundTrip: boolean, + allowEmptyArrays: boolean, + strictNullHandling: boolean, + skipNulls: boolean, + encodeDotInKeys: boolean, + encoder: StringifyOptions['encoder'], + filter: StringifyOptions['filter'], + sort: StringifyOptions['sort'], + allowDots: StringifyOptions['allowDots'], + serializeDate: StringifyOptions['serializeDate'], + format: StringifyOptions['format'], + formatter: StringifyOptions['formatter'], + encodeValuesOnly: boolean, + charset: StringifyOptions['charset'], + sideChannel: WeakMap, +) { + let obj = object; + + let tmp_sc = sideChannel; + let step = 0; + let find_flag = false; + while ((tmp_sc = tmp_sc.get(sentinel)) !== void undefined && !find_flag) { + // Where object last appeared in the ref tree + const pos = tmp_sc.get(object); + step += 1; + if (typeof pos !== 'undefined') { + if (pos === step) { + throw new RangeError('Cyclic object value'); + } else { + find_flag = true; // Break while + } + } + if (typeof tmp_sc.get(sentinel) === 'undefined') { + step = 0; + } + } + + if (typeof filter === 'function') { + obj = filter(prefix, obj); + } else if (obj instanceof Date) { + obj = serializeDate?.(obj); + } else if (generateArrayPrefix === 'comma' && isArray(obj)) { + obj = maybe_map(obj, function (value) { + if (value instanceof Date) { + return serializeDate?.(value); + } + return value; + }); + } + + if (obj === null) { + if (strictNullHandling) { + return encoder && !encodeValuesOnly + ? // @ts-expect-error + encoder(prefix, defaults.encoder, charset, 'key', format) + : prefix; + } + + obj = ''; + } + + if (is_non_nullish_primitive(obj) || is_buffer(obj)) { + if (encoder) { + const key_value = encodeValuesOnly + ? prefix + : // @ts-expect-error + encoder(prefix, defaults.encoder, charset, 'key', format); + return [ + formatter?.(key_value) + + '=' + + // @ts-expect-error + formatter?.(encoder(obj, defaults.encoder, charset, 'value', format)), + ]; + } + return [formatter?.(prefix) + '=' + formatter?.(String(obj))]; + } + + const values: string[] = []; + + if (typeof obj === 'undefined') { + return values; + } + + let obj_keys; + if (generateArrayPrefix === 'comma' && isArray(obj)) { + // we need to join elements in + if (encodeValuesOnly && encoder) { + // @ts-expect-error values only + obj = maybe_map(obj, encoder); + } + obj_keys = [{ value: obj.length > 0 ? obj.join(',') || null : void undefined }]; + } else if (isArray(filter)) { + obj_keys = filter; + } else { + const keys = Object.keys(obj); + obj_keys = sort ? keys.sort(sort) : keys; + } + + const encoded_prefix = encodeDotInKeys ? String(prefix).replace(/\./g, '%2E') : String(prefix); + + const adjusted_prefix = + commaRoundTrip && isArray(obj) && obj.length === 1 ? encoded_prefix + '[]' : encoded_prefix; + + if (allowEmptyArrays && isArray(obj) && obj.length === 0) { + return adjusted_prefix + '[]'; + } + + for (let j = 0; j < obj_keys.length; ++j) { + const key = obj_keys[j]; + const value = + // @ts-ignore + typeof key === 'object' && typeof key.value !== 'undefined' ? key.value : obj[key as any]; + + if (skipNulls && value === null) { + continue; + } + + // @ts-ignore + const encoded_key = allowDots && encodeDotInKeys ? (key as any).replace(/\./g, '%2E') : key; + const key_prefix = isArray(obj) + ? typeof generateArrayPrefix === 'function' + ? generateArrayPrefix(adjusted_prefix, encoded_key) + : adjusted_prefix + : adjusted_prefix + (allowDots ? '.' + encoded_key : '[' + encoded_key + ']'); + + sideChannel.set(object, step); + const valueSideChannel = new WeakMap(); + valueSideChannel.set(sentinel, sideChannel); + push_to_array( + values, + inner_stringify( + value, + key_prefix, + generateArrayPrefix, + commaRoundTrip, + allowEmptyArrays, + strictNullHandling, + skipNulls, + encodeDotInKeys, + // @ts-ignore + generateArrayPrefix === 'comma' && encodeValuesOnly && isArray(obj) ? null : encoder, + filter, + sort, + allowDots, + serializeDate, + format, + formatter, + encodeValuesOnly, + charset, + valueSideChannel, + ), + ); + } + + return values; +} + +function normalize_stringify_options( + opts: StringifyOptions = defaults, +): NonNullableProperties> & { indices?: boolean } { + if (typeof opts.allowEmptyArrays !== 'undefined' && typeof opts.allowEmptyArrays !== 'boolean') { + throw new TypeError('`allowEmptyArrays` option can only be `true` or `false`, when provided'); + } + + if (typeof opts.encodeDotInKeys !== 'undefined' && typeof opts.encodeDotInKeys !== 'boolean') { + throw new TypeError('`encodeDotInKeys` option can only be `true` or `false`, when provided'); + } + + if (opts.encoder !== null && typeof opts.encoder !== 'undefined' && typeof opts.encoder !== 'function') { + throw new TypeError('Encoder has to be a function.'); + } + + const charset = opts.charset || defaults.charset; + if (typeof opts.charset !== 'undefined' && opts.charset !== 'utf-8' && opts.charset !== 'iso-8859-1') { + throw new TypeError('The charset option must be either utf-8, iso-8859-1, or undefined'); + } + + let format = default_format; + if (typeof opts.format !== 'undefined') { + if (!has(formatters, opts.format)) { + throw new TypeError('Unknown format option provided.'); + } + format = opts.format; + } + const formatter = formatters[format]; + + let filter = defaults.filter; + if (typeof opts.filter === 'function' || isArray(opts.filter)) { + filter = opts.filter; + } + + let arrayFormat: StringifyOptions['arrayFormat']; + if (opts.arrayFormat && opts.arrayFormat in array_prefix_generators) { + arrayFormat = opts.arrayFormat; + } else if ('indices' in opts) { + arrayFormat = opts.indices ? 'indices' : 'repeat'; + } else { + arrayFormat = defaults.arrayFormat; + } + + if ('commaRoundTrip' in opts && typeof opts.commaRoundTrip !== 'boolean') { + throw new TypeError('`commaRoundTrip` must be a boolean, or absent'); + } + + const allowDots = + typeof opts.allowDots === 'undefined' + ? !!opts.encodeDotInKeys === true + ? true + : defaults.allowDots + : !!opts.allowDots; + + return { + addQueryPrefix: typeof opts.addQueryPrefix === 'boolean' ? opts.addQueryPrefix : defaults.addQueryPrefix, + // @ts-ignore + allowDots: allowDots, + allowEmptyArrays: + typeof opts.allowEmptyArrays === 'boolean' ? !!opts.allowEmptyArrays : defaults.allowEmptyArrays, + arrayFormat: arrayFormat, + charset: charset, + charsetSentinel: + typeof opts.charsetSentinel === 'boolean' ? opts.charsetSentinel : defaults.charsetSentinel, + commaRoundTrip: !!opts.commaRoundTrip, + delimiter: typeof opts.delimiter === 'undefined' ? defaults.delimiter : opts.delimiter, + encode: typeof opts.encode === 'boolean' ? opts.encode : defaults.encode, + encodeDotInKeys: + typeof opts.encodeDotInKeys === 'boolean' ? opts.encodeDotInKeys : defaults.encodeDotInKeys, + encoder: typeof opts.encoder === 'function' ? opts.encoder : defaults.encoder, + encodeValuesOnly: + typeof opts.encodeValuesOnly === 'boolean' ? opts.encodeValuesOnly : defaults.encodeValuesOnly, + filter: filter, + format: format, + formatter: formatter, + serializeDate: typeof opts.serializeDate === 'function' ? opts.serializeDate : defaults.serializeDate, + skipNulls: typeof opts.skipNulls === 'boolean' ? opts.skipNulls : defaults.skipNulls, + // @ts-ignore + sort: typeof opts.sort === 'function' ? opts.sort : null, + strictNullHandling: + typeof opts.strictNullHandling === 'boolean' ? opts.strictNullHandling : defaults.strictNullHandling, + }; +} + +export function stringify(object: any, opts: StringifyOptions = {}) { + let obj = object; + const options = normalize_stringify_options(opts); + + let obj_keys: PropertyKey[] | undefined; + let filter; + + if (typeof options.filter === 'function') { + filter = options.filter; + obj = filter('', obj); + } else if (isArray(options.filter)) { + filter = options.filter; + obj_keys = filter; + } + + const keys: string[] = []; + + if (typeof obj !== 'object' || obj === null) { + return ''; + } + + const generateArrayPrefix = array_prefix_generators[options.arrayFormat]; + const commaRoundTrip = generateArrayPrefix === 'comma' && options.commaRoundTrip; + + if (!obj_keys) { + obj_keys = Object.keys(obj); + } + + if (options.sort) { + obj_keys.sort(options.sort); + } + + const sideChannel = new WeakMap(); + for (let i = 0; i < obj_keys.length; ++i) { + const key = obj_keys[i]!; + + if (options.skipNulls && obj[key] === null) { + continue; + } + push_to_array( + keys, + inner_stringify( + obj[key], + key, + // @ts-expect-error + generateArrayPrefix, + commaRoundTrip, + options.allowEmptyArrays, + options.strictNullHandling, + options.skipNulls, + options.encodeDotInKeys, + options.encode ? options.encoder : null, + options.filter, + options.sort, + options.allowDots, + options.serializeDate, + options.format, + options.formatter, + options.encodeValuesOnly, + options.charset, + sideChannel, + ), + ); + } + + const joined = keys.join(options.delimiter); + let prefix = options.addQueryPrefix === true ? '?' : ''; + + if (options.charsetSentinel) { + if (options.charset === 'iso-8859-1') { + // encodeURIComponent('✓'), the "numeric entity" representation of a checkmark + prefix += 'utf8=%26%2310003%3B&'; + } else { + // encodeURIComponent('✓') + prefix += 'utf8=%E2%9C%93&'; + } + } + + return joined.length > 0 ? prefix + joined : ''; +} diff --git a/src/sdk/internal/qs/types.ts b/src/sdk/internal/qs/types.ts new file mode 100644 index 0000000..7c28dbb --- /dev/null +++ b/src/sdk/internal/qs/types.ts @@ -0,0 +1,71 @@ +export type Format = 'RFC1738' | 'RFC3986'; + +export type DefaultEncoder = (str: any, defaultEncoder?: any, charset?: string) => string; +export type DefaultDecoder = (str: string, decoder?: any, charset?: string) => string; + +export type BooleanOptional = boolean | undefined; + +export type StringifyBaseOptions = { + delimiter?: string; + allowDots?: boolean; + encodeDotInKeys?: boolean; + strictNullHandling?: boolean; + skipNulls?: boolean; + encode?: boolean; + encoder?: ( + str: any, + defaultEncoder: DefaultEncoder, + charset: string, + type: 'key' | 'value', + format?: Format, + ) => string; + filter?: Array | ((prefix: PropertyKey, value: any) => any); + arrayFormat?: 'indices' | 'brackets' | 'repeat' | 'comma'; + indices?: boolean; + sort?: ((a: PropertyKey, b: PropertyKey) => number) | null; + serializeDate?: (d: Date) => string; + format?: 'RFC1738' | 'RFC3986'; + formatter?: (str: PropertyKey) => string; + encodeValuesOnly?: boolean; + addQueryPrefix?: boolean; + charset?: 'utf-8' | 'iso-8859-1'; + charsetSentinel?: boolean; + allowEmptyArrays?: boolean; + commaRoundTrip?: boolean; +}; + +export type StringifyOptions = StringifyBaseOptions; + +export type ParseBaseOptions = { + comma?: boolean; + delimiter?: string | RegExp; + depth?: number | false; + decoder?: (str: string, defaultDecoder: DefaultDecoder, charset: string, type: 'key' | 'value') => any; + arrayLimit?: number; + parseArrays?: boolean; + plainObjects?: boolean; + allowPrototypes?: boolean; + allowSparse?: boolean; + parameterLimit?: number; + strictDepth?: boolean; + strictNullHandling?: boolean; + ignoreQueryPrefix?: boolean; + charset?: 'utf-8' | 'iso-8859-1'; + charsetSentinel?: boolean; + interpretNumericEntities?: boolean; + allowEmptyArrays?: boolean; + duplicates?: 'combine' | 'first' | 'last'; + allowDots?: boolean; + decodeDotInKeys?: boolean; +}; + +export type ParseOptions = ParseBaseOptions; + +export type ParsedQs = { + [key: string]: undefined | string | string[] | ParsedQs | ParsedQs[]; +}; + +// Type to remove null or undefined union from each property +export type NonNullableProperties = { + [K in keyof T]-?: Exclude; +}; diff --git a/src/sdk/internal/qs/utils.ts b/src/sdk/internal/qs/utils.ts new file mode 100644 index 0000000..4cd5657 --- /dev/null +++ b/src/sdk/internal/qs/utils.ts @@ -0,0 +1,265 @@ +import { RFC1738 } from './formats'; +import type { DefaultEncoder, Format } from './types'; +import { isArray } from '../utils/values'; + +export let has = (obj: object, key: PropertyKey): boolean => ( + (has = (Object as any).hasOwn ?? Function.prototype.call.bind(Object.prototype.hasOwnProperty)), + has(obj, key) +); + +const hex_table = /* @__PURE__ */ (() => { + const array = []; + for (let i = 0; i < 256; ++i) { + array.push('%' + ((i < 16 ? '0' : '') + i.toString(16)).toUpperCase()); + } + + return array; +})(); + +function compact_queue>(queue: Array<{ obj: T; prop: string }>) { + while (queue.length > 1) { + const item = queue.pop(); + if (!item) continue; + + const obj = item.obj[item.prop]; + + if (isArray(obj)) { + const compacted: unknown[] = []; + + for (let j = 0; j < obj.length; ++j) { + if (typeof obj[j] !== 'undefined') { + compacted.push(obj[j]); + } + } + + // @ts-ignore + item.obj[item.prop] = compacted; + } + } +} + +function array_to_object(source: any[], options: { plainObjects: boolean }) { + const obj = options && options.plainObjects ? Object.create(null) : {}; + for (let i = 0; i < source.length; ++i) { + if (typeof source[i] !== 'undefined') { + obj[i] = source[i]; + } + } + + return obj; +} + +export function merge( + target: any, + source: any, + options: { plainObjects?: boolean; allowPrototypes?: boolean } = {}, +) { + if (!source) { + return target; + } + + if (typeof source !== 'object') { + if (isArray(target)) { + target.push(source); + } else if (target && typeof target === 'object') { + if ((options && (options.plainObjects || options.allowPrototypes)) || !has(Object.prototype, source)) { + target[source] = true; + } + } else { + return [target, source]; + } + + return target; + } + + if (!target || typeof target !== 'object') { + return [target].concat(source); + } + + let mergeTarget = target; + if (isArray(target) && !isArray(source)) { + // @ts-ignore + mergeTarget = array_to_object(target, options); + } + + if (isArray(target) && isArray(source)) { + source.forEach(function (item, i) { + if (has(target, i)) { + const targetItem = target[i]; + if (targetItem && typeof targetItem === 'object' && item && typeof item === 'object') { + target[i] = merge(targetItem, item, options); + } else { + target.push(item); + } + } else { + target[i] = item; + } + }); + return target; + } + + return Object.keys(source).reduce(function (acc, key) { + const value = source[key]; + + if (has(acc, key)) { + acc[key] = merge(acc[key], value, options); + } else { + acc[key] = value; + } + return acc; + }, mergeTarget); +} + +export function assign_single_source(target: any, source: any) { + return Object.keys(source).reduce(function (acc, key) { + acc[key] = source[key]; + return acc; + }, target); +} + +export function decode(str: string, _: any, charset: string) { + const strWithoutPlus = str.replace(/\+/g, ' '); + if (charset === 'iso-8859-1') { + // unescape never throws, no try...catch needed: + return strWithoutPlus.replace(/%[0-9a-f]{2}/gi, unescape); + } + // utf-8 + try { + return decodeURIComponent(strWithoutPlus); + } catch (e) { + return strWithoutPlus; + } +} + +const limit = 1024; + +export const encode: ( + str: any, + defaultEncoder: DefaultEncoder, + charset: string, + type: 'key' | 'value', + format: Format, +) => string = (str, _defaultEncoder, charset, _kind, format: Format) => { + // This code was originally written by Brian White for the io.js core querystring library. + // It has been adapted here for stricter adherence to RFC 3986 + if (str.length === 0) { + return str; + } + + let string = str; + if (typeof str === 'symbol') { + string = Symbol.prototype.toString.call(str); + } else if (typeof str !== 'string') { + string = String(str); + } + + if (charset === 'iso-8859-1') { + return escape(string).replace(/%u[0-9a-f]{4}/gi, function ($0) { + return '%26%23' + parseInt($0.slice(2), 16) + '%3B'; + }); + } + + let out = ''; + for (let j = 0; j < string.length; j += limit) { + const segment = string.length >= limit ? string.slice(j, j + limit) : string; + const arr = []; + + for (let i = 0; i < segment.length; ++i) { + let c = segment.charCodeAt(i); + if ( + c === 0x2d || // - + c === 0x2e || // . + c === 0x5f || // _ + c === 0x7e || // ~ + (c >= 0x30 && c <= 0x39) || // 0-9 + (c >= 0x41 && c <= 0x5a) || // a-z + (c >= 0x61 && c <= 0x7a) || // A-Z + (format === RFC1738 && (c === 0x28 || c === 0x29)) // ( ) + ) { + arr[arr.length] = segment.charAt(i); + continue; + } + + if (c < 0x80) { + arr[arr.length] = hex_table[c]; + continue; + } + + if (c < 0x800) { + arr[arr.length] = hex_table[0xc0 | (c >> 6)]! + hex_table[0x80 | (c & 0x3f)]; + continue; + } + + if (c < 0xd800 || c >= 0xe000) { + arr[arr.length] = + hex_table[0xe0 | (c >> 12)]! + hex_table[0x80 | ((c >> 6) & 0x3f)] + hex_table[0x80 | (c & 0x3f)]; + continue; + } + + i += 1; + c = 0x10000 + (((c & 0x3ff) << 10) | (segment.charCodeAt(i) & 0x3ff)); + + arr[arr.length] = + hex_table[0xf0 | (c >> 18)]! + + hex_table[0x80 | ((c >> 12) & 0x3f)] + + hex_table[0x80 | ((c >> 6) & 0x3f)] + + hex_table[0x80 | (c & 0x3f)]; + } + + out += arr.join(''); + } + + return out; +}; + +export function compact(value: any) { + const queue = [{ obj: { o: value }, prop: 'o' }]; + const refs = []; + + for (let i = 0; i < queue.length; ++i) { + const item = queue[i]; + // @ts-ignore + const obj = item.obj[item.prop]; + + const keys = Object.keys(obj); + for (let j = 0; j < keys.length; ++j) { + const key = keys[j]!; + const val = obj[key]; + if (typeof val === 'object' && val !== null && refs.indexOf(val) === -1) { + queue.push({ obj: obj, prop: key }); + refs.push(val); + } + } + } + + compact_queue(queue); + + return value; +} + +export function is_regexp(obj: any) { + return Object.prototype.toString.call(obj) === '[object RegExp]'; +} + +export function is_buffer(obj: any) { + if (!obj || typeof obj !== 'object') { + return false; + } + + return !!(obj.constructor && obj.constructor.isBuffer && obj.constructor.isBuffer(obj)); +} + +export function combine(a: any, b: any) { + return [].concat(a, b); +} + +export function maybe_map(val: T[], fn: (v: T) => T) { + if (isArray(val)) { + const mapped = []; + for (let i = 0; i < val.length; i += 1) { + mapped.push(fn(val[i]!)); + } + return mapped; + } + return fn(val); +} diff --git a/src/sdk/internal/request-options.ts b/src/sdk/internal/request-options.ts new file mode 100644 index 0000000..4dc4258 --- /dev/null +++ b/src/sdk/internal/request-options.ts @@ -0,0 +1,39 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import type { BodyInit } from './builtin-types'; +import type { HTTPMethod, MergedRequestInit } from './types'; +import type { HeadersLike, NullableHeaders } from './headers'; + +export type RequestOptions = { + method?: HTTPMethod | undefined; + path?: string | undefined; + headers?: HeadersLike | undefined; + query?: object | undefined | null; + body?: unknown; + timeout?: number | undefined; + maxRetries?: number | undefined; + stream?: boolean | undefined; + signal?: AbortSignal | undefined | null; + fetchOptions?: MergedRequestInit | undefined; + idempotencyKey?: string | undefined; + defaultBaseURL?: string | undefined; + __binaryResponse?: boolean | undefined; +}; + +export type FinalRequestOptions = RequestOptions & { + method: HTTPMethod; + path: string; +}; + +export type EncodedContent = { bodyHeaders: HeadersLike; body: BodyInit }; +export type RequestEncoder = (request: { headers: NullableHeaders; body: unknown }) => EncodedContent; + +/** Fallback JSON encoder used when a request body is not already a fetch body type. */ +export const FallbackEncoder: RequestEncoder = ({ body }) => { + return { + bodyHeaders: { + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }; +}; diff --git a/src/sdk/internal/shim-types.ts b/src/sdk/internal/shim-types.ts new file mode 100644 index 0000000..89f60e1 --- /dev/null +++ b/src/sdk/internal/shim-types.ts @@ -0,0 +1,27 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +/** + * Shims for types that we can't always rely on being available globally. + * + * Note: these only exist at the type-level, there is no corresponding runtime + * version for any of these symbols. + */ + +type NeverToAny = T extends never ? any : T; + +/** @ts-ignore */ +type _DOMReadableStream = globalThis.ReadableStream; + +/** @ts-ignore */ +type _NodeReadableStream = import('stream/web').ReadableStream; + +type _ConditionalNodeReadableStream = typeof globalThis extends { ReadableStream: any } + ? never + : _NodeReadableStream; + +type _ReadableStream = NeverToAny< + | ([0] extends [1 & _DOMReadableStream] ? never : _DOMReadableStream) + | ([0] extends [1 & _ConditionalNodeReadableStream] ? never : _ConditionalNodeReadableStream) +>; + +export type { _ReadableStream as ReadableStream }; diff --git a/src/sdk/internal/shims.ts b/src/sdk/internal/shims.ts new file mode 100644 index 0000000..0774514 --- /dev/null +++ b/src/sdk/internal/shims.ts @@ -0,0 +1,107 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +/** + * This module provides internal shims and utility functions for environments where certain Node.js or global types may not be available. + * + * These are used to ensure we can provide a consistent behaviour between different JavaScript environments and good error + * messages in cases where an environment isn't fully supported. + */ + +import type { Fetch } from './builtin-types'; +import type { ReadableStream } from './shim-types'; + +export function getDefaultFetch(): Fetch { + if (typeof fetch !== 'undefined') { + return fetch as any; + } + + throw new Error( + '`fetch` is not defined as a global; Either pass `fetch` to the client, `new Dedalus({ fetch })` or polyfill the global, `globalThis.fetch = fetch`', + ); +} + +type ReadableStreamArgs = ConstructorParameters; + +export function makeReadableStream(...args: ReadableStreamArgs): ReadableStream { + const ReadableStream = (globalThis as any).ReadableStream; + if (typeof ReadableStream === 'undefined') { + // Note: All of the platforms / runtimes we officially support already define + // `ReadableStream` as a global, so this should only ever be hit on unsupported runtimes. + throw new Error( + '`ReadableStream` is not defined as a global; You will need to polyfill it, `globalThis.ReadableStream = ReadableStream`', + ); + } + + return new ReadableStream(...args); +} + +export function ReadableStreamFrom(iterable: Iterable | AsyncIterable): ReadableStream { + let iter: AsyncIterator | Iterator = + Symbol.asyncIterator in iterable ? iterable[Symbol.asyncIterator]() : iterable[Symbol.iterator](); + + return makeReadableStream({ + start() {}, + async pull(controller: any) { + const { done, value } = await iter.next(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + }, + async cancel() { + await iter.return?.(); + }, + }); +} + +/** + * Most browsers don't yet have async iterable support for ReadableStream, + * and Node has a very different way of reading bytes from its "ReadableStream". + * + * This polyfill was pulled from https://github.com/MattiasBuelens/web-streams-polyfill/pull/122#issuecomment-1627354490 + */ +export function ReadableStreamToAsyncIterable(stream: any): AsyncIterableIterator { + if (stream[Symbol.asyncIterator]) return stream; + + const reader = stream.getReader(); + return { + async next() { + try { + const result = await reader.read(); + if (result?.done) reader.releaseLock(); // release lock when stream becomes closed + return result; + } catch (e) { + reader.releaseLock(); // release lock when stream becomes errored + throw e; + } + }, + async return() { + const cancelPromise = reader.cancel(); + reader.releaseLock(); + await cancelPromise; + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} + +/** + * Cancels a ReadableStream we don't need to consume. + * See https://undici.nodejs.org/#/?id=garbage-collection + */ +export async function CancelReadableStream(stream: any): Promise { + if (stream === null || typeof stream !== 'object') return; + + if (stream[Symbol.asyncIterator]) { + await stream[Symbol.asyncIterator]().return?.(); + return; + } + + const reader = stream.getReader(); + const cancelPromise = reader.cancel(); + reader.releaseLock(); + await cancelPromise; +} diff --git a/src/sdk/internal/to-file.ts b/src/sdk/internal/to-file.ts new file mode 100644 index 0000000..30eada3 --- /dev/null +++ b/src/sdk/internal/to-file.ts @@ -0,0 +1,154 @@ +import { BlobPart, getName, makeFile, isAsyncIterable } from './uploads'; +import type { FilePropertyBag } from './builtin-types'; +import { checkFileSupport } from './uploads'; + +type BlobLikePart = string | ArrayBuffer | ArrayBufferView | BlobLike | DataView; + +/** + * Intended to match DOM Blob, node-fetch Blob, node:buffer Blob, etc. + * Don't add arrayBuffer here, node-fetch doesn't have it + */ +interface BlobLike { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */ + readonly size: number; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */ + readonly type: string; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */ + text(): Promise; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */ + slice(start?: number, end?: number): BlobLike; +} + +/** + * This check adds the arrayBuffer() method type because it is available and used at runtime + */ +const isBlobLike = (value: any): value is BlobLike & { arrayBuffer(): Promise } => + value != null && + typeof value === 'object' && + typeof value.size === 'number' && + typeof value.type === 'string' && + typeof value.text === 'function' && + typeof value.slice === 'function' && + typeof value.arrayBuffer === 'function'; + +/** + * Intended to match DOM File, node:buffer File, undici File, etc. + */ +interface FileLike extends BlobLike { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */ + readonly lastModified: number; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */ + readonly name?: string | undefined; +} + +/** + * This check adds the arrayBuffer() method type because it is available and used at runtime + */ +const isFileLike = (value: any): value is FileLike & { arrayBuffer(): Promise } => + value != null && + typeof value === 'object' && + typeof value.name === 'string' && + typeof value.lastModified === 'number' && + isBlobLike(value); + +/** + * Intended to match DOM Response, node-fetch Response, undici Response, etc. + */ +export interface ResponseLike { + url: string; + blob(): Promise; +} + +const isResponseLike = (value: any): value is ResponseLike => + value != null && + typeof value === 'object' && + typeof value.url === 'string' && + typeof value.blob === 'function'; + +export type ToFileInput = + | FileLike + | ResponseLike + | Exclude + | AsyncIterable; + +/** + * Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats + * @param value the raw content of the file. Can be an {@link Uploadable}, BlobLikePart, or AsyncIterable of BlobLikeParts + * @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible + * @param {Object=} options additional properties + * @param {string=} options.type the MIME type of the content + * @param {number=} options.lastModified the last modified timestamp + * @returns a {@link File} with the given properties + */ +export async function toFile( + value: ToFileInput | PromiseLike, + name?: string | null | undefined, + options?: FilePropertyBag | undefined, +): Promise { + checkFileSupport(); + + // If it's a promise, resolve it. + value = await value; + + // If we've been given a `File` we don't need to do anything + if (isFileLike(value)) { + if (value instanceof File) { + return value; + } + return makeFile([await value.arrayBuffer()], value.name); + } + + if (isResponseLike(value)) { + const blob = await value.blob(); + name ||= new URL(value.url).pathname.split(/[\\/]/).pop(); + + return makeFile(await getBytes(blob), name, options); + } + + const parts = await getBytes(value); + + name ||= getName(value); + + if (!options?.type) { + const type = parts.find((part) => typeof part === 'object' && 'type' in part && part.type); + if (typeof type === 'string') { + options = { ...options, type }; + } + } + + return makeFile(parts, name, options); +} + +async function getBytes(value: BlobLikePart | AsyncIterable): Promise> { + let parts: Array = []; + if ( + typeof value === 'string' || + ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc. + value instanceof ArrayBuffer + ) { + parts.push(value); + } else if (isBlobLike(value)) { + parts.push(value instanceof Blob ? value : await value.arrayBuffer()); + } else if ( + isAsyncIterable(value) // includes Readable, ReadableStream, etc. + ) { + for await (const chunk of value) { + parts.push(...(await getBytes(chunk as BlobLikePart))); // TODO, consider validating? + } + } else { + const constructor = value?.constructor?.name; + throw new Error( + `Unexpected data type: ${typeof value}${ + constructor ? `; constructor: ${constructor}` : '' + }${propsForError(value)}`, + ); + } + + return parts; +} + +function propsForError(value: unknown): string { + if (typeof value !== 'object' || value === null) return ''; + const props = Object.getOwnPropertyNames(value); + return `; props: [${props.map((p) => `"${p}"`).join(', ')}]`; +} diff --git a/src/sdk/internal/types.ts b/src/sdk/internal/types.ts new file mode 100644 index 0000000..5f1b840 --- /dev/null +++ b/src/sdk/internal/types.ts @@ -0,0 +1,93 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export type PromiseOrValue = T | Promise; +export type HTTPMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; + +export type KeysEnum = { [P in keyof Required]: true }; + +export type FinalizedRequestInit = RequestInit & { headers: Headers }; + +type NotAny = [0] extends [1 & T] ? never : T; + +/** + * Some environments overload the global fetch function, and Parameters only gets the last signature. + */ +type OverloadedParameters = T extends { + (...args: infer A): unknown; + (...args: infer B): unknown; + (...args: infer C): unknown; + (...args: infer D): unknown; +} + ? A | B | C | D + : T extends { + (...args: infer A): unknown; + (...args: infer B): unknown; + (...args: infer C): unknown; + } + ? A | B | C + : T extends { + (...args: infer A): unknown; + (...args: infer B): unknown; + } + ? A | B + : T extends (...args: infer A) => unknown + ? A + : never; + +/** + * These imports attempt to get types from a parent package's dependencies. + * Unresolved bare specifiers can trigger [automatic type acquisition][1] in some projects, which + * would cause typescript to show types not present at runtime. To avoid this, we import + * directly from parent node_modules folders. + * + * We need to check multiple levels because we don't know what directory structure we'll be in. + * For example, pnpm generates directories like this: + * ``` + * node_modules + * ├── .pnpm + * │ └── pkg@1.0.0 + * │ └── node_modules + * │ └── pkg + * │ └── internal + * │ └── types.d.ts + * ├── pkg -> .pnpm/pkg@1.0.0/node_modules/pkg + * └── undici + * ``` + * + * [1]: https://www.typescriptlang.org/tsconfig/#typeAcquisition + */ +// biome-ignore format: the whole alias must stay on one physical line — the @ts-ignore below only suppresses the first line of the statement it precedes +/** @ts-ignore For users with \@types/node */ /* prettier-ignore */ +type UndiciTypesRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +// biome-ignore format: the whole alias must stay on one physical line — the @ts-ignore below only suppresses the first line of the statement it precedes +/** @ts-ignore For users with undici */ /* prettier-ignore */ +type UndiciRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +// biome-ignore format: the whole alias must stay on one physical line — the @ts-ignore below only suppresses the first line of the statement it precedes +/** @ts-ignore For users with \@types/bun */ /* prettier-ignore */ +type BunRequestInit = globalThis.FetchRequestInit; +// biome-ignore format: the whole alias must stay on one physical line — the @ts-ignore below only suppresses the first line of the statement it precedes +/** @ts-ignore For users with node-fetch@2 */ /* prettier-ignore */ +type NodeFetch2RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +// biome-ignore format: the whole alias must stay on one physical line — the @ts-ignore below only suppresses the first line of the statement it precedes +/** @ts-ignore For users with node-fetch@3, doesn't need file extension because types are at ./@types/index.d.ts */ /* prettier-ignore */ +type NodeFetch3RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +// biome-ignore format: the whole alias must stay on one physical line — the @ts-ignore below only suppresses the first line of the statement it precedes +/** @ts-ignore For users who use Deno */ /* prettier-ignore */ +type FetchRequestInit = NonNullable[1]>; + +type RequestInits = + | NotAny + | NotAny + | NotAny + | NotAny + | NotAny + | NotAny + | NotAny; + +/** + * This type contains `RequestInit` options that may be available on the current runtime, + * including per-platform extensions like `dispatcher`, `agent`, `client`, etc. + */ +export type MergedRequestInit = RequestInits & + /** We don't include these in the types as they'll be overridden for every request. */ + Partial>; diff --git a/src/sdk/internal/uploads.ts b/src/sdk/internal/uploads.ts new file mode 100644 index 0000000..f9bd7ad --- /dev/null +++ b/src/sdk/internal/uploads.ts @@ -0,0 +1,204 @@ +import { type RequestOptions } from './request-options'; +import type { FilePropertyBag, Fetch } from './builtin-types'; +import type { Dedalus } from '../client'; +import { ReadableStreamFrom } from './shims'; + +export type BlobPart = string | ArrayBuffer | ArrayBufferView | Blob | DataView; +type FsReadStream = AsyncIterable & { path: string | { toString(): string } }; + +// https://github.com/oven-sh/bun/issues/5980 +interface BunFile extends Blob { + readonly name?: string | undefined; +} + +export const checkFileSupport = () => { + if (typeof File === 'undefined') { + const { process } = globalThis as any; + const isOldNode = + typeof process?.versions?.node === 'string' && parseInt(process.versions.node.split('.')) < 20; + throw new Error( + '`File` is not defined as a global, which is required for file uploads.' + + (isOldNode + ? " Update to Node 20 LTS or newer, or set `globalThis.File` to `import('node:buffer').File`." + : ''), + ); + } +}; + +/** + * Typically, this is a native "File" class. + * + * We provide the {@link toFile} utility to convert a variety of objects + * into the File class. + * + * For convenience, you can also pass a fetch Response, or in Node, + * the result of fs.createReadStream(). + */ +export type Uploadable = Blob | File | Response | FsReadStream | BunFile; + +/** + * Construct a `File` instance. This is used to ensure a helpful error is thrown + * for environments that don't define a global `File` yet. + */ +export function makeFile( + fileBits: BlobPart[], + fileName: string | undefined, + options?: FilePropertyBag, +): File { + checkFileSupport(); + return new File(fileBits as any, fileName ?? 'unknown_file', options); +} + +export function getName(value: any): string | undefined { + return ( + ( + (typeof value === 'object' && + value !== null && + (('name' in value && value.name && String(value.name)) || + ('url' in value && value.url && String(value.url)) || + ('filename' in value && value.filename && String(value.filename)) || + ('path' in value && value.path && String(value.path)))) || + '' + ) + .split(/[\\/]/) + .pop() || undefined + ); +} + +export const isAsyncIterable = (value: any): value is AsyncIterable => + value != null && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function'; + +/** + * Returns a multipart/form-data request if any part of the given request body contains a File / Blob value. + * Otherwise returns the request as is. + */ +export const maybeMultipartFormRequestOptions = async ( + opts: RequestOptions, + fetch: Dedalus | Fetch, +): Promise => { + if (!hasUploadableValue(opts.body)) return opts; + + return { ...opts, body: await createForm(opts.body, fetch) }; +}; + +type MultipartFormRequestOptions = Omit & { body: unknown }; + +export const multipartFormRequestOptions = async ( + opts: MultipartFormRequestOptions, + fetch: Dedalus | Fetch, +): Promise => { + return { ...opts, body: await createForm(opts.body, fetch) }; +}; + +const supportsFormDataMap = /* @__PURE__ */ new WeakMap>(); + +/** + * node-fetch doesn't support the global FormData object in recent node versions. Instead of sending + * properly-encoded form data, it just stringifies the object, resulting in a request body of "[object FormData]". + * This function detects if the fetch function provided supports the global FormData object to avoid + * confusing error messages later on. + */ +function supportsFormData(fetchObject: Dedalus | Fetch): Promise { + const fetch: Fetch = typeof fetchObject === 'function' ? fetchObject : (fetchObject as any).fetch; + const cached = supportsFormDataMap.get(fetch); + if (cached) return cached; + const promise = (async () => { + try { + // Prefer a `Response` constructor we can reach without a network round-trip: the one attached to + // the fetch function, then the global `Response`. Only fall back to probing `data:,` when neither + // exists, so serializing an already-provided File/Blob never triggers an extra fetch (which would + // otherwise show up as a spurious request to `data:,` before the real API call). + const FetchResponse = ( + 'Response' in fetch + ? fetch.Response + : typeof Response !== 'undefined' + ? Response + : (await fetch('data:,')).constructor + ) as typeof Response; + const data = new FormData(); + if (data.toString() === (await new FetchResponse(data).text())) { + return false; + } + return true; + } catch { + // avoid false negatives + return true; + } + })(); + supportsFormDataMap.set(fetch, promise); + return promise; +} + +export const createForm = async >( + body: T | undefined, + fetch: Dedalus | Fetch, +): Promise => { + if (!(await supportsFormData(fetch))) { + throw new TypeError( + 'The provided fetch function does not support file uploads with the current global FormData class.', + ); + } + const form = new FormData(); + if (isUploadable(body)) { + // Multipart schemas can describe the whole request body as a single binary part. + await addFormValue(form, 'body', body); + return form; + } + await Promise.all(Object.entries(body || {}).map(([key, value]) => addFormValue(form, key, value))); + return form; +}; + +// We check for Blob not File because Bun.File doesn't inherit from File, +// but they both inherit from Blob and have a `name` property at runtime. +const isBlob = (value: unknown): value is Blob => value instanceof Blob; + +const isUploadable = (value: unknown) => + typeof value === 'object' && + value !== null && + (value instanceof Response || isAsyncIterable(value) || isBlob(value)); + +const hasUploadableValue = (value: unknown): boolean => { + if (isUploadable(value)) return true; + if (Array.isArray(value)) return value.some(hasUploadableValue); + if (value && typeof value === 'object') { + for (const k in value) { + if (hasUploadableValue((value as any)[k])) return true; + } + } + return false; +}; + +const addFormValue = async (form: FormData, key: string, value: unknown): Promise => { + if (value === undefined) return; + if (value == null) { + throw new TypeError( + `Received null for "${key}"; to pass null in FormData, you must use the string 'null'`, + ); + } + + // TODO: make nested formats configurable + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + form.append(key, String(value)); + } else if (value instanceof Response) { + form.append(key, makeFile([await value.blob()], getName(value))); + } else if (isAsyncIterable(value)) { + form.append(key, makeFile([await new Response(ReadableStreamFrom(value)).blob()], getName(value))); + } else if (isBlob(value)) { + const name = getName(value); + if (name === undefined) { + form.append(key, value); + } else { + form.append(key, value, name); + } + } else if (Array.isArray(value)) { + await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry))); + } else if (typeof value === 'object') { + await Promise.all( + Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop)), + ); + } else { + throw new TypeError( + `Invalid value given to form, expected a string, number, boolean, object, Array, File or Blob but got ${value} instead`, + ); + } +}; diff --git a/src/sdk/internal/utils.ts b/src/sdk/internal/utils.ts new file mode 100644 index 0000000..986ff46 --- /dev/null +++ b/src/sdk/internal/utils.ts @@ -0,0 +1,9 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export * from './utils/values'; +export * from './utils/base64'; +export * from './utils/env'; +export * from './utils/log'; +export * from './utils/uuid'; +export * from './utils/sleep'; +export * from './utils/query'; diff --git a/src/sdk/internal/utils/base64.ts b/src/sdk/internal/utils/base64.ts new file mode 100644 index 0000000..44d5e8a --- /dev/null +++ b/src/sdk/internal/utils/base64.ts @@ -0,0 +1,40 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { DedalusError } from '../../core/error'; +import { encodeUTF8 } from './bytes'; + +export const toBase64 = (data: string | Uint8Array | null | undefined): string => { + if (!data) return ''; + + if (typeof (globalThis as any).Buffer !== 'undefined') { + return (globalThis as any).Buffer.from(data).toString('base64'); + } + + if (typeof data === 'string') { + data = encodeUTF8(data); + } + + if (typeof btoa !== 'undefined') { + return btoa(String.fromCharCode.apply(null, data as any)); + } + + throw new DedalusError('Cannot generate base64 string; Expected `Buffer` or `btoa` to be defined'); +}; + +export const fromBase64 = (str: string): Uint8Array => { + if (typeof (globalThis as any).Buffer !== 'undefined') { + const buf = (globalThis as any).Buffer.from(str, 'base64'); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } + + if (typeof atob !== 'undefined') { + const bstr = atob(str); + const buf = new Uint8Array(bstr.length); + for (let i = 0; i < bstr.length; i++) { + buf[i] = bstr.charCodeAt(i); + } + return buf; + } + + throw new DedalusError('Cannot decode base64 string; Expected `Buffer` or `atob` to be defined'); +}; diff --git a/src/sdk/internal/utils/bytes.ts b/src/sdk/internal/utils/bytes.ts new file mode 100644 index 0000000..8da627a --- /dev/null +++ b/src/sdk/internal/utils/bytes.ts @@ -0,0 +1,32 @@ +export function concatBytes(buffers: Uint8Array[]): Uint8Array { + let length = 0; + for (const buffer of buffers) { + length += buffer.length; + } + const output = new Uint8Array(length); + let index = 0; + for (const buffer of buffers) { + output.set(buffer, index); + index += buffer.length; + } + + return output; +} + +let encodeUTF8_: (str: string) => Uint8Array; +export function encodeUTF8(str: string) { + let encoder; + return ( + encodeUTF8_ ?? + ((encoder = new (globalThis as any).TextEncoder()), (encodeUTF8_ = encoder.encode.bind(encoder))) + )(str); +} + +let decodeUTF8_: (bytes: Uint8Array) => string; +export function decodeUTF8(bytes: Uint8Array) { + let decoder; + return ( + decodeUTF8_ ?? + ((decoder = new (globalThis as any).TextDecoder()), (decodeUTF8_ = decoder.decode.bind(decoder))) + )(bytes); +} diff --git a/src/sdk/internal/utils/env.ts b/src/sdk/internal/utils/env.ts new file mode 100644 index 0000000..6212168 --- /dev/null +++ b/src/sdk/internal/utils/env.ts @@ -0,0 +1,18 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +/** + * Read an environment variable. + * + * Trims beginning and trailing whitespace. + * + * Will return undefined if the environment variable doesn't exist or cannot be accessed. + */ +export const readEnv = (env: string): string | undefined => { + if (typeof (globalThis as any).process !== 'undefined') { + return (globalThis as any).process.env?.[env]?.trim() || undefined; + } + if (typeof (globalThis as any).Deno !== 'undefined') { + return (globalThis as any).Deno.env?.get?.(env)?.trim() || undefined; + } + return undefined; +}; diff --git a/src/sdk/internal/utils/log.ts b/src/sdk/internal/utils/log.ts new file mode 100644 index 0000000..ddd44d3 --- /dev/null +++ b/src/sdk/internal/utils/log.ts @@ -0,0 +1,304 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { hasOwn } from './values'; +import { type Dedalus } from '../../client'; +import { RequestOptions } from '../request-options'; + +type LogFn = (message: string, ...rest: unknown[]) => void; +export type Logger = { + error: LogFn; + warn: LogFn; + info: LogFn; + debug: LogFn; +}; +export type LogLevel = 'off' | 'error' | 'warn' | 'info' | 'debug'; + +const levelNumbers = { + off: 0, + error: 200, + warn: 300, + info: 400, + debug: 500, +}; + +export const parseLogLevel = ( + maybeLevel: string | undefined, + sourceName: string, + client: Dedalus, +): LogLevel | undefined => { + if (!maybeLevel) { + return undefined; + } + if (hasOwn(levelNumbers, maybeLevel)) { + return maybeLevel; + } + loggerFor(client).warn( + `${sourceName} was set to ${JSON.stringify(maybeLevel)}, expected one of ${JSON.stringify( + Object.keys(levelNumbers), + )}`, + ); + return undefined; +}; + +function noop() {} + +function makeLogFn(fnLevel: keyof Logger, logger: Logger | undefined, logLevel: LogLevel) { + if (!logger || levelNumbers[fnLevel] > levelNumbers[logLevel]) { + return noop; + } else { + // Don't wrap logger functions, we want the stacktrace intact! + return logger[fnLevel].bind(logger); + } +} + +const noopLogger = { + error: noop, + warn: noop, + info: noop, + debug: noop, +}; + +let cachedLoggers = /* @__PURE__ */ new WeakMap(); + +export function loggerFor(client: Dedalus): Logger { + const logger = client.logger; + const logLevel = client.logLevel ?? 'off'; + if (!logger) { + return noopLogger; + } + + const cachedLogger = cachedLoggers.get(logger); + if (cachedLogger && cachedLogger[0] === logLevel) { + return cachedLogger[1]; + } + + const levelLogger = { + error: makeLogFn('error', logger, logLevel), + warn: makeLogFn('warn', logger, logLevel), + info: makeLogFn('info', logger, logLevel), + debug: makeLogFn('debug', logger, logLevel), + }; + + cachedLoggers.set(logger, [logLevel, levelLogger]); + + return levelLogger; +} + +/** Value a credential is replaced with before it reaches a log line. */ +const REDACTED = '***'; + +/** + * Header names whose value is replaced before a request or response is logged. + * + * The fixed entries are the conventional credential headers, which is all a runtime shared by every + * SDK could know on its own. The generated ones are the headers *this* SDK's auth schemes actually + * send: an OpenAPI `apiKey` scheme names its own header, and `X-Auth-Token` or `PRIVATE-TOKEN` is no + * less a credential for being spelled differently. Without them a `logLevel: "debug"` run prints the + * credential in clear text, and the generated CLI turns that level on with `--debug`, so it reaches + * terminals and CI logs alike. + */ +const REDACTED_HEADERS: ReadonlySet = new Set([ + 'authorization', + 'api-key', + 'x-api-key', + 'cookie', + 'set-cookie', +]); + +/** + * Query parameters this SDK sends a credential in, from its `apiKey` schemes with `in: query`. + * + * A credential in the query string is worse off than one in a header: the request URL is logged at + * `info` as well as at `debug`, so it leaks a level below the one a reader would think of as + * verbose. + */ +const REDACTED_QUERY_PARAMS: readonly string[] = []; + +/** + * Blanks the userinfo of a URL, which is a credential wherever it appears. + * + * A `--base-url` carries whatever the caller typed, and this repo already treats userinfo as secret + * where it handles a base URL elsewhere (`profileKey` blanks it before a URL becomes a keychain + * account). Both halves go rather than the password alone: a token is as often the username + * (`https://@host`) as the password, so keeping either back still prints one shape in full. + */ +const redactUserinfo = (url: string): string => { + const scheme = url.indexOf('://'); + if (scheme === -1) return url; + const start = scheme + 3; + // The authority ends at the first of these; an `@` past one of them belongs to a path or a query. + let end = url.length; + for (const delimiter of ['/', '?', '#']) { + const at = url.indexOf(delimiter, start); + if (at !== -1 && at < end) end = at; + } + const at = url.lastIndexOf('@', end); + return at === -1 || at < start ? url : url.slice(0, start) + REDACTED + url.slice(at); +}; + +/** + * Decodes one `application/x-www-form-urlencoded` parameter name, for comparison against the list. + * + * A name reaches the URL percent-encoded, so `api%5Fkey` has to match `api_key`. A malformed escape + * comes back as written rather than throwing: a log line is no place to fail over one, and a name + * that cannot be decoded cannot match the list anyway. + */ +const decodeQueryName = (name: string): string => { + try { + return decodeURIComponent(name.replace(/\+/gu, ' ')); + } catch { + return name; + } +}; + +/** + * Replaces the value of every credential-bearing query parameter in a URL about to be logged. + * + * Rewritten on the query substring rather than through `new URL`, so a relative URL is handled the + * same way an absolute one is and no parse can throw on the logging path. + */ +export const redactUrl = (url: string): string => { + const url_ = redactUserinfo(url); + if (REDACTED_QUERY_PARAMS.length === 0) return url_; + const mark = url_.indexOf('?'); + if (mark === -1) return url_; + // A fragment is not part of the query, so splitting it off keeps it out of the rewrite. + const fragment = url_.indexOf('#', mark); + const end = fragment === -1 ? url_.length : fragment; + // Each pair is rewritten where it stands rather than round-tripped through `URLSearchParams`, + // whose serializer re-spells every *other* parameter in its own safe set -- a space comes back as + // `+`, a `~` as `%7E` -- and a logged URL that is not the one the request used is a URL nobody can + // paste back. Splitting and rejoining on `&` is lossless, so a URL carrying no credential comes + // out of this exactly as it went in. + const query = url_ + .slice(mark + 1, end) + .split('&') + .map((pair) => { + const equals = pair.indexOf('='); + // A parameter with no `=` carries no value, so there is nothing in it to redact. + if (equals === -1) return pair; + const name = pair.slice(0, equals); + return REDACTED_QUERY_PARAMS.includes(decodeQueryName(name)) ? name + '=' + REDACTED : pair; + }) + .join('&'); + return url_.slice(0, mark + 1) + query + url_.slice(end); +}; + +/** + * Replaces credential values in a caller-supplied query object. + * + * The client merges its own auth query into the URL, but a caller may pass the same parameter here + * and `buildURL` lets that value win — so it is as live a credential as the one in the URL. + */ +const redactQuery = (query: object): object => { + const redacted: Record = { ...(query as Record) }; + for (const name of REDACTED_QUERY_PARAMS) { + if (hasOwn(redacted, name)) redacted[name] = REDACTED; + } + return redacted; +}; + +/** Replaces the value of every credential-bearing header, for a `Headers` or a plain record alike. */ +export const redactHeaders = (headers: Headers | Record): Record => + Object.fromEntries( + (headers instanceof Headers ? [...headers] : Object.entries(headers)).map(([name, value]) => [ + name, + REDACTED_HEADERS.has(name.toLowerCase()) ? REDACTED : value, + ]), + ); + +/** URL-shaped runs inside free text. Whitespace and quotes end one; see {@link TRAILING_PUNCTUATION}. */ +const URL_IN_TEXT = /\bhttps?:\/\/[^\s"'<>]+/gu; + +/** + * Punctuation that ends a sentence or closes a bracket rather than belonging to the URL. + * + * Trimmed back after the match instead of being excluded from it, because an IPv6 host carries its + * own `]` (`http://[::1]:8080/p?api_key=…`). Excluding the character cut the match short at the + * host and left the query — credential included — in the text. + */ +const TRAILING_PUNCTUATION = /[.,;:!?)\]}]+$/u; + +/** + * Redacts every URL quoted inside free text, leaving the rest of the text alone. + * + * Runs for every SDK, not only those with a query credential: Node's `fetch` rejects a URL carrying + * userinfo with a message that quotes the whole URL back (`Request cannot be constructed from a URL + * that includes credentials: …`), and that message is logged on the connection-failure path. Gating + * this on the query list left the password in the log for every SDK whose document happens not to + * put a credential in the query. + * + * Only the matched URL is rewritten, never the whole string: a message is not a query string, and + * handing one to `URLSearchParams` wholesale would swallow everything after the first parameter and + * take the diagnostic with it. + */ +const redactUrlsInText = (text: string): string => + text.replace(URL_IN_TEXT, (match) => { + const trailing = match.match(TRAILING_PUNCTUATION)?.[0] ?? ''; + return redactUrl(match.slice(0, match.length - trailing.length)) + trailing; + }); + +export const formatRequestDetails = (details: { + options?: RequestOptions | undefined; + headers?: Headers | Record | undefined; + retryOfRequestLogID?: string | undefined; + retryOf?: string | undefined; + url?: string | undefined; + status?: number | undefined; + method?: string | undefined; + durationMs?: number | undefined; + message?: unknown; + body?: unknown; +}) => { + if (details.options) { + // Swept through a record view rather than field by field: not every field below is declared on + // `RequestOptions` in every profile (`serverURL` belongs to the Speakeasy compatibility one), + // and the copy is what keeps the caller's own options object untouched. + const options: Record = { ...details.options }; + delete options['headers']; // redundant + leaks internals + // The Speakeasy profile re-adds a header bag under `fetchOptions`, and the client reads it as + // the request's headers whenever the flattened one is absent -- so it holds the credential a + // migrated call site passes. Dropped for the same reason as the field above rather than + // redacted, because `HeadersLike` also takes shapes (an array of pairs, `null`) that the header + // redaction does not, and the sent headers are already reported beside this, redacted. + const fetchOptions = options['fetchOptions']; + if (fetchOptions && typeof fetchOptions === 'object') { + const copy: Record = { ...(fetchOptions as Record) }; + delete copy['headers']; + options['fetchOptions'] = copy; + } + const query = options['query']; + if (query && typeof query === 'object') options['query'] = redactQuery(query); + // These three are URL strings, not route fragments: `buildURL` takes an absolute `path` whole -- + // userinfo, query string and all -- while `defaultBaseURL` and the Speakeasy profile's + // `serverURL` each replace the base URL outright. The caller-supplied escape hatches therefore + // carry a credential exactly as readily as the resolved URL beside them does. `serverURL` may + // be a `URL`, whose `username` and `password` print as fields of their own, so the redacted + // string goes back rather than the object. + for (const field of ['path', 'defaultBaseURL', 'serverURL']) { + const value = options[field]; + if (typeof value === 'string') options[field] = redactUrl(value); + else if (value instanceof URL) options[field] = redactUrl(value.toString()); + } + details.options = options as RequestOptions; + } + if (details.url) { + details.url = redactUrl(details.url); + } + if (details.headers) { + details.headers = redactHeaders(details.headers); + } + // The message is whatever the fetch implementation threw, and Deno's connection errors quote the + // request URL inside their text (see the note at the `connection failed` call sites), so a query + // credential rides along in a field none of the checks above look at. + if (typeof details.message === 'string') { + details.message = redactUrlsInText(details.message); + } + if ('retryOfRequestLogID' in details) { + if (details.retryOfRequestLogID) { + details.retryOf = details.retryOfRequestLogID; + } + delete details.retryOfRequestLogID; + } + return details; +}; diff --git a/src/sdk/internal/utils/path.ts b/src/sdk/internal/utils/path.ts new file mode 100644 index 0000000..3ac1ed7 --- /dev/null +++ b/src/sdk/internal/utils/path.ts @@ -0,0 +1,124 @@ +import { DedalusError } from '../../core/error'; + +/** + * Percent-encode everything that isn't safe to have in a path without encoding safe chars. + * + * Taken from https://datatracker.ietf.org/doc/html/rfc3986#section-3.3: + * > unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + * > sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" + * > pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + */ +export function encodeURIPath(str: string) { + return str.replace(/[^A-Za-z0-9\-._~!$&'()*+,;=:@]+/g, encodeURIComponent); +} + +/** + * Like {@link encodeURIPath} but leaves `/` unescaped, implementing RFC 6570 reserved expansion + * (`{+var}`) for path-like parameters such as a file path. The slash is part of the route shape for + * these params — `docs/example.txt` must stay nested under `.../files/docs/example.txt` rather than + * collapsing to a single `docs%2Fexample.txt` segment that points at a different backend path. Each + * `/`-separated segment is still run through {@link encodeURIPath}, so every other unsafe character is + * percent-encoded with the exact same rules, and the tag function below still rejects `.`/`..` segments, + * so a reserved value cannot smuggle in path traversal. + */ +export function encodeURIPathReserved(str: string) { + return str.split('/').map(encodeURIPath).join('/'); +} + +/** + * Wrapper marking a path-parameter value for reserved expansion. The {@link createPathTagFunction} tag + * detects this instance and encodes its value with {@link encodeURIPathReserved} (slash-preserving) + * instead of the default per-segment encoder, while leaving all other interpolated params untouched. + */ +class ReservedPathParam { + constructor(readonly value: string) {} + toString(): string { + return this.value; + } +} + +/** + * Marks a value so the `path` tag preserves `/` when encoding it (RFC 6570 reserved expansion). Used by + * generated request paths for parameters the spec flags with `allowReserved` (e.g. file paths). + */ +export const reserved = (value: unknown): ReservedPathParam => new ReservedPathParam('' + value); + +const EMPTY = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.create(null)); + +export const createPathTagFunction = (pathEncoder = encodeURIPath) => + function path(statics: readonly string[], ...params: readonly unknown[]): string { + // If there are no params, no processing is needed. + if (statics.length === 1) return statics[0]!; + + let postPath = false; + const invalidSegments = []; + const path = statics.reduce((previousValue, currentValue, index) => { + if (/[?#]/.test(currentValue)) { + postPath = true; + } + const param = params[index]; + // Reserved params keep `/` (file-path-like values); everything else uses the default encoder. + const isReserved = param instanceof ReservedPathParam; + const value = isReserved ? param.value : param; + let encoded = (postPath ? encodeURIComponent : isReserved ? encodeURIPathReserved : pathEncoder)( + '' + value, + ); + if ( + index !== params.length && + (value == null || + (typeof value === 'object' && + // handle values from other realms + value.toString === + Object.getPrototypeOf(Object.getPrototypeOf((value as any).hasOwnProperty ?? EMPTY) ?? EMPTY) + ?.toString)) + ) { + encoded = value + ''; + invalidSegments.push({ + start: previousValue.length + currentValue.length, + length: encoded.length, + error: `Value of type ${Object.prototype.toString + .call(value) + .slice(8, -1)} is not a valid path parameter`, + }); + } + return previousValue + currentValue + (index === params.length ? '' : encoded); + }, ''); + + const pathOnly = path.split(/[?#]/, 1)[0]!; + const invalidSegmentPattern = /(?<=^|\/)(?:\.|%2e){1,2}(?=\/|$)/gi; + let match; + + // Find all invalid segments + while ((match = invalidSegmentPattern.exec(pathOnly)) !== null) { + invalidSegments.push({ + start: match.index, + length: match[0].length, + error: `Value "${match[0]}" can\'t be safely passed as a path parameter`, + }); + } + + invalidSegments.sort((a, b) => a.start - b.start); + + if (invalidSegments.length > 0) { + let lastEnd = 0; + const underline = invalidSegments.reduce((acc, segment) => { + const spaces = ' '.repeat(segment.start - lastEnd); + const arrows = '^'.repeat(segment.length); + lastEnd = segment.start + segment.length; + return acc + spaces + arrows; + }, ''); + + throw new DedalusError( + `Path parameters result in path with invalid segments:\n${invalidSegments + .map((e) => e.error) + .join('\n')}\n${path}\n${underline}`, + ); + } + + return path; + }; + +/** + * URI-encodes path params and ensures no unsafe /./ or /../ path segments are introduced. + */ +export const path = /* @__PURE__ */ createPathTagFunction(encodeURIPath); diff --git a/src/sdk/internal/utils/query.ts b/src/sdk/internal/utils/query.ts new file mode 100644 index 0000000..638b5a0 --- /dev/null +++ b/src/sdk/internal/utils/query.ts @@ -0,0 +1,10 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import * as qs from '../qs/stringify'; + +export function stringifyQuery(query: object | Record): string { + // Substituted from the same config as the client's own `stringifyQuery`; this module is re-exported + // through the package's `./*` subpath, so a hardcoded format here would serialize arrays differently + // from every generated request. + return qs.stringify(query, { arrayFormat: 'comma', allowDots: false }); +} diff --git a/src/sdk/internal/utils/sleep.ts b/src/sdk/internal/utils/sleep.ts new file mode 100644 index 0000000..f8d0a12 --- /dev/null +++ b/src/sdk/internal/utils/sleep.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/sdk/internal/utils/uuid.ts b/src/sdk/internal/utils/uuid.ts new file mode 100644 index 0000000..94878fa --- /dev/null +++ b/src/sdk/internal/utils/uuid.ts @@ -0,0 +1,17 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +/** + * https://stackoverflow.com/a/2117523 + */ +export let uuid4 = function () { + const { crypto } = globalThis as any; + if (crypto?.randomUUID) { + uuid4 = crypto.randomUUID.bind(crypto); + return crypto.randomUUID(); + } + const u8 = new Uint8Array(1); + const randomByte = crypto ? () => crypto.getRandomValues(u8)[0]! : () => (Math.random() * 0xff) & 0xff; + return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => + (+c ^ (randomByte() & (15 >> (+c / 4)))).toString(16), + ); +}; diff --git a/src/sdk/internal/utils/values.ts b/src/sdk/internal/utils/values.ts new file mode 100644 index 0000000..6361499 --- /dev/null +++ b/src/sdk/internal/utils/values.ts @@ -0,0 +1,105 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { DedalusError } from '../../core/error'; + +// https://url.spec.whatwg.org/#url-scheme-string +const startsWithSchemeRegexp = /^[a-z][a-z0-9+.-]*:/i; + +export const isAbsoluteURL = (url: string): boolean => { + return startsWithSchemeRegexp.test(url); +}; + +export let isArray = (val: unknown): val is unknown[] => ((isArray = Array.isArray), isArray(val)); +export let isReadonlyArray = isArray as (val: unknown) => val is readonly unknown[]; + +/** Returns an object if the given value isn't an object, otherwise returns as-is */ +export function maybeObj(x: unknown): Record { + if (typeof x !== 'object') { + return {}; + } + + return (x ?? {}) as Record; +} + +// https://stackoverflow.com/a/34491287 +export function isEmptyObj(obj: Object | null | undefined): boolean { + if (!obj) return true; + for (const _k in obj) return false; + return true; +} + +// https://eslint.org/docs/latest/rules/no-prototype-builtins +export function hasOwn(obj: T, key: PropertyKey): key is keyof T { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +export function isObj(obj: unknown): obj is Record { + return obj != null && typeof obj === 'object' && !Array.isArray(obj); +} + +export const ensurePresent = (value: T | null | undefined): T => { + if (value == null) { + throw new DedalusError(`Expected a value to be given but received ${value} instead.`); + } + + return value; +}; + +export const validatePositiveInteger = (name: string, n: unknown): number => { + if (typeof n !== 'number' || !Number.isInteger(n)) { + throw new DedalusError(`${name} must be an integer`); + } + if (n < 0) { + throw new DedalusError(`${name} must be a positive integer`); + } + return n; +}; + +export const coerceInteger = (value: unknown): number => { + if (typeof value === 'number') return Math.round(value); + if (typeof value === 'string') return parseInt(value, 10); + + throw new DedalusError(`Could not coerce ${value} (type: ${typeof value}) into a number`); +}; + +export const coerceFloat = (value: unknown): number => { + if (typeof value === 'number') return value; + if (typeof value === 'string') return parseFloat(value); + + throw new DedalusError(`Could not coerce ${value} (type: ${typeof value}) into a number`); +}; + +export const coerceBoolean = (value: unknown): boolean => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === 'true'; + return Boolean(value); +}; + +export const maybeCoerceInteger = (value: unknown): number | undefined => { + if (value == null) { + return undefined; + } + return coerceInteger(value); +}; + +export const maybeCoerceFloat = (value: unknown): number | undefined => { + if (value == null) { + return undefined; + } + return coerceFloat(value); +}; + +export const maybeCoerceBoolean = (value: unknown): boolean | undefined => { + if (value == null) { + return undefined; + } + return coerceBoolean(value); +}; + +export const safeJSON = (text: string) => { + try { + return JSON.parse(text); + } catch (err) { + return undefined; + } +}; diff --git a/src/sdk/pagination.ts b/src/sdk/pagination.ts new file mode 100644 index 0000000..60f19a7 --- /dev/null +++ b/src/sdk/pagination.ts @@ -0,0 +1,4 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +/** @deprecated Import from ./core/pagination instead */ +export * from './core/pagination'; diff --git a/src/sdk/resource.ts b/src/sdk/resource.ts new file mode 100644 index 0000000..066a2ba --- /dev/null +++ b/src/sdk/resource.ts @@ -0,0 +1,11 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import type { Dedalus } from './client'; + +export abstract class APIResource { + protected _client: Dedalus; + + constructor(client: Dedalus) { + this._client = client; + } +} diff --git a/src/sdk/resources.ts b/src/sdk/resources.ts new file mode 100644 index 0000000..11bb295 --- /dev/null +++ b/src/sdk/resources.ts @@ -0,0 +1,21 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export { Machines, Organization } from './resources/index'; +export type { + Machine, + MachineList, + MachineListItem, + CreateParams, + UpdateParams, + LifecycleStatus, + MachineListParams, + MachineListItemsCursorPage, + MachineCreateParams, + MachineRetrieveParams, + MachineRetrieveResponse, + MachineUpdateParams, + MachineDeleteParams, + MachineSleepParams, + MachineWakeParams, + MachineRebootParams, +} from './resources/index'; diff --git a/src/sdk/resources/index.ts b/src/sdk/resources/index.ts new file mode 100644 index 0000000..25ae9f1 --- /dev/null +++ b/src/sdk/resources/index.ts @@ -0,0 +1,22 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export { Machines } from './machines/machines'; +export type { + Machine, + MachineList, + MachineListItem, + CreateParams, + UpdateParams, + LifecycleStatus, + MachineListParams, + MachineListItemsCursorPage, + MachineCreateParams, + MachineRetrieveParams, + MachineRetrieveResponse, + MachineUpdateParams, + MachineDeleteParams, + MachineSleepParams, + MachineWakeParams, + MachineRebootParams, +} from './machines/machines'; +export { Organization } from './organization/organization'; diff --git a/src/sdk/resources/machines.ts b/src/sdk/resources/machines.ts new file mode 100644 index 0000000..fb16b1b --- /dev/null +++ b/src/sdk/resources/machines.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export * from './machines/index'; diff --git a/src/sdk/resources/machines/autoresizing.ts b/src/sdk/resources/machines/autoresizing.ts new file mode 100644 index 0000000..e5af9dc --- /dev/null +++ b/src/sdk/resources/machines/autoresizing.ts @@ -0,0 +1,82 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { APIResource } from '../../resource'; +import { APIPromise } from '../../api-promise'; +import type { RequestOptions } from '../../internal/request-options'; +import { path as __scalarPath } from '../../internal/utils/path'; + +export class Autoresizing extends APIResource { + /** + * Read this machine's RAM autoresizing settings + * + * @param {AutoresizingRetrieveParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const settings = await client.machines.autoresizing.retrieve({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * }); + * ``` + */ + retrieve(params: AutoresizingRetrieveParams, options?: RequestOptions): APIPromise { + const { machine_id } = params; + return this._client.get(__scalarPath`/v1/machines/${machine_id}/autoresizing`, options); + } + + /** + * Set this machine's RAM autoresizing settings + * + * @param {AutoresizingUpdateParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const settings = await client.machines.autoresizing.update({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * enabled: false, + * }); + * ``` + */ + update(params: AutoresizingUpdateParams, options?: RequestOptions): APIPromise { + const { machine_id, ...body } = params; + return this._client.put(__scalarPath`/v1/machines/${machine_id}/autoresizing`, { body, ...options }); + } +} + +export interface Settings { + enabled: boolean; +} + +export interface AutoresizingRetrieveParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; +} + +export interface AutoresizingUpdateParams { + /** + * Path param: Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * Body param + */ + enabled: boolean; +} +export declare namespace Autoresizing { + export { + type Settings as Settings, + type AutoresizingRetrieveParams as AutoresizingRetrieveParams, + type AutoresizingUpdateParams as AutoresizingUpdateParams, + }; +} diff --git a/src/sdk/resources/machines/executions.ts b/src/sdk/resources/machines/executions.ts new file mode 100644 index 0000000..0349f9d --- /dev/null +++ b/src/sdk/resources/machines/executions.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export * from './executions/index'; diff --git a/src/sdk/resources/machines/executions/executions.ts b/src/sdk/resources/machines/executions/executions.ts new file mode 100644 index 0000000..516f697 --- /dev/null +++ b/src/sdk/resources/machines/executions/executions.ts @@ -0,0 +1,430 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { APIResource } from '../../../resource'; +import { APIPromise } from '../../../api-promise'; +import { CursorPage, type CursorPageParams, type PagePromise } from '../../../core/pagination'; +import type { RequestOptions } from '../../../internal/request-options'; +import { path as __scalarPath } from '../../../internal/utils/path'; +import * as LogsAPI from './logs'; +import { + Logs, + type Status, + type ReadToken, + type LogRetrieveParams, + type LogReauthorizeParams, + type LogCreateTokenParams, +} from './logs'; + +export class Executions extends APIResource { + logs: LogsAPI.Logs = new LogsAPI.Logs(this._client); + + /** + * List executions + * + * @param {ExecutionListParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {PagePromise} OK + * + * @example + * ```ts + * const page = await client.machines.executions.list({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * }); + * ``` + */ + list(params: ExecutionListParams, options?: RequestOptions): PagePromise { + const { machine_id, ...query } = params; + return this._client.getAPIList( + __scalarPath`/v1/machines/${machine_id}/executions`, + CursorPage, + { query, ...options }, + ); + } + + /** + * Create execution + * + * @param {ExecutionCreateParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const execution = await client.machines.executions.create({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * command: [''], + * }); + * ``` + */ + create(params: ExecutionCreateParams, options?: RequestOptions): APIPromise { + const { machine_id, ...body } = params; + return this._client.post(__scalarPath`/v1/machines/${machine_id}/executions`, { body, ...options }); + } + + /** + * Get execution + * + * @param {ExecutionRetrieveParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const execution = await client.machines.executions.retrieve({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * execution_id: 'executionID', + * }); + * ``` + */ + retrieve(params: ExecutionRetrieveParams, options?: RequestOptions): APIPromise { + const { machine_id, execution_id } = params; + return this._client.get(__scalarPath`/v1/machines/${machine_id}/executions/${execution_id}`, options); + } + + /** + * Delete execution + * + * @param {ExecutionDeleteParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const execution = await client.machines.executions.delete({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * execution_id: 'executionID', + * }); + * ``` + */ + delete(params: ExecutionDeleteParams, options?: RequestOptions): APIPromise { + const { machine_id, execution_id } = params; + return this._client.delete(__scalarPath`/v1/machines/${machine_id}/executions/${execution_id}`, options); + } + + /** + * Get execution output + * + * @param {ExecutionOutputParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const execution = await client.machines.executions.output({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * execution_id: 'executionID', + * }); + * ``` + */ + output(params: ExecutionOutputParams, options?: RequestOptions): APIPromise { + const { machine_id, execution_id } = params; + return this._client.get( + __scalarPath`/v1/machines/${machine_id}/executions/${execution_id}/output`, + options, + ); + } + + /** + * List execution events + * + * @param {ExecutionEventsParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {PagePromise} OK + * + * @example + * ```ts + * const page = await client.machines.executions.events({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * execution_id: 'executionID', + * }); + * ``` + */ + events( + params: ExecutionEventsParams, + options?: RequestOptions, + ): PagePromise { + const { machine_id, execution_id, ...query } = params; + return this._client.getAPIList( + __scalarPath`/v1/machines/${machine_id}/executions/${execution_id}/events`, + CursorPage, + { query, ...options }, + ); + } +} + +export interface ExecutionCreateParams { + command: Array | null; + cwd?: string; + env?: Record; + stdin?: string; + /** + * @format int64 + */ + timeout_ms?: number; +} + +export interface Execution { + command: Array | null; + /** + * @format date-time + */ + created_at: string; + execution_id: string; + log_capture: Execution.LogCapture; + /** + * @format uuid + */ + machine_id: string; + status: 'wake_in_progress' | 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'expired'; + artifacts?: Array | null; + /** + * @format date-time + */ + completed_at?: string; + creation_request_id?: string; + creation_trace_id?: string; + cwd?: string; + env_keys?: Array | null; + error_code?: string; + error_message?: string; + /** + * @format int64 + */ + exit_code?: number; + /** + * @format date-time + */ + expires_at?: string; + /** + * @format int64 + */ + retry_after_ms?: number; + /** + * @format int64 + */ + signal?: number; + /** + * @format date-time + */ + started_at?: string; + /** + * @format int64 + */ + stderr_bytes?: number; + stderr_truncated?: boolean; + /** + * @format int64 + */ + stdout_bytes?: number; + stdout_truncated?: boolean; +} + +export namespace Execution { + export interface LogCapture { + code?: string; + /** + * @maxLength 20 + * @pattern ^(0|[1-9][0-9]*)$ + */ + lost_bytes?: string; + state?: 'pending' | 'incomplete' | 'unavailable'; + /** + * @maxLength 20 + * @pattern ^(0|[1-9][0-9]*)$ + */ + unconfirmed_bytes?: string; + } +} + +export interface ExecutionList { + items: Array | null; + next_cursor?: string; +} + +export interface ExecutionOutput { + execution_id: string; + stderr?: string; + /** + * @format int64 + */ + stderr_bytes?: number; + stderr_truncated?: boolean; + stdout?: string; + /** + * @format int64 + */ + stdout_bytes?: number; + stdout_truncated?: boolean; +} + +export interface ExecutionEvent { + /** + * @format date-time + */ + at: string; + /** + * @format int64 + */ + sequence: number; + type: 'lifecycle' | 'stdout' | 'stderr'; + chunk?: string; + error_code?: string; + error_message?: string; + /** + * @format int64 + */ + exit_code?: number; + /** + * @format int64 + */ + signal?: number; + status?: 'wake_in_progress' | 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'expired'; +} + +export interface ExecutionEvents { + items: Array | null; + next_cursor?: string; +} + +export interface ArtifactRef { + artifact_id: string; + name: string; +} + +export interface ExecutionListParams extends CursorPageParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; +} + +export type ExecutionsCursorPage = CursorPage; + +export interface ExecutionCreateParams { + /** + * Path param: Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * Body param + */ + command: Array | null; + /** + * Body param + */ + cwd?: string; + /** + * Body param + */ + env?: Record; + /** + * Body param + */ + stdin?: string; + /** + * Body param + * @format int64 + */ + timeout_ms?: number; +} + +export interface ExecutionRetrieveParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * @minLength 1 + * @maxLength 253 + * @pattern ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ + */ + execution_id: string; +} + +export interface ExecutionDeleteParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * @minLength 1 + * @maxLength 253 + * @pattern ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ + */ + execution_id: string; +} + +export interface ExecutionOutputParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * @minLength 1 + * @maxLength 253 + * @pattern ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ + */ + execution_id: string; +} + +export interface ExecutionEventsParams extends CursorPageParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * @minLength 1 + * @maxLength 253 + * @pattern ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ + */ + execution_id: string; +} + +export type ExecutionEventsCursorPage = CursorPage; +Executions.Logs = Logs; + +export declare namespace Executions { + export { + type ExecutionCreateParams as ExecutionCreateParams, + type Execution as Execution, + type ExecutionList as ExecutionList, + type ExecutionOutput as ExecutionOutput, + type ExecutionEvent as ExecutionEvent, + type ExecutionEvents as ExecutionEvents, + type ArtifactRef as ArtifactRef, + type ExecutionsCursorPage as ExecutionsCursorPage, + type ExecutionEventsCursorPage as ExecutionEventsCursorPage, + type ExecutionListParams as ExecutionListParams, + type ExecutionRetrieveParams as ExecutionRetrieveParams, + type ExecutionDeleteParams as ExecutionDeleteParams, + type ExecutionOutputParams as ExecutionOutputParams, + type ExecutionEventsParams as ExecutionEventsParams, + }; + + export { + Logs as Logs, + type Status as Status, + type ReadToken as ReadToken, + type LogRetrieveParams as LogRetrieveParams, + type LogReauthorizeParams as LogReauthorizeParams, + type LogCreateTokenParams as LogCreateTokenParams, + }; +} diff --git a/src/sdk/resources/machines/executions/index.ts b/src/sdk/resources/machines/executions/index.ts new file mode 100644 index 0000000..6989d90 --- /dev/null +++ b/src/sdk/resources/machines/executions/index.ts @@ -0,0 +1,27 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export { Executions } from './executions'; +export type { + ExecutionCreateParams, + Execution, + ExecutionList, + ExecutionOutput, + ExecutionEvent, + ExecutionEvents, + ArtifactRef, + ExecutionListParams, + ExecutionsCursorPage, + ExecutionRetrieveParams, + ExecutionDeleteParams, + ExecutionOutputParams, + ExecutionEventsParams, + ExecutionEventsCursorPage, +} from './executions'; +export { Logs } from './logs'; +export type { + Status, + ReadToken, + LogRetrieveParams, + LogReauthorizeParams, + LogCreateTokenParams, +} from './logs'; diff --git a/src/sdk/resources/machines/executions/logs.ts b/src/sdk/resources/machines/executions/logs.ts new file mode 100644 index 0000000..88b2f20 --- /dev/null +++ b/src/sdk/resources/machines/executions/logs.ts @@ -0,0 +1,182 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { APIResource } from '../../../resource'; +import { APIPromise } from '../../../api-promise'; +import type { RequestOptions } from '../../../internal/request-options'; +import { path as __scalarPath } from '../../../internal/utils/path'; + +export class Logs extends APIResource { + /** + * Get execution log status + * + * @param {LogRetrieveParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const status = await client.machines.executions.logs.retrieve({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * execution_id: 'executionID', + * }); + * ``` + */ + retrieve(params: LogRetrieveParams, options?: RequestOptions): APIPromise { + const { machine_id, execution_id } = params; + return this._client.get( + __scalarPath`/v1/machines/${machine_id}/executions/${execution_id}/logs`, + options, + ); + } + + /** + * Reauthorize execution log publication + * + * @param {LogReauthorizeParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const status = await client.machines.executions.logs.reauthorize({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * execution_id: 'executionID', + * }); + * ``` + */ + reauthorize(params: LogReauthorizeParams, options?: RequestOptions): APIPromise { + const { machine_id, execution_id } = params; + return this._client.post( + __scalarPath`/v1/machines/${machine_id}/executions/${execution_id}/logs/reauthorize`, + options, + ); + } + + /** + * Create execution log read token + * + * @param {LogCreateTokenParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const readToken = await client.machines.executions.logs.createToken({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * execution_id: 'executionID', + * }); + * ``` + */ + createToken(params: LogCreateTokenParams, options?: RequestOptions): APIPromise { + const { machine_id, execution_id } = params; + return this._client.post( + __scalarPath`/v1/machines/${machine_id}/executions/${execution_id}/logs/token`, + options, + ); + } +} + +export interface Status { + capture: Status.Capture; + execution_id: string; + code?: string; + publication?: Status.Publication; + stream_id?: string; +} + +export namespace Status { + export interface Capture { + code?: string; + /** + * @maxLength 20 + * @pattern ^(0|[1-9][0-9]*)$ + */ + lost_bytes?: string; + state?: 'pending' | 'incomplete' | 'unavailable'; + /** + * @maxLength 20 + * @pattern ^(0|[1-9][0-9]*)$ + */ + unconfirmed_bytes?: string; + } + + export interface Publication { + complete: boolean; + /** + * @format int64 + * @minimum 0 + */ + queued_messages: number; + state: 'active' | 'finishing' | 'failed' | 'closed'; + code?: string; + } +} + +export interface ReadToken { + access_token: string; + base_url: string; + execution_id: string; + /** + * @format date-time + */ + expires_at: string; + stream_id: string; + token_type: string; +} + +export interface LogRetrieveParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * @minLength 1 + * @maxLength 253 + * @pattern ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ + */ + execution_id: string; +} + +export interface LogReauthorizeParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * @minLength 1 + * @maxLength 253 + * @pattern ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ + */ + execution_id: string; +} + +export interface LogCreateTokenParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * @minLength 1 + * @maxLength 253 + * @pattern ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ + */ + execution_id: string; +} +export declare namespace Logs { + export { + type Status as Status, + type ReadToken as ReadToken, + type LogRetrieveParams as LogRetrieveParams, + type LogReauthorizeParams as LogReauthorizeParams, + type LogCreateTokenParams as LogCreateTokenParams, + }; +} diff --git a/src/sdk/resources/machines/index.ts b/src/sdk/resources/machines/index.ts new file mode 100644 index 0000000..3c78d73 --- /dev/null +++ b/src/sdk/resources/machines/index.ts @@ -0,0 +1,53 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export { Machines } from './machines'; +export type { + Machine, + MachineList, + MachineListItem, + CreateParams, + UpdateParams, + LifecycleStatus, + MachineListParams, + MachineListItemsCursorPage, + MachineCreateParams, + MachineRetrieveParams, + MachineRetrieveResponse, + MachineUpdateParams, + MachineDeleteParams, + MachineSleepParams, + MachineWakeParams, + MachineRebootParams, +} from './machines'; +export { SSH } from './ssh'; +export type { + SSHSessionCreateParams, + SSHSession, + SSHSessionList, + SSHConnection, + SSHHostTrust, + SSHListParams, + SSHSessionsCursorPage, + SSHCreateParams, + SSHRetrieveParams, + SSHDeleteParams, +} from './ssh'; +export { Executions } from './executions/executions'; +export type { + ExecutionCreateParams, + Execution, + ExecutionList, + ExecutionOutput, + ExecutionEvent, + ExecutionEvents, + ArtifactRef, + ExecutionListParams, + ExecutionsCursorPage, + ExecutionRetrieveParams, + ExecutionDeleteParams, + ExecutionOutputParams, + ExecutionEventsParams, + ExecutionEventsCursorPage, +} from './executions/executions'; +export { Autoresizing } from './autoresizing'; +export type { Settings, AutoresizingRetrieveParams, AutoresizingUpdateParams } from './autoresizing'; diff --git a/src/sdk/resources/machines/machines.ts b/src/sdk/resources/machines/machines.ts new file mode 100644 index 0000000..6889c05 --- /dev/null +++ b/src/sdk/resources/machines/machines.ts @@ -0,0 +1,608 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { APIResource } from '../../resource'; +import { APIPromise } from '../../api-promise'; +import { CursorPage, type CursorPageParams, type PagePromise } from '../../core/pagination'; +import type { RequestOptions } from '../../internal/request-options'; +import { path as __scalarPath } from '../../internal/utils/path'; +import * as SSHAPI from './ssh'; +import { + SSH, + type SSHSessionCreateParams, + type SSHSession, + type SSHSessionList, + type SSHConnection, + type SSHHostTrust, + type SSHSessionsCursorPage, + type SSHListParams, + type SSHCreateParams, + type SSHRetrieveParams, + type SSHDeleteParams, +} from './ssh'; +import * as ExecutionsAPI from './executions/executions'; +import { + Executions, + type ExecutionCreateParams, + type Execution, + type ExecutionList, + type ExecutionOutput, + type ExecutionEvent, + type ExecutionEvents, + type ArtifactRef, + type ExecutionsCursorPage, + type ExecutionEventsCursorPage, + type ExecutionListParams, + type ExecutionRetrieveParams, + type ExecutionDeleteParams, + type ExecutionOutputParams, + type ExecutionEventsParams, +} from './executions/executions'; +import * as AutoresizingAPI from './autoresizing'; +import { + Autoresizing, + type Settings, + type AutoresizingRetrieveParams, + type AutoresizingUpdateParams, +} from './autoresizing'; + +export class Machines extends APIResource { + ssh: SSHAPI.SSH = new SSHAPI.SSH(this._client); + executions: ExecutionsAPI.Executions = new ExecutionsAPI.Executions(this._client); + autoresizing: AutoresizingAPI.Autoresizing = new AutoresizingAPI.Autoresizing(this._client); + + /** + * List machines + * + * @param {MachineListParams} [query] - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {PagePromise} OK + * + * @example + * ```ts + * const page = await client.machines.list(); + * ``` + */ + list( + query: MachineListParams | null | undefined = {}, + options?: RequestOptions, + ): PagePromise { + return this._client.getAPIList('/v1/machines', CursorPage, { query, ...options }); + } + + /** + * Create machine + * + * @param {MachineCreateParams} body - The request body to send. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} Create converged inline + * + * @example + * ```ts + * const machine = await client.machines.create({ + * autosleep: '300s', + * memory_mib: 4096, + * storage_gib: 10, + * vcpu: 1, + * }); + * ``` + */ + create(body: MachineCreateParams, options?: RequestOptions): APIPromise { + return this._client.post('/v1/machines', { body, ...options }); + } + + /** + * Get machine + * + * @param {MachineRetrieveParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const machine = await client.machines.retrieve({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * }); + * ``` + */ + retrieve(params: MachineRetrieveParams, options?: RequestOptions): APIPromise { + const { machine_id } = params; + return this._client.get(__scalarPath`/v1/machines/${machine_id}`, options); + } + + /** + * Update machine + * + * @param {MachineUpdateParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const machine = await client.machines.update({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * }); + * ``` + */ + update(params: MachineUpdateParams, options?: RequestOptions): APIPromise { + const { machine_id, ...body } = params; + return this._client.patch(__scalarPath`/v1/machines/${machine_id}`, { body, ...options }); + } + + /** + * Destroy machine + * + * @param {MachineDeleteParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const machine = await client.machines.delete({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * }); + * ``` + */ + delete(params: MachineDeleteParams, options?: RequestOptions): APIPromise { + const { machine_id } = params; + return this._client.delete(__scalarPath`/v1/machines/${machine_id}`, options); + } + + /** + * Sleep a running machine + * + * @param {MachineSleepParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const machine = await client.machines.sleep({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * }); + * ``` + */ + sleep(params: MachineSleepParams, options?: RequestOptions): APIPromise { + const { machine_id } = params; + return this._client.post(__scalarPath`/v1/machines/${machine_id}/sleep`, options); + } + + /** + * Wake a sleeping machine + * + * @param {MachineWakeParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const machine = await client.machines.wake({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * }); + * ``` + */ + wake(params: MachineWakeParams, options?: RequestOptions): APIPromise { + const { machine_id } = params; + return this._client.post(__scalarPath`/v1/machines/${machine_id}/wake`, options); + } + + /** + * Checkpoints files and replaces the runtime. The machine ID and filesystem are preserved. RAM, processes, and temporary mounts are cleared. Poll the machine until its phase is running. Retry the same Idempotency-Key after a lost response. + * + * @param {MachineRebootParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const machine = await client.machines.reboot({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * }); + * ``` + */ + reboot(params: MachineRebootParams, options?: RequestOptions): APIPromise { + const { machine_id, force } = params; + return this._client.post(__scalarPath`/v1/machines/${machine_id}/reboot`, { + query: { force }, + ...options, + }); + } +} + +export interface Machine { + /** + * Seconds of inactivity before autosleep. 0 disables autosleep. + * @format int64 + * @minimum 0 + * @maximum 9223372036 + */ + autosleep_seconds: number; + desired_state: 'running' | 'sleeping' | 'destroyed'; + /** + * @format uuid + */ + machine_id: string; + /** + * Memory in MiB. + * @format int64 + */ + memory_mib: number; + phase: + | 'accepted' + | 'placement_pending' + | 'starting' + | 'running' + | 'stopping' + | 'sleeping' + | 'destroying' + | 'destroyed' + | 'failed'; + /** + * @format int64 + */ + storage_gib: number; + /** + * CPU in vCPUs. + * @format double + */ + vcpu: number; +} + +export interface MachineList { + items: Array | null; + next_cursor?: string; +} + +export interface MachineListItem { + /** + * Seconds of inactivity before autosleep. 0 disables autosleep. + * @format int64 + * @minimum 0 + * @maximum 9223372036 + */ + autosleep_seconds: number; + /** + * @format date-time + */ + created_at: string; + desired_state: 'running' | 'sleeping' | 'destroyed'; + /** + * @format uuid + */ + machine_id: string; + /** + * Memory in MiB. + * @format int64 + */ + memory_mib: number; + phase: + | 'accepted' + | 'placement_pending' + | 'starting' + | 'running' + | 'stopping' + | 'sleeping' + | 'destroying' + | 'destroyed' + | 'failed'; + /** + * @format int64 + */ + storage_gib: number; + /** + * CPU in vCPUs. + * @format double + */ + vcpu: number; +} + +export interface CreateParams { + /** + * Idle window before autosleep. Accepts fixed duration units like 30s, 30m, 2h, 7d3h4s, or 1w3d, raw seconds ("1800"), or never to disable. + * @default 300s + */ + autosleep?: string; + /** + * Memory in MiB. + * @default 4096 + * @format int64 + * @exclusiveMinimum 0 + */ + memory_mib?: number; + /** + * Storage in GiB. + * @default 10 + * @format int64 + * @exclusiveMinimum 0 + */ + storage_gib?: number; + /** + * CPU in vCPUs. + * @default 1 + * @format double + * @exclusiveMinimum 0 + */ + vcpu?: number; +} + +export interface UpdateParams { + /** + * Idle window before autosleep. Accepts fixed duration units like 30s, 30m, 2h, 7d3h4s, or 1w3d, raw seconds ("1800"), or never to disable. + */ + autosleep?: string; + /** + * Memory in MiB. + * @format int64 + */ + memory_mib?: number; + /** + * Storage in GiB. + * @format int64 + */ + storage_gib?: number; + /** + * CPU in vCPUs. + * @format double + */ + vcpu?: number; +} + +export interface LifecycleStatus { + /** + * @format date-time + */ + last_progress_at: string; + /** + * @format date-time + */ + last_transition_at: string; + /** + * Accepted RAM maximum, including completed automatic increases. + * @format int64 + * @minimum 1 + */ + memory_configured_mib: number; + phase: + | 'accepted' + | 'placement_pending' + | 'starting' + | 'running' + | 'stopping' + | 'sleeping' + | 'destroying' + | 'destroyed' + | 'failed'; + reason: string; + retryable: boolean; + revision: string; + last_error?: string; + /** + * Last confirmed RAM allocation for the current running generation. Absent when the allocation is unknown or no longer current. + * @format int64 + * @minimum 1 + */ + memory_assigned_mib?: number; + /** + * Time of the latest confirmed automatic RAM increase. Does not include explicit resizing or a complete change history. + * @format date-time + */ + memory_last_autoresized_at?: string; + /** + * Resize progress reported by the current runtime. A pending automatic target may not yet be applied by that runtime. + */ + memory_resize_state?: 'stable' | 'error' | 'pending_capacity'; + /** + * Pending automatic RAM target, or the current runtime target when no automatic target is pending. + * @format int64 + * @minimum 1 + */ + memory_target_mib?: number; +} + +export interface MachineListParams extends CursorPageParams {} + +export type MachineListItemsCursorPage = CursorPage; + +export interface MachineCreateParams { + /** + * Idle window before autosleep. Accepts fixed duration units like 30s, 30m, 2h, 7d3h4s, or 1w3d, raw seconds ("1800"), or never to disable. + * @default 300s + */ + autosleep?: string; + /** + * Memory in MiB. + * @default 4096 + * @format int64 + * @exclusiveMinimum 0 + */ + memory_mib?: number; + /** + * Storage in GiB. + * @default 10 + * @format int64 + * @exclusiveMinimum 0 + */ + storage_gib?: number; + /** + * CPU in vCPUs. + * @default 1 + * @format double + * @exclusiveMinimum 0 + */ + vcpu?: number; +} + +export interface MachineRetrieveParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; +} + +export interface MachineRetrieveResponse { + /** + * Seconds of inactivity before autosleep. 0 disables autosleep. + * @format int64 + * @minimum 0 + * @maximum 9223372036 + */ + autosleep_seconds: number; + desired_state: 'running' | 'sleeping' | 'destroyed'; + /** + * @format uuid + */ + machine_id: string; + /** + * Memory in MiB. + * @format int64 + */ + memory_mib: number; + status: LifecycleStatus; + /** + * @format int64 + */ + storage_gib: number; + /** + * CPU in vCPUs. + * @format double + */ + vcpu: number; +} + +export interface MachineUpdateParams { + /** + * Path param: Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * Body param: Idle window before autosleep. Accepts fixed duration units like 30s, 30m, 2h, 7d3h4s, or 1w3d, raw seconds ("1800"), or never to disable. + */ + autosleep?: string; + /** + * Body param: Memory in MiB. + * @format int64 + */ + memory_mib?: number; + /** + * Body param: Storage in GiB. + * @format int64 + */ + storage_gib?: number; + /** + * Body param: CPU in vCPUs. + * @format double + */ + vcpu?: number; +} + +export interface MachineDeleteParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; +} + +export interface MachineSleepParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; +} + +export interface MachineWakeParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; +} + +export interface MachineRebootParams { + /** + * Path param: Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * Query param: Recover from the last committed filesystem checkpoint without guest cooperation. Unpublished file writes are lost. The default checkpoints files before rebooting. + */ + force?: boolean; +} +Machines.SSH = SSH; +Machines.Executions = Executions; +Machines.Autoresizing = Autoresizing; + +export declare namespace Machines { + export { + type Machine as Machine, + type MachineList as MachineList, + type MachineListItem as MachineListItem, + type CreateParams as CreateParams, + type UpdateParams as UpdateParams, + type LifecycleStatus as LifecycleStatus, + type MachineListItemsCursorPage as MachineListItemsCursorPage, + type MachineRetrieveResponse as MachineRetrieveResponse, + type MachineListParams as MachineListParams, + type MachineCreateParams as MachineCreateParams, + type MachineRetrieveParams as MachineRetrieveParams, + type MachineUpdateParams as MachineUpdateParams, + type MachineDeleteParams as MachineDeleteParams, + type MachineSleepParams as MachineSleepParams, + type MachineWakeParams as MachineWakeParams, + type MachineRebootParams as MachineRebootParams, + }; + + export { + SSH as SSH, + type SSHSessionCreateParams as SSHSessionCreateParams, + type SSHSession as SSHSession, + type SSHSessionList as SSHSessionList, + type SSHConnection as SSHConnection, + type SSHHostTrust as SSHHostTrust, + type SSHSessionsCursorPage as SSHSessionsCursorPage, + type SSHListParams as SSHListParams, + type SSHCreateParams as SSHCreateParams, + type SSHRetrieveParams as SSHRetrieveParams, + type SSHDeleteParams as SSHDeleteParams, + }; + + export { + Executions as Executions, + type ExecutionCreateParams as ExecutionCreateParams, + type Execution as Execution, + type ExecutionList as ExecutionList, + type ExecutionOutput as ExecutionOutput, + type ExecutionEvent as ExecutionEvent, + type ExecutionEvents as ExecutionEvents, + type ArtifactRef as ArtifactRef, + type ExecutionsCursorPage as ExecutionsCursorPage, + type ExecutionEventsCursorPage as ExecutionEventsCursorPage, + type ExecutionListParams as ExecutionListParams, + type ExecutionRetrieveParams as ExecutionRetrieveParams, + type ExecutionDeleteParams as ExecutionDeleteParams, + type ExecutionOutputParams as ExecutionOutputParams, + type ExecutionEventsParams as ExecutionEventsParams, + }; + + export { + Autoresizing as Autoresizing, + type Settings as Settings, + type AutoresizingRetrieveParams as AutoresizingRetrieveParams, + type AutoresizingUpdateParams as AutoresizingUpdateParams, + }; +} diff --git a/src/sdk/resources/machines/ssh.ts b/src/sdk/resources/machines/ssh.ts new file mode 100644 index 0000000..da72aa0 --- /dev/null +++ b/src/sdk/resources/machines/ssh.ts @@ -0,0 +1,217 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { APIResource } from '../../resource'; +import { APIPromise } from '../../api-promise'; +import { CursorPage, type CursorPageParams, type PagePromise } from '../../core/pagination'; +import type { RequestOptions } from '../../internal/request-options'; +import { path as __scalarPath } from '../../internal/utils/path'; + +export class SSH extends APIResource { + /** + * List SSH sessions + * + * @param {SSHListParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {PagePromise} OK + * + * @example + * ```ts + * const page = await client.machines.ssh.list({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * }); + * ``` + */ + list(params: SSHListParams, options?: RequestOptions): PagePromise { + const { machine_id, ...query } = params; + return this._client.getAPIList(__scalarPath`/v1/machines/${machine_id}/ssh`, CursorPage, { + query, + ...options, + }); + } + + /** + * Create SSH session + * + * @param {SSHCreateParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const sshSession = await client.machines.ssh.create({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * public_key: '', + * }); + * ``` + */ + create(params: SSHCreateParams, options?: RequestOptions): APIPromise { + const { machine_id, ...body } = params; + return this._client.post(__scalarPath`/v1/machines/${machine_id}/ssh`, { body, ...options }); + } + + /** + * Get SSH session + * + * @param {SSHRetrieveParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const sshSession = await client.machines.ssh.retrieve({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * session_id: 'sessionID', + * }); + * ``` + */ + retrieve(params: SSHRetrieveParams, options?: RequestOptions): APIPromise { + const { machine_id, session_id } = params; + return this._client.get(__scalarPath`/v1/machines/${machine_id}/ssh/${session_id}`, options); + } + + /** + * Delete SSH session + * + * @param {SSHDeleteParams} params - The parameters to send with the request. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const sshSession = await client.machines.ssh.delete({ + * machine_id: '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + * session_id: 'sessionID', + * }); + * ``` + */ + delete(params: SSHDeleteParams, options?: RequestOptions): APIPromise { + const { machine_id, session_id } = params; + return this._client.delete(__scalarPath`/v1/machines/${machine_id}/ssh/${session_id}`, options); + } +} + +export interface SSHSessionCreateParams { + public_key: string; +} + +export interface SSHSession { + /** + * @format date-time + */ + created_at: string; + /** + * @format uuid + */ + machine_id: string; + session_id: string; + status: 'wake_in_progress' | 'ssh_in_progress' | 'ready' | 'closed' | 'expired' | 'failed'; + connection?: SSHConnection; + error_code?: string; + error_message?: string; + /** + * @format date-time + */ + expires_at?: string; + /** + * @format date-time + */ + ready_at?: string; + /** + * @format int64 + */ + retry_after_ms?: number; +} + +export interface SSHSessionList { + items: Array | null; + next_cursor?: string; +} + +export interface SSHConnection { + endpoint: string; + /** + * @format int64 + */ + port: number; + ssh_username: string; + host_trust?: SSHHostTrust; + user_certificate?: string; +} + +export interface SSHHostTrust { + host_pattern: string; + kind: 'cert_authority'; + public_key: string; +} + +export interface SSHListParams extends CursorPageParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; +} + +export type SSHSessionsCursorPage = CursorPage; + +export interface SSHCreateParams { + /** + * Path param: Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * Body param + */ + public_key: string; +} + +export interface SSHRetrieveParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * @minLength 1 + * @maxLength 253 + * @pattern ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ + */ + session_id: string; +} + +export interface SSHDeleteParams { + /** + * Bare, lowercase, hyphenated Machine UUID. Pass the returned machine_id unchanged. + * @minLength 36 + * @maxLength 39 + * @pattern ^(dm-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + */ + machine_id: string; + /** + * @minLength 1 + * @maxLength 253 + * @pattern ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$ + */ + session_id: string; +} +export declare namespace SSH { + export { + type SSHSessionCreateParams as SSHSessionCreateParams, + type SSHSession as SSHSession, + type SSHSessionList as SSHSessionList, + type SSHConnection as SSHConnection, + type SSHHostTrust as SSHHostTrust, + type SSHSessionsCursorPage as SSHSessionsCursorPage, + type SSHListParams as SSHListParams, + type SSHCreateParams as SSHCreateParams, + type SSHRetrieveParams as SSHRetrieveParams, + type SSHDeleteParams as SSHDeleteParams, + }; +} diff --git a/src/sdk/resources/organization.ts b/src/sdk/resources/organization.ts new file mode 100644 index 0000000..a9a6a18 --- /dev/null +++ b/src/sdk/resources/organization.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export * from './organization/index'; diff --git a/src/sdk/resources/organization/autoresizing.ts b/src/sdk/resources/organization/autoresizing.ts new file mode 100644 index 0000000..5393c09 --- /dev/null +++ b/src/sdk/resources/organization/autoresizing.ts @@ -0,0 +1,57 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { APIResource } from '../../resource'; +import { APIPromise } from '../../api-promise'; +import type { RequestOptions } from '../../internal/request-options'; + +export class Autoresizing extends APIResource { + /** + * Read organization RAM autoresizing policy + * + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const policy = await client.organization.autoresizing.retrieve(); + * ``` + */ + retrieve(options?: RequestOptions): APIPromise { + return this._client.get('/v1/organization/autoresizing', options); + } + + /** + * Set organization RAM autoresizing policy + * + * @param {AutoresizingUpdateParams} body - The request body to send. + * @param {RequestOptions} [options] - Options to apply to the request, such as headers and an abort signal. + * @returns {APIPromise} OK + * + * @example + * ```ts + * const policy = await client.organization.autoresizing.update({ + * enabled: false, + * }); + * ``` + */ + update(body: AutoresizingUpdateParams, options?: RequestOptions): APIPromise { + return this._client.put('/v1/organization/autoresizing', { body, ...options }); + } +} + +export interface Policy { + /** + * Allow automatic RAM increases for all organization machines. Disabling preserves applied RAM and already admitted resizes. + */ + enabled: boolean; +} + +export interface AutoresizingUpdateParams { + /** + * Allow automatic RAM increases for all organization machines. Disabling preserves applied RAM and already admitted resizes. + */ + enabled: boolean; +} +export declare namespace Autoresizing { + export { type Policy as Policy, type AutoresizingUpdateParams as AutoresizingUpdateParams }; +} diff --git a/src/sdk/resources/organization/index.ts b/src/sdk/resources/organization/index.ts new file mode 100644 index 0000000..c7586b9 --- /dev/null +++ b/src/sdk/resources/organization/index.ts @@ -0,0 +1,5 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export { Organization } from './organization'; +export { Autoresizing } from './autoresizing'; +export type { Policy, AutoresizingUpdateParams } from './autoresizing'; diff --git a/src/sdk/resources/organization/organization.ts b/src/sdk/resources/organization/organization.ts new file mode 100644 index 0000000..a251530 --- /dev/null +++ b/src/sdk/resources/organization/organization.ts @@ -0,0 +1,21 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +import { APIResource } from '../../resource'; +import { APIPromise } from '../../api-promise'; +import type { RequestOptions } from '../../internal/request-options'; +import * as AutoresizingAPI from './autoresizing'; +import { Autoresizing, type Policy, type AutoresizingUpdateParams } from './autoresizing'; + +export class Organization extends APIResource { + autoresizing: AutoresizingAPI.Autoresizing = new AutoresizingAPI.Autoresizing(this._client); +} + +Organization.Autoresizing = Autoresizing; + +export declare namespace Organization { + export { + Autoresizing as Autoresizing, + type Policy as Policy, + type AutoresizingUpdateParams as AutoresizingUpdateParams, + }; +} diff --git a/src/sdk/uploads.ts b/src/sdk/uploads.ts new file mode 100644 index 0000000..86a228e --- /dev/null +++ b/src/sdk/uploads.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export { type Uploadable, toFile, type ToFileInput } from './core/uploads'; diff --git a/src/sdk/version.ts b/src/sdk/version.ts new file mode 100644 index 0000000..b0f17e3 --- /dev/null +++ b/src/sdk/version.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +export const VERSION = '0.7.0'; // x-release-please-version diff --git a/tests/browser-login.test.mjs b/tests/browser-login.test.mjs new file mode 100644 index 0000000..630d2a4 --- /dev/null +++ b/tests/browser-login.test.mjs @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { auth } from '../dist/esm/commands/index.js'; +import { runLogin } from '../dist/esm/cli/login.js'; + +test('browser login refuses to store a public session for another API', async () => { + await assert.rejects(runLogin(auth, 'https://other.invalid', 'browser'), /unavailable for this API/); +}); + +test('invariant_unconfigured_public_browser_login_fails_before_opening_a_browser', async () => { + assert.equal(auth.methods[0].issuer, ''); + await assert.rejects(runLogin(auth, auth.defaultBaseUrl, 'browser'), /not configured/); +}); + +for (const invalid of [false, true]) { + test(`browser login ${invalid ? 'rejects another issuer before exchange' : 'persists, refreshes and revokes credentials'}`, { + skip: process.platform === 'win32', timeout: 15000, + }, async (t) => { + const directory = mkdtempSync(join(tmpdir(), 'dedalus-browser-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + // Capture the printed URL instead of opening a real account in the test. + for (const opener of ['open', 'xdg-open']) { + writeFileSync(join(directory, opener), '#!/bin/sh\nexit 0\n', { mode: 0o700 }); + } + let authorization; + const exchanges = []; + const requests = []; + const revocations = []; + let rejectRevocation = false; + const server = createServer(async (req, res) => { + if (req.url.startsWith('/oauth2/auth?')) { + authorization = new URL(req.url, origin).searchParams; + const callback = new URL(authorization.get('redirect_uri')); + callback.searchParams.set('code', 'test-code'); + callback.searchParams.set('state', authorization.get('state')); + callback.searchParams.set('iss', invalid ? 'https://other.invalid' : origin); + res.writeHead(302, { location: callback.href }).end(); + } else if (req.url === '/oauth2/revoke') { + let body = ''; + for await (const chunk of req) body += chunk; + const form = new URLSearchParams(body); + revocations.push(Object.fromEntries(form)); + res.writeHead(rejectRevocation ? 503 : 200).end(); + } else if (req.url === '/oauth2/token') { + let body = ''; + for await (const chunk of req) body += chunk; + const form = new URLSearchParams(body); + exchanges.push(form); + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ + access_token: exchanges.length === 1 ? 'test-access' : 'test-refreshed', + refresh_token: exchanges.length === 1 ? 'test-refresh' : 'test-rotated', + token_type: 'Bearer', expires_in: 3600, + })); + } else { + requests.push(req.headers.authorization); + res.writeHead(200, { 'content-type': 'application/json' }) + .end(JSON.stringify({ items: [], next_cursor: null })); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { server.closeAllConnections(); server.close(); }); + const origin = 'http://127.0.0.1:' + server.address().port; + const store = join(directory, 'credentials.json'); + const env = { ...process.env, PATH: directory + ':' + process.env.PATH, + DEDALUS_CREDENTIALS_FILE: store, TEST_API_ORIGIN: origin }; + for (const key of ['DEDALUS_API_KEY', 'DEDALUS_X_API_KEY', 'DEDALUS_BASE_URL', 'DEDALUS_CUSTOM_HEADERS']) delete env[key]; + const run = (args, browser = false) => new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--input-type=module', '-e', ` + import { auth, getProgram } from './dist/esm/commands/index.js'; + const origin = process.env.TEST_API_ORIGIN; + auth.backend = 'file'; + auth.defaultBaseUrl = origin; + Object.assign(auth.methods[0], { resource: origin, issuer: origin, + authorizationUrl: origin + '/oauth2/auth', tokenUrl: origin + '/oauth2/token', + refreshUrl: origin + '/oauth2/token', revocationUrl: origin + '/oauth2/revoke' }); + await getProgram().parseAsync(JSON.parse(process.env.TEST_ARGS), { from: 'user' }); + `], { env: { ...env, TEST_ARGS: JSON.stringify([...args, '--base-url', origin]) }, stdio: ['ignore', 'pipe', 'pipe'] }); + t.after(() => { if (child.exitCode === null) child.kill(); }); + let stdout = '', stderr = '', opened = false; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + const match = stderr.match(/ (http:\/\/127\.0\.0\.1:[^\s]+)/); + if (browser && match && !opened) { + opened = true; + fetch(match[1]).then((response) => response.text()).catch(reject); + } + }); + child.on('error', reject); + child.on('exit', (code) => resolve({ code, stdout, stderr })); + }); + const login = await run(['login', '--flow', 'browser'], true); + if (invalid) { + assert.notEqual(login.code, 0); + assert.equal(exchanges.length, 0); + assert.throws(() => readFileSync(store), { code: 'ENOENT' }); + return; + } + assert.equal(login.code, 0, login.stderr); + assert.equal(authorization.size, 8); + assert.equal(authorization.get('resource'), origin); + assert.equal(authorization.get('client_id'), 'dedalus-cli'); + assert.equal(authorization.get('scope'), 'dedalus:cli offline_access'); + assert.equal(Buffer.from(authorization.get('state'), 'base64url').length, 32); + assert.equal(authorization.get('code_challenge_method'), 'S256'); + assert.equal(createHash('sha256').update(exchanges[0].get('code_verifier')).digest('base64url'), + authorization.get('code_challenge')); + assert.equal(exchanges[0].get('redirect_uri'), authorization.get('redirect_uri')); + assert.equal(statSync(store).mode & 0o777, 0o600); + assert.equal((await run(['machines', 'list'])).code, 0); + assert.equal(requests.at(-1), 'Bearer test-access'); + const saved = JSON.parse(readFileSync(store, 'utf8')); + saved.profiles[origin].oauth.apiKey.expiresAt = 0; + writeFileSync(store, JSON.stringify(saved)); + assert.equal((await run(['machines', 'list'])).code, 0); + assert.equal(exchanges[1].get('grant_type'), 'refresh_token'); + assert.equal(exchanges[1].get('refresh_token'), 'test-refresh'); + assert.equal(requests.at(-1), 'Bearer test-refreshed'); + assert.equal(JSON.parse(readFileSync(store, 'utf8')).profiles[origin].oauth.apiKey.refreshToken, 'test-rotated'); + assert.equal((await run(['machines', 'list', '--api-key', 'explicit'])).code, 0); + assert.equal(requests.at(-1), 'Bearer explicit'); + const logout = await run(['logout']); + assert.equal(logout.code, 0, logout.stderr); + assert.deepEqual(revocations, [{ token: 'test-rotated', token_type_hint: 'refresh_token' }]); + assert.equal(JSON.parse(readFileSync(store, 'utf8')).profiles[origin], undefined); + assert.equal(login.stdout.includes('test-access'), false); + }); +} + +test('design_logout_clears_local_credentials_despite_revocation_failure', { timeout: 20000 }, async (t) => { + const { runLogout } = await import('../dist/esm/cli/login.js'); + const directory = mkdtempSync(join(tmpdir(), 'dedalus-logout-test-')); + const store = join(directory, 'credentials.json'); + const variable = 'DEDALUS_LOGOUT_TEST_STORE'; + process.env[variable] = store; + t.after(() => { delete process.env[variable]; rmSync(directory, { recursive: true, force: true }); }); + const config = { ...auth, backend: 'file', storeEnv: variable }; + let behavior = 'success'; + let warnings = ''; + t.mock.method(process.stderr, 'write', (chunk) => { warnings += chunk; return true; }); + const requests = []; + const server = createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) body += chunk; + requests.push(Object.fromEntries(new URLSearchParams(body))); + if (behavior === 'offline') { req.socket.destroy(); return; } + if (behavior === 'timeout') return; + if (behavior === 'replace') { + const data = JSON.parse(readFileSync(store, 'utf8')); + data.profiles[origin].credentials.apiKey = 'new-access'; + writeFileSync(store, JSON.stringify(data)); + } + res.writeHead(behavior === 'failure' ? 503 : 204).end(); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { server.closeAllConnections(); server.close(); }); + const origin = 'http://127.0.0.1:' + server.address().port; + const oauth = { credentials: { apiKey: 'access-secret' }, oauth: { apiKey: { + refreshToken: 'refresh-secret', clientId: 'client', revocationUrl: origin + '/revoke', + } } }; + const save = (profile = oauth) => { + warnings = ''; + requests.length = 0; + writeFileSync(store, JSON.stringify({ version: 1, profiles: { + [origin]: profile, 'https://manual.example': { credentials: { apiKey: 'manual' } }, + } })); + }; + const profiles = () => JSON.parse(readFileSync(store, 'utf8')).profiles; + for (behavior of ['success', 'failure', 'offline', 'timeout', 'replace']) { + save(); + const started = Date.now(); + await runLogout(config, origin, true); + assert.throws(() => readFileSync(store), { code: 'ENOENT' }); + assert.deepEqual(requests, [{ token: 'refresh-secret', token_type_hint: 'refresh_token' }]); + if (['failure', 'offline', 'timeout'].includes(behavior)) assert.match(warnings, /revocation could not be confirmed/); + else assert.equal(warnings, ''); + assert.doesNotMatch(warnings, /access-secret|refresh-secret/); + if (behavior === 'timeout') assert.ok(Date.now() - started < 14000, 'revocation must be bounded to 10 seconds'); + } + behavior = 'success'; + const accessOnly = structuredClone(oauth); + delete accessOnly.oauth.apiKey.refreshToken; + save(accessOnly); + await runLogout(config, origin, false); + assert.deepEqual(requests, [{ token: 'access-secret', token_type_hint: 'access_token' }]); + assert.equal(profiles()[origin], undefined); + assert.ok(profiles()['https://manual.example']); + const legacy = structuredClone(oauth); + delete legacy.oauth.apiKey.revocationUrl; + save(legacy); + await runLogout(config, origin, true); + assert.deepEqual(requests, []); + assert.match(warnings, /revocation could not be confirmed/); + assert.throws(() => readFileSync(store), { code: 'ENOENT' }); + writeFileSync(store, '{corrupt'); + await runLogout(config, origin, true); + assert.throws(() => readFileSync(store), { code: 'ENOENT' }); + // Local removal failures must still fail logout, even when no revocation is possible. + const { mkdirSync } = await import('node:fs'); + mkdirSync(store); + await assert.rejects(runLogout(config, origin, true)); +}); diff --git a/tests/feedback-bundles.test.mjs b/tests/feedback-bundles.test.mjs new file mode 100644 index 0000000..ceb939a --- /dev/null +++ b/tests/feedback-bundles.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { gunzipSync } from 'node:zlib'; +import { test } from 'node:test'; +const root = resolve(process.env.FEEDBACK_PACKAGE_ROOT ?? '.'); +const load = (name) => import(pathToFileURL(join(root, 'dist/esm/feedback', name + '.js'))); +const { createDiagnostics, diagnosticScope, selectDiagnostics } = await load('diagnostics'); +const { buildFeedbackBundle } = await load('bundle'); +const scope = diagnosticScope({ apiKey: 'test-key', baseURL: 'https://staging.invalid' }); +const receipt = '01973f7b7cf6726a9a9f4f37d4b47a21'; +const directory = (t) => { + const dir = mkdtempSync(join(tmpdir(), 'dedalus-feedback-test-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +}; + +test('invariant opt-out sends no files and preserves the failure receipt', (t) => { + const dir = directory(t); + const log = createDiagnostics('dedalus machines create', scope, dir); + log.record({ + kind: 'response', + route: '/v1/machines', + status_code: 500, + duration_ms: 42, + request_id: receipt, + }); + log.record({ kind: 'command_failure' }); + const result = buildFeedbackBundle('false', scope, 'https://staging.invalid', '1.0.0', dir); + assert.equal(result.selection.receipt, receipt); + assert.deepEqual(result.attachments, []); +}); + +test('invariant auto selects only recent failures for the same credential and host', (t) => { + const dir = directory(t); + const log = createDiagnostics('dedalus machines create', scope, dir); + log.record({ kind: 'response', route: '/v1/machines', status_code: 200, duration_ms: 1 }); + assert.equal( + buildFeedbackBundle('auto', scope, 'https://staging.invalid', '1.0.0', dir).attachments.length, + 0, + ); + log.record({ kind: 'transport_failure', route: '/v1/machines', duration_ms: 3 }); + log.record({ kind: 'command_failure' }); + assert.ok( + buildFeedbackBundle('auto', scope, 'https://staging.invalid', '1.0.0', dir).attachments.length > + 0, + ); + assert.equal(selectDiagnostics(diagnosticScope({ apiKey: 'other-key' }), dir).events.length, 0); + assert.equal(selectDiagnostics(scope, dir, Date.now() + 16 * 60 * 1000).events.length, 0); + assert.equal(selectDiagnostics(scope, dir).receipt, undefined); +}); + +test('invariant attachments contain only allowlisted fields and exact byte hashes', (t) => { + const dir = directory(t); + const log = createDiagnostics('dedalus machines create', scope, dir); + log.record({ + kind: 'response', + status_code: 500, + route: '/v1/machines', + request_id: receipt, + duration_ms: 7, + authorization: 'Bearer super-secret', + body: { password: 'secret-password' }, + error: 'private workspace content', + }); + log.record({ kind: 'command_failure' }); + const raw = readFileSync(join(dir, readdirSync(dir)[0]), 'utf8'); + assert.doesNotMatch(raw, /super-secret|secret-password|private workspace/); + const bundle = buildFeedbackBundle( + 'true', + scope, + 'https://user:password@staging.invalid/?token=secret', + '1.0.0', + dir, + ); + assert.equal(bundle.attachments.length, 4); + for (const file of bundle.attachments) { + assert.equal(file.manifest.sha256, createHash('sha256').update(file.bytes).digest('hex')); + assert.equal(file.manifest.size_bytes, file.bytes.length); + const content = + file.manifest.format === 'application/gzip' ? gunzipSync(file.bytes) : file.bytes; + assert.doesNotMatch( + Buffer.from(content).toString(), + /super-secret|secret-password|private workspace|user:password|token=secret/, + ); + } + assert.ok(bundle.attachments.reduce((sum, file) => sum + file.bytes.length, 0) <= 1024 * 1024); +}); diff --git a/tests/feedback-command.test.mjs b/tests/feedback-command.test.mjs new file mode 100644 index 0000000..8b72003 --- /dev/null +++ b/tests/feedback-command.test.mjs @@ -0,0 +1,299 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawn } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { test } from 'node:test'; +const root = resolve(process.env.FEEDBACK_PACKAGE_ROOT ?? '.'); +const { feedbackIdempotencyKey } = await import(pathToFileURL(join(root, 'dist/esm/feedback/command.js'))); +const { diagnosticScope } = await import(pathToFileURL(join(root, 'dist/esm/feedback/diagnostics.js'))); +const receipt = '01973f7b7cf6726a9a9f4f37d4b47a21'; + +test('invariant idempotency keys match the server UUIDv7 contract', () => { + const keys = new Set(Array.from({ length: 100 }, feedbackIdempotencyKey)); + assert.equal(keys.size, 100); + for (const key of keys) assert.match(key, /^[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}$/); +}); + +const runCLI = async (args, env = {}) => { + const child = spawn(process.execPath, [join(root, 'dist/esm/bin.js'), ...args], { + env: { PATH: process.env.PATH, ...env }, + }); + let stdout = '', + stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + const code = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + return { code, stdout, stderr }; +}; + +test('invariant organization selection never turns a request receipt into an outbound request ID', async (t) => { + let headers; + const server = createServer((req, res) => { + headers = req.headers; + req.resume(); + res.writeHead(201, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ id: receipt, source: 'cli', debug: { included: false } })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + const result = await runCLI( + [ + 'feedback', + 'organization check', + '--include-logs=false', + '--api-key', + 'test-key', + '--x-dedalus-org-id', + 'org-current', + '--base-url', + 'http://127.0.0.1:' + server.address().port, + ], + { + DEDALUS_CUSTOM_HEADERS: 'x-request-id: ' + receipt, + }, + ); + assert.equal(result.code, 0, result.stderr); + assert.equal(headers['x-dedalus-org-id'], 'org-current'); + assert.equal(headers['x-request-id'], undefined); + assert.notEqual( + diagnosticScope({ apiKey: 'key', dedalusOrgID: 'org-current' }), + diagnosticScope({ apiKey: 'key', dedalusOrgID: 'org-other' }), + ); +}); + +test('invariant installed CLI sends a server-compatible multipart manifest', async (t) => { + let received; + const server = createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const wire = new Request('http://fixture/v1/feedback', { + method: 'POST', + headers: req.headers, + body: Buffer.concat(chunks), + }); + received = await wire.formData(); + res.writeHead(201, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ id: receipt, source: 'cli', debug: { included: true } })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + const result = await runCLI([ + 'feedback', + 'package attachment check', + '--include-logs=true', + '--api-key', + 'test-key', + '--base-url', + 'http://127.0.0.1:' + server.address().port, + '--format', + 'json', + ]); + assert.equal(result.code, 0, result.stderr); + const metadata = JSON.parse(received.get('metadata')); + const files = received.getAll('debug_files'); + assert.equal(metadata.debug.included, true); + assert.equal(files.length, metadata.debug.files.length); + assert.ok(files.length > 0 && files.length <= 4); + for (const file of files) { + const bytes = Buffer.from(await file.arrayBuffer()); + const manifest = metadata.debug.files.find((entry) => entry.filename === file.name); + assert.equal(manifest.size_bytes, bytes.length); + assert.equal(manifest.sha256, createHash('sha256').update(bytes).digest('hex')); + assert.equal(manifest.format, file.type); + assert.doesNotMatch(bytes.toString(), /test-key|Authorization|Bearer/); + } +}); + +for (const [status, exitCode] of [ + [401, 10], + [429, 12], + [422, 13], +]) { + test( + 'invariant installed CLI reports HTTP ' + status + ' without claiming acceptance', + async (t) => { + const envelope = { + error_code: status === 429 ? 'RATE_LIMITED' : 'INVALID_REQUEST', + message: 'fixture rejection', + retryable: status === 429, + ...(status === 429 ? { retry_after_ms: 1000, details: { rate_limit_scope: 'organization' } } : {}), + }; + const server = createServer((req, res) => { + req.resume(); + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(envelope)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + const result = await runCLI([ + 'feedback', + 'rejection check', + '--include-logs=false', + '--api-key', + 'test-key', + '--base-url', + 'http://127.0.0.1:' + server.address().port, + '--max-retries', + '0', + '--format', + 'json', + ]); + assert.equal(result.code, exitCode, result.stderr); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /fixture rejection/); + const error = JSON.parse(result.stderr); + assert.equal(error.status, status); + assert.deepEqual(error.body, envelope); + }, + ); +} + +test('invariant installed CLI retries one submission with the same idempotency key', async (t) => { + const requests = []; + const server = createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) body += chunk; + requests.push({ headers: req.headers, body, path: req.url }); + res.setHeader('content-type', 'application/json'); + if (requests.length === 1) { + res.writeHead(503, { 'retry-after-ms': '1' }); + res.end('{"error":{"message":"retry fixture"}}'); + } else { + res.writeHead(201); + res.end( + JSON.stringify({ + id: receipt, + source: 'cli', + reported_request_id: null, + debug: { included: false }, + }), + ); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + const result = await runCLI([ + 'feedback', + 'package retry check', + '--include-logs=false', + '--api-key', + 'test-key', + '--base-url', + 'http://127.0.0.1:' + server.address().port, + '--format', + 'json', + ]); + assert.equal(result.code, 0, result.stderr); + assert.equal(requests.length, 2); + assert.equal(requests[0].headers['idempotency-key'], requests[1].headers['idempotency-key']); + assert.equal(requests[0].body, requests[1].body); + assert.equal(requests[0].path, '/v1/feedback'); + assert.equal(requests[0].headers['x-dedalus-cli-command'], 'dedalus feedback'); + assert.equal(requests[0].headers['x-request-id'], undefined); + assert.equal(JSON.parse(requests[0].body).debug.included, false); + assert.equal(JSON.parse(result.stdout).id, receipt); +}); + +test('invariant invalid include-logs values fail before submission', async () => { + const result = await runCLI(['feedback', 'invalid mode', '--include-logs=maybe']); + assert.equal(result.code, 2); +}); + +for (const failsDecoding of [false, true]) { + test('invariant feedback follows the terminal command outcome after HTTP responses: ' + (failsDecoding ? 'decoding failure' : 'recovered retry'), async (t) => { + const home = mkdtempSync(join(tmpdir(), 'dedalus-command-outcome-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + let attempts = 0; + let report; + const server = createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + if (req.url === '/v1/feedback') { + const wire = new Request('http://fixture/v1/feedback', { + method: 'POST', headers: req.headers, body: Buffer.concat(chunks), + }); + report = req.headers['content-type'].startsWith('multipart/') + ? JSON.parse((await wire.formData()).get('metadata')) + : await wire.json(); + res.writeHead(201, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ id: receipt, source: 'cli', debug: report.debug })); + return; + } + attempts++; + res.setHeader('content-type', 'application/json'); + res.setHeader('x-request-id', receipt); + if (!failsDecoding && attempts === 1) { + res.writeHead(503, { 'retry-after-ms': '1' }); + res.end('{"message":"retry fixture"}'); + } else { + res.writeHead(200); + res.end(failsDecoding ? '{' : '{"data":[]}'); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + const options = ['--api-key', 'test-key', '--base-url', 'http://127.0.0.1:' + server.address().port]; + const command = await runCLI(['machines', 'list', ...options], { HOME: home }); + assert.equal(command.code, failsDecoding ? 1 : 0, command.stderr); + assert.equal(attempts, failsDecoding ? 1 : 2); + const feedback = await runCLI([ + 'feedback', 'command outcome', '--include-logs=' + (failsDecoding ? 'false' : 'auto'), ...options, + ], { HOME: home }); + assert.equal(feedback.code, 0, feedback.stderr); + assert.equal(report.debug.included, false); + assert.deepEqual(report.debug.files, []); + assert.equal(report.reported_request_id, failsDecoding ? receipt : undefined); + assert.equal(report.diagnostics, undefined); + }); +} + +test('invariant generated and custom commands share scoped stored credentials and request headers', async (t) => { + const directory = mkdtempSync(join(tmpdir(), 'dedalus-stored-auth-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const requests = []; + const server = createServer((req, res) => { + requests.push({ method: req.method, headers: req.headers }); + req.resume(); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ items: [], next_cursor: null })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + const baseURL = 'http://127.0.0.1:' + server.address().port; + const store = join(directory, 'credentials.json'); + writeFileSync(store, JSON.stringify({ version: 1, profiles: { + [baseURL]: { backend: 'file', credentials: { apiKey: 'stored-key' } }, + 'https://other.invalid': { backend: 'file', credentials: { apiKey: 'other-host-key' } }, + } }), { mode: 0o600 }); + const env = { HOME: directory, DEDALUS_CREDENTIALS_FILE: store, + DEDALUS_CUSTOM_HEADERS: 'x-request-id: ' + receipt }; + for (const args of [ + ['machines', 'list'], + ['feedback', 'stored credential', '--include-logs=false'], + ]) { + const orgFlags = args[0] === 'feedback' ? ['--x-dedalus-org-id', 'org-current'] : []; + const result = await runCLI([...args, '--base-url', baseURL, ...orgFlags, '--format', 'json'], env); + assert.equal(result.code, 0, result.stderr); + const { headers } = requests.at(-1); + assert.equal(headers.authorization, 'Bearer stored-key'); + assert.equal(headers['x-dedalus-org-id'], args[0] === 'feedback' ? 'org-current' : undefined); + assert.equal(headers['x-request-id'], undefined); + assert.match(headers['user-agent'], /^Dedalus\/CLI /); + assert.ok(headers['x-dedalus-cli-command'].startsWith('dedalus ')); + } + const explicit = await runCLI(['feedback', 'explicit credential', '--include-logs=false', + '--base-url', baseURL, '--api-key', 'explicit-key'], env); + assert.equal(explicit.code, 0, explicit.stderr); + assert.equal(requests.at(-1).headers.authorization, 'Bearer explicit-key'); +}); diff --git a/tests/feedback-diagnostics.test.mjs b/tests/feedback-diagnostics.test.mjs new file mode 100644 index 0000000..6c3617e --- /dev/null +++ b/tests/feedback-diagnostics.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, utimesSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { test } from 'node:test'; + +const root = resolve(process.env.FEEDBACK_PACKAGE_ROOT ?? '.'); +const { createDiagnostics, diagnosticScope, selectDiagnostics, pruneDiagnostics } = + await import(pathToFileURL(join(root, 'dist/esm/feedback/diagnostics.js'))); +const scope = diagnosticScope({ apiKey: 'test-key', baseURL: 'https://staging.invalid' }); +const receipt = '01973f7b7cf6726a9a9f4f37d4b47a21'; +const directory = (t) => { + const path = mkdtempSync(join(tmpdir(), 'dedalus-diagnostics-test-')); + t.after(() => rmSync(path, { recursive: true, force: true })); + return path; +}; + +test('invariant selection requires a recent failure in the same credential scope', (t) => { + const dir = directory(t); + const log = createDiagnostics('dedalus machines create', scope, dir); + log.record({ kind: 'response', route: '/v1/machines', status_code: 200, duration_ms: 1 }); + assert.equal(selectDiagnostics(scope, dir).events.length, 0); + log.record({ kind: 'transport_failure', route: '/v1/machines', duration_ms: 3 }); + assert.equal(selectDiagnostics(scope, dir).events.length, 0); + log.record({ kind: 'command_failure' }); + assert.ok(selectDiagnostics(scope, dir).events.length > 0); + log.record({ kind: 'command_complete' }); + assert.ok(selectDiagnostics(scope, dir).events.length > 0); + assert.equal(selectDiagnostics(diagnosticScope({ apiKey: 'other-key' }), dir).events.length, 0); + assert.equal(selectDiagnostics(scope, dir, Date.now() + 16 * 60 * 1000).events.length, 0); +}); + +test('invariant recording drops fields outside the diagnostic contract', (t) => { + const dir = directory(t); + const log = createDiagnostics('dedalus machines create', scope, dir); + log.record({ + kind: 'response', status_code: 500, route: '/v1/machines', request_id: receipt, + duration_ms: 7, authorization: 'Bearer super-secret', + body: { password: 'secret-password' }, error: 'private workspace content', + }); + const raw = readFileSync(join(dir, readdirSync(dir)[0]), 'utf8'); + assert.doesNotMatch(raw, /super-secret|secret-password|private workspace/); + log.record({ kind: 'command_failure' }); + assert.equal(selectDiagnostics(scope, dir).receipt, receipt); +}); + +test('invariant feedback logs and symlinked files cannot be selected', (t) => { + const dir = directory(t); + const log = createDiagnostics('dedalus feedback', scope, dir); + log.record({ kind: 'command_failure' }); + assert.equal(selectDiagnostics(scope, dir).events.length, 0); + const name = scope + '.' + Date.now() + '.00000000-0000-0000-0000-000000000000.jsonl'; + symlinkSync(join(dir, readdirSync(dir)[0]), join(dir, name)); + assert.equal(selectDiagnostics(scope, dir).events.length, 0); +}); + +test('invariant local SSH failure retains the last session API receipt', (t) => { + const dir = directory(t); + const log = createDiagnostics('dedalus ssh', scope, dir); + log.record({ kind: 'response', route: '/v1/machines/{machine_id}/ssh', + status_code: 200, duration_ms: 10, request_id: receipt }); + log.record({ kind: 'command_failure' }); + const selection = selectDiagnostics(scope, dir); + assert.equal(selection.receipt, receipt); + assert.equal(selection.failure, undefined); + assert.equal(selectDiagnostics(diagnosticScope({ apiKey: 'other-key' }), dir).events.length, 0); +}); + +test('invariant a later response without a receipt does not reuse an older receipt', (t) => { + const dir = directory(t); + const log = createDiagnostics('dedalus ssh', scope, dir); + log.record({ kind: 'response', route: '/v1/machines/{machine_id}/ssh', + status_code: 500, duration_ms: 10, request_id: receipt }); + log.record({ kind: 'response', route: '/v1/machines/{machine_id}/ssh/{resource_id}', + status_code: 200, duration_ms: 5 }); + log.record({ kind: 'command_failure' }); + const selection = selectDiagnostics(scope, dir); + assert.equal(selection.receipt, undefined); + assert.equal(selection.failure, undefined); +}); + +test('invariant partitions are capped and expired logs are pruned', (t) => { + const dir = directory(t); + const log = createDiagnostics('dedalus machines list', scope, dir); + for (let i = 0; i < 1200; i++) log.record({ kind: 'request_start', route: '/v1/machines' }); + const path = join(dir, readdirSync(dir)[0]); + assert.equal(readFileSync(path, 'utf8').trim().split('\n').length, 1000); + const old = new Date(Date.now() - 11 * 24 * 60 * 60 * 1000); + utimesSync(path, old, old); + pruneDiagnostics(dir); + assert.equal(readdirSync(dir).length, 0); +}); + +test('invariant transport capture preserves receipts without request data', async (t) => { + const dir = directory(t); + t.mock.method(globalThis, 'fetch', async () => new Response('private response body', { + status: 500, headers: { 'X-Request-ID': receipt }, + })); + const diagnostics = createDiagnostics('dedalus machines retrieve', scope, dir); + await diagnostics.fetch('https://staging.invalid/v1/machines/private-machine?token=secret-query', { + headers: { Authorization: 'Bearer secret-key' }, + }); + diagnostics.record({ kind: 'command_failure' }); + const selection = selectDiagnostics(scope, dir); + assert.equal(selection.receipt, receipt); + assert.equal(selection.failure.route, '/v1/machines/{machine_id}'); + assert.doesNotMatch(JSON.stringify(selection), /private-machine|secret-query|secret-key|private response/); +}); + +test('invariant successful commands exclude recovered attempts from selection', (t) => { + for (const kind of ['response', 'transport_failure']) { + const dir = directory(t); + const log = createDiagnostics('dedalus machines list', scope, dir); + log.record({ kind, status_code: 503, route: '/v1/machines', request_id: receipt, duration_ms: 1 }); + log.record({ kind: 'response', status_code: 200, route: '/v1/machines', duration_ms: 1 }); + log.record({ kind: 'command_complete' }); + assert.deepEqual(selectDiagnostics(scope, dir), { events: [] }, kind); + } +}); + +test('invariant client processing failures retain the latest valid response receipt', (t) => { + const dir = directory(t); + const latestReceipt = '01973f7b7cf6726a9a9f4f37d4b47a22'; + const log = createDiagnostics('dedalus machines list', scope, dir); + log.record({ kind: 'response', status_code: 503, route: '/v1/machines', request_id: receipt, duration_ms: 1 }); + log.record({ kind: 'response', status_code: 200, route: '/v1/machines', request_id: latestReceipt, duration_ms: 2 }); + log.record({ kind: 'command_failure' }); + const selection = selectDiagnostics(scope, dir); + assert.equal(selection.receipt, latestReceipt); + assert.equal(selection.command, 'dedalus machines list'); + assert.equal(selection.failure, undefined); +}); diff --git a/tests/feedback-retry-keys.test.mjs b/tests/feedback-retry-keys.test.mjs new file mode 100644 index 0000000..22a2e73 --- /dev/null +++ b/tests/feedback-retry-keys.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; +import { join, resolve } from 'node:path'; +import { test } from 'node:test'; +const { default: SDK } = await import(pathToFileURL(join(resolve(process.env.FEEDBACK_PACKAGE_ROOT ?? '.'), 'dist/esm/sdk/index.js'))); +test('invariant automatic retries preserve a valid feedback identity', async () => { + const keys = []; + const uuid7 = /^[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}$/; + const client = new SDK({ baseURL: 'https://feedback.invalid', apiKey: 'fixture-key', maxRetries: 1, logLevel: 'off', + fetch: async (_url, options) => { + const key = new Headers(options.headers).get('Idempotency-Key'); + keys.push(key); + assert.match(key, uuid7, 'automatic feedback key must match the API contract'); + if (keys.length === 1) return Response.json({}, {status: 503, headers: {'retry-after-ms': '1'}}); + return Response.json({id: key}, {status: 201}); + }, + }); + await client.post('/v1/feedback', {body: {message: 'fixture', source: 'cli'}}); + await client.post('/v1/feedback', {body: {message: 'new submission', source: 'cli'}}); + assert.equal(keys.length, 3); + assert.equal(keys[0], keys[1], 'automatic retry must preserve identity'); + assert.notEqual(keys[1], keys[2], 'new submission must receive its own identity'); +}); diff --git a/tests/feedback-runtime.test.mjs b/tests/feedback-runtime.test.mjs new file mode 100644 index 0000000..a280686 --- /dev/null +++ b/tests/feedback-runtime.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { Command } from 'commander'; +import { test } from 'node:test'; + +const root = resolve(process.env.FEEDBACK_PACKAGE_ROOT ?? '.'); +const { sdkClientOptions, writeError } = await import(pathToFileURL(join(root, 'dist/esm/cli/runtime.js'))); +const { default: SDK } = await import(pathToFileURL(join(root, 'dist/esm/sdk/index.js'))); + +test('invariant error formatting preserves errors with an explicit response body', async (t) => { + let output = ''; + t.mock.method(process.stderr, 'write', (chunk) => { output += chunk; return true; }); + const body = { error_code: 'INVALID_REQUEST', message: 'fixture rejection', retryable: false }; + await writeError({ message: body.message, status: 422, body }, { format: 'json' }, [], SDK); + assert.deepEqual(JSON.parse(output).body, body); +}); + +test('invariant SDK requests preserve organization scope without forwarding receipts', async (t) => { + const previous = process.env.DEDALUS_CUSTOM_HEADERS; + process.env.DEDALUS_CUSTOM_HEADERS = 'x-request-id: 01973f7b7cf6726a9a9f4f37d4b47a21'; + t.after(() => { + if (previous === undefined) delete process.env.DEDALUS_CUSTOM_HEADERS; + else process.env.DEDALUS_CUSTOM_HEADERS = previous; + }); + const command = new Command('dedalus').version('1.0.0').command('machines').command('create'); + const options = await sdkClientOptions({ + baseUrl: 'https://staging.invalid', apiKey: 'test-key', xDedalusOrgId: 'org-current', + }, command, [{ name: 'api-key', optionKey: 'apiKey', sdkKey: 'apiKey' }], undefined); + let headers; + const client = new SDK({ + ...options, + fetch: async (_url, init) => { + headers = new Headers(init.headers); + return new Response('{}', { headers: { 'content-type': 'application/json' } }); + }, + }); + await client.get('/v1/machines'); + assert.equal(headers.get('x-dedalus-org-id'), 'org-current'); + assert.equal(headers.get('x-request-id'), null); + assert.equal(headers.get('x-dedalus-cli-command'), 'dedalus machines create'); + assert.equal(headers.get('user-agent'), 'Dedalus/CLI 1.0.0'); +}); diff --git a/tests/machine-aliases.test.mjs b/tests/machine-aliases.test.mjs new file mode 100644 index 0000000..8c0ecca --- /dev/null +++ b/tests/machine-aliases.test.mjs @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { afterEach, test } from 'node:test' +import { Command } from 'commander' + +import SDK from '../dist/esm/sdk/index.js' +import { getProgram } from '../dist/esm/index.js' +import { addMachineAliases, createMachineAPI } from '../dist/esm/custom/machines.js' +import { loadMachineChoices, matchingMachines, renderPicker, updatePicker } from '../dist/esm/custom/ssh-picker.js' +import { awaitSSHSession } from '../dist/esm/custom/ssh.js' + +afterEach(() => { process.exitCode = undefined }) + +const machine = (id, name, phase = 'running') => ({ + machine_id: id, name, desired_state: 'running', phase, +}) + +const capture = async (action) => { + let stdout = '' + let stderr = '' + const originalOut = process.stdout.write + const originalErr = process.stderr.write + process.stdout.write = (value, ...args) => { + if (typeof value !== 'string') return originalOut.call(process.stdout, value, ...args) + stdout += value; return true + } + process.stderr.write = (value, ...args) => { + if (typeof value !== 'string') return originalErr.call(process.stderr, value, ...args) + stderr += value; return true + } + try { await action() } finally { + process.stdout.write = originalOut + process.stderr.write = originalErr + } + return { stdout, stderr } +} + +const aliases = (options) => addMachineAliases(new Command() + .enablePositionalOptions() + .option('--format ', '', 'auto') + .option('--format-error ', '', 'auto') + .option('--transform ') + .option('--raw-output') + .option('--api-key ', '', 'test') + .option('--x-dedalus-org-id ') + .option('--base-url '), [ + { clientKey: 'apiKey', sdkKey: 'apiKey', name: 'api-key', optionKey: 'apiKey', auth: true }, +], options) + +test('public program includes custom aliases alongside generated commands', () => { + const program = getProgram() + assert.ok(program.commands.some((command) => command.name() === 'ssh')) + assert.equal(program.commands.some((command) => command.name() === 'rename'), false) + assert.ok(program.commands.find((command) => command.name() === 'machines').commands.some((command) => command.name() === 'update')) +}) + +test('SSH explicit IDs bypass the picker even without a TTY', async () => { + for (const target of ['00000000-0000-4000-8000-000000000111', '00000000-0000-4000-8000-000000000123']) { + const connected = [] + const program = aliases({ api: () => ({}), interactive: () => false, + pick: () => { throw new Error('unexpected picker') }, + connect: async (_api, id) => { connected.push(id) }, + }) + await program.parseAsync(['ssh', target], { from: 'user' }) + assert.deepEqual(connected, [target]) + } +}) + +test('SSH picker selection transmits stable ID and cancellation creates no session', async () => { + for (const selected of ['00000000-0000-4000-8000-000000000111', undefined]) { + const connected = [] + const program = aliases({ api: () => ({}), interactive: () => true, + pick: async () => selected, connect: async (_api, id) => { connected.push(id) }, + }) + await program.parseAsync(['ssh'], { from: 'user' }) + assert.deepEqual(connected, selected === undefined ? [] : [selected]) + } +}) + +test('SSH without a TTY or target exits promptly with actionable usage', () => { + const result = spawnSync(process.execPath, ['dist/esm/bin.js', 'ssh'], { encoding: 'utf8', timeout: 3000 }) + assert.equal(result.status, 2) + assert.match(result.stderr, /interactive terminal.*dedalus ssh /u) + assert.equal(result.stdout, '') +}) + +test('aliases retain CLI usage exit codes', () => { + const result = spawnSync(process.execPath, ['dist/esm/bin.js', 'ssh', 'not-a-uuid'], { encoding: 'utf8' }) + assert.equal(result.status, 2) +}) + +test('organization flag is exposed only when the generated commands expose it', () => { + const plain = addMachineAliases(new Command(), []) + assert.equal(plain.commands.find((command) => command.name() === 'ssh').options.some((option) => + option.long === '--x-dedalus-org-id'), false) + const source = new Command() + source.command('machines').command('retrieve').option('--x-dedalus-org-id ') + const generated = addMachineAliases(source, []) + assert.equal(generated.commands.find((command) => command.name() === 'ssh').options.some((option) => + option.long === '--x-dedalus-org-id'), true) + assert.equal(getProgram().commands.find((command) => command.name() === 'ssh').options.some((option) => + option.long === '--x-dedalus-org-id'), false) +}) + +test('invariant_picker_reads_list_phases_keeps_unnamed_and_skips_destroyed', async () => { + const cursors = [] + const pages = [ + { items: [machine('00000000-0000-4000-8000-000000000001', 'one'), { ...machine('00000000-0000-4000-8000-000000000003', 'dying'), desired_state: 'destroyed' }], next_cursor: 'page-2' }, + { items: [], next_cursor: 'page-3' }, + { items: [machine('00000000-0000-4000-8000-000000000002', null, 'sleeping'), machine('00000000-0000-4000-8000-000000000004', 'gone', 'destroyed')], next_cursor: null }, + ] + const choices = await loadMachineChoices({ listMachines: async (cursor) => { + cursors.push(cursor) + return pages.shift() + } }, new AbortController().signal) + assert.deepEqual(cursors, [undefined, 'page-2', 'page-3']) + assert.deepEqual(choices, [ + { id: '00000000-0000-4000-8000-000000000001', name: 'one', status: 'running' }, + { id: '00000000-0000-4000-8000-000000000002', name: null, status: 'sleeping' }, + ]) +}) + +test('picker propagates load errors and reports empty fleet', async () => { + await assert.rejects(loadMachineChoices({ listMachines: async () => { + throw new Error('access denied') + } }, new AbortController().signal), /access denied/u) + await assert.rejects(loadMachineChoices({ listMachines: async () => ({ items: [], next_cursor: null }) }, + new AbortController().signal), /No machines available.*dedalus machines create/u) +}) + +test('picker refuses malformed names and repeating pagination cursors', async () => { + await assert.rejects(loadMachineChoices({ listMachines: async () => ({ items: [machine('not-a-uuid', 123)] }) }, + new AbortController().signal), /invalid name/u) + await assert.rejects(loadMachineChoices({ listMachines: async () => ({ items: [], next_cursor: 'repeat' }) }, + new AbortController().signal), /repeated cursor/u) +}) + +test('picker search covers names, IDs, status; navigation and editing keep a valid selection', () => { + let state = { machines: [ + { id: '00000000-0000-4000-8000-000000000001', name: 'alpha', status: 'running' }, + { id: '00000000-0000-4000-8000-000000000002', name: 'beta', status: 'sleeping' }, + ], query: '', cursor: 0 } + state = updatePicker(state, '', { name: 'down' }) + assert.equal(matchingMachines(state)[state.cursor].id, '00000000-0000-4000-8000-000000000002') + state = updatePicker(state, 'ALPHA', {}) + assert.deepEqual(matchingMachines(state).map(({ id }) => id), ['00000000-0000-4000-8000-000000000001']) + assert.equal(state.cursor, 0) + for (const query of ['00000000-0000-4000-8000-000000000002', 'SLEEPING', 'beta']) { + assert.deepEqual(matchingMachines({ ...state, query }).map(({ id }) => id), ['00000000-0000-4000-8000-000000000002']) + } + state = updatePicker(state, '', { name: 'u', ctrl: true }) + assert.equal(state.query, '') + state = updatePicker(state, 'z', {}) + assert.deepEqual(matchingMachines(state), []) + assert.match(renderPicker(state, 24, 100), /No matching machines/u) + state = updatePicker(state, '', { name: 'backspace' }) + assert.equal(matchingMachines(state).length, 2) + const view = renderPicker(state, 24, 100) + assert.match(view, /> alpha \[running\]\n 00000000-0000-4000-8000-000000000001/u) + assert.match(view, /beta \[sleeping\]\n 00000000-0000-4000-8000-000000000002/u) +}) + +test('SSH session creation and polling use the returned canonical ID', async () => { + const requests = [] + const api = createMachineAPI(new SDK({ apiKey: 'test', maxRetries: 0, fetch: async (url, init) => { + requests.push({ url: String(url), method: init.method, body: init.body }) + return Response.json({ machine_id: '00000000-0000-4000-8000-000000000111', session_id: '00000000-0000-4000-8000-000000000010', + status: ['wake_in_progress', 'ssh_in_progress', 'ready'][requests.length - 1], retry_after_ms: 1 }) + } })) + await capture(() => awaitSSHSession(api, '00000000-0000-4000-8000-000000000111', 'ssh-ed25519 public')) + assert.ok(requests[0].url.endsWith('/v1/machines/00000000-0000-4000-8000-000000000111/ssh')) + assert.deepEqual(JSON.parse(requests[0].body), { public_key: 'ssh-ed25519 public' }) + assert.equal(requests.length, 3) + for (const request of requests.slice(1)) { + assert.ok(request.url.endsWith('/v1/machines/00000000-0000-4000-8000-000000000111/ssh/00000000-0000-4000-8000-000000000010')) + } +}) + +test('SSH refuses to poll without a canonical ID', async () => { + await assert.rejects(awaitSSHSession({ createSSHSession: async () => ({ session_id: '00000000-0000-4000-8000-000000000010', status: 'wake_in_progress' }), + getMachineSSHSession: async () => { throw new Error('must not poll') }, + }, 'name', 'key'), /omitted machine_id/u) +}) diff --git a/tests/smoke-test.ts b/tests/smoke-test.ts new file mode 100644 index 0000000..3c33dc6 --- /dev/null +++ b/tests/smoke-test.ts @@ -0,0 +1,531 @@ +// File generated from our OpenAPI spec by Scalar. See README.md for details. + +// Smoke test: invokes the generated CLI once per operation to confirm each command can reach +// its endpoint. Build the CLI first (so dist/esm/bin.js exists), then run this from the repo +// with `bun tests/smoke-test.ts`. Each case below holds the argv for one command, minus the +// base URL and credentials — the embedded SDK reads those from the environment, so set +// _BASE_URL and the auth variables before running. +// +// Two environment variables tune a run: +// - SCALAR_SMOKE_FILTER: comma-separated needles; only operations whose name or path contains +// one of them run, so you can smoke-test a subset without editing this file. +// - SCALAR_SMOKE_REPORT: a file path; when set, the run writes a JSON report there instead of +// printing a table. The generator uses this to collect per-operation results. +import { execFile } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +// The result of running one case, collected for the JSON report or the printed table. +type SmokeResult = { + operation: string; + method: string; + path: string; + label?: string; + status: 'passed' | 'failed'; + durationMs: number; + error?: string; +}; + +// One or two entries per generated operation: the first passes only the flags the command +// requires, the second also passes every optional flag. `label` says which is which, and is +// absent when the command has no optional flag and so has only one case. `args` is the argv +// passed to the built CLI; the other fields are metadata used for filtering and reporting. This +// list is generated, so it stays in sync with the CLI command surface. +const cases: { operation: string; method: string; path: string; label?: string; args: string[] }[] = [ + { + operation: 'list', + method: 'GET', + path: '/v1/machines', + args: ['machines', 'list', '--max-items', '10'], + }, + + { + operation: 'create', + method: 'POST', + path: '/v1/machines', + args: [ + 'machines', + 'create', + '--autosleep', + '300s', + '--memory-mib', + '4096', + '--storage-gib', + '10', + '--vcpu', + '1', + ], + }, + + { + operation: 'retrieve', + method: 'GET', + path: '/v1/machines/{machine_id}', + args: ['machines', 'retrieve', '--machine-id', '017f22e2-79b0-7cc3-98c4-dc0c0c07398f'], + }, + + { + operation: 'update', + method: 'PATCH', + path: '/v1/machines/{machine_id}', + label: 'required params', + args: ['machines', 'update', '--machine-id', '017f22e2-79b0-7cc3-98c4-dc0c0c07398f'], + }, + + { + operation: 'update', + method: 'PATCH', + path: '/v1/machines/{machine_id}', + label: 'all params', + args: [ + 'machines', + 'update', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--autosleep', + '', + '--memory-mib', + '0', + '--storage-gib', + '0', + '--vcpu', + '0', + ], + }, + + { + operation: 'delete', + method: 'DELETE', + path: '/v1/machines/{machine_id}', + args: ['machines', 'delete', '--machine-id', '017f22e2-79b0-7cc3-98c4-dc0c0c07398f'], + }, + + { + operation: 'sleep', + method: 'POST', + path: '/v1/machines/{machine_id}/sleep', + args: ['machines', 'sleep', '--machine-id', '017f22e2-79b0-7cc3-98c4-dc0c0c07398f'], + }, + + { + operation: 'wake', + method: 'POST', + path: '/v1/machines/{machine_id}/wake', + args: ['machines', 'wake', '--machine-id', '017f22e2-79b0-7cc3-98c4-dc0c0c07398f'], + }, + + { + operation: 'reboot', + method: 'POST', + path: '/v1/machines/{machine_id}/reboot', + label: 'required params', + args: ['machines', 'reboot', '--machine-id', '017f22e2-79b0-7cc3-98c4-dc0c0c07398f'], + }, + + { + operation: 'reboot', + method: 'POST', + path: '/v1/machines/{machine_id}/reboot', + label: 'all params', + args: ['machines', 'reboot', '--machine-id', '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', '--force'], + }, + + { + operation: 'list', + method: 'GET', + path: '/v1/machines/{machine_id}/ssh', + args: [ + 'machines:ssh', + 'list', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--max-items', + '10', + ], + }, + + { + operation: 'create', + method: 'POST', + path: '/v1/machines/{machine_id}/ssh', + args: [ + 'machines:ssh', + 'create', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--public-key', + '', + ], + }, + + { + operation: 'retrieve', + method: 'GET', + path: '/v1/machines/{machine_id}/ssh/{session_id}', + args: [ + 'machines:ssh', + 'retrieve', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--session-id', + 'session_id', + ], + }, + + { + operation: 'delete', + method: 'DELETE', + path: '/v1/machines/{machine_id}/ssh/{session_id}', + args: [ + 'machines:ssh', + 'delete', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--session-id', + 'session_id', + ], + }, + + { + operation: 'list', + method: 'GET', + path: '/v1/machines/{machine_id}/executions', + args: [ + 'machines:executions', + 'list', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--max-items', + '10', + ], + }, + + { + operation: 'create', + method: 'POST', + path: '/v1/machines/{machine_id}/executions', + label: 'required params', + args: [ + 'machines:executions', + 'create', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--command', + '[""]', + ], + }, + + { + operation: 'create', + method: 'POST', + path: '/v1/machines/{machine_id}/executions', + label: 'all params', + args: [ + 'machines:executions', + 'create', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--command', + '[""]', + '--cwd', + '', + '--env', + '{}', + '--stdin', + '', + '--timeout-ms', + '0', + ], + }, + + { + operation: 'retrieve', + method: 'GET', + path: '/v1/machines/{machine_id}/executions/{execution_id}', + args: [ + 'machines:executions', + 'retrieve', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--execution-id', + 'execution_id', + ], + }, + + { + operation: 'delete', + method: 'DELETE', + path: '/v1/machines/{machine_id}/executions/{execution_id}', + args: [ + 'machines:executions', + 'delete', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--execution-id', + 'execution_id', + ], + }, + + { + operation: 'output', + method: 'GET', + path: '/v1/machines/{machine_id}/executions/{execution_id}/output', + args: [ + 'machines:executions', + 'output', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--execution-id', + 'execution_id', + ], + }, + + { + operation: 'events', + method: 'GET', + path: '/v1/machines/{machine_id}/executions/{execution_id}/events', + args: [ + 'machines:executions', + 'events', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--execution-id', + 'execution_id', + '--max-items', + '10', + ], + }, + + { + operation: 'retrieve', + method: 'GET', + path: '/v1/machines/{machine_id}/executions/{execution_id}/logs', + args: [ + 'machines:executions:logs', + 'retrieve', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--execution-id', + 'execution_id', + ], + }, + + { + operation: 'reauthorize', + method: 'POST', + path: '/v1/machines/{machine_id}/executions/{execution_id}/logs/reauthorize', + args: [ + 'machines:executions:logs', + 'reauthorize', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--execution-id', + 'execution_id', + ], + }, + + { + operation: 'createToken', + method: 'POST', + path: '/v1/machines/{machine_id}/executions/{execution_id}/logs/token', + args: [ + 'machines:executions:logs', + 'create-token', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--execution-id', + 'execution_id', + ], + }, + + { + operation: 'retrieve', + method: 'GET', + path: '/v1/machines/{machine_id}/autoresizing', + args: ['machines:autoresizing', 'retrieve', '--machine-id', '017f22e2-79b0-7cc3-98c4-dc0c0c07398f'], + }, + + { + operation: 'update', + method: 'PUT', + path: '/v1/machines/{machine_id}/autoresizing', + args: [ + 'machines:autoresizing', + 'update', + '--machine-id', + '017f22e2-79b0-7cc3-98c4-dc0c0c07398f', + '--enabled', + ], + }, + + { + operation: 'retrieve', + method: 'GET', + path: '/v1/organization/autoresizing', + args: ['organization:autoresizing', 'retrieve'], + }, + + { + operation: 'update', + method: 'PUT', + path: '/v1/organization/autoresizing', + args: ['organization:autoresizing', 'update', '--enabled'], + }, +]; + +// Each command gets its own budget so one hanging command fails on its own instead of stalling +// the whole run; the generator additionally bounds the overall run. +const COMMAND_TIMEOUT_MS = 60_000; + +// Locate the built executable from the nearest package.json `bin` entry. Walking up from this +// file (rather than assuming a fixed relative path) keeps it correct whether this harness runs +// from the repo's `tests/` directory or is staged flat into a runner by the smoke tester. +const resolveBinPath = (): string => { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let depth = 0; depth < 6; depth += 1) { + const manifestPath = join(dir, 'package.json'); + if (existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + bin?: string | Record; + }; + const bin = typeof manifest.bin === 'string' ? manifest.bin : Object.values(manifest.bin ?? {})[0]; + if (bin) return join(dir, bin); + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error( + 'Could not locate the built CLI binary (run the package build first so dist/esm/bin.js exists).', + ); +}; + +// A `file` flag is a path the CLI opens, so its argv token is a placeholder rather than a sampled +// value — nothing the schema could produce names a real file. One temporary file backs every such +// flag in the run: the commands only need the path to resolve and the bytes to arrive. +const createSmokeFile = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'scalar-cli-smoke-')); + const path = join(dir, 'smoke-upload.txt'); + writeFileSync(path, 'scalar smoke test upload\n', 'utf8'); + return path; +}; + +/** + * How many commands run at once, capped at the number of cases there are. + * + * SCALAR_SMOKE_CONCURRENCY overrides the default; anything unparseable falls back to it. + */ +const smokeConcurrency = (caseCount: number): number => { + const override = Number.parseInt(process.env['SCALAR_SMOKE_CONCURRENCY'] ?? '', 10); + const limit = Number.isInteger(override) && override > 0 ? override : 32; + return Math.min(limit, caseCount); +}; + +const main = async (): Promise => { + const binPath = resolveBinPath(); + const smokeFilePath = cases.some((testCase) => testCase.args.includes('__scalar_smoke_file__')) + ? createSmokeFile() + : undefined; + + // SCALAR_SMOKE_FILTER (comma-separated) keeps only cases whose operation name or path matches + // one of the needles, so a caller can smoke-test a subset. With no filter, every case runs. + const filter = process.env['SCALAR_SMOKE_FILTER']; + const needles = filter + ? filter + .split(',') + .map((needle) => needle.trim()) + .filter(Boolean) + : []; + const selected = + needles.length > 0 + ? cases.filter((testCase) => + needles.some((needle) => testCase.operation.includes(needle) || testCase.path.includes(needle)), + ) + : cases; + + // Run the selected commands under a bounded worker pool rather than all at once. Every case + // spawns a whole node process running the built binary, so an unbounded fan-out over a large + // SDK's command surface would swamp the machine. Each worker pulls the next index off a shared + // cursor and writes into a pre-sized array, so results stay in case order however the workers + // interleave. The per-case body catches everything and never rejects, so one failing command + // still cannot block the others. + const results: SmokeResult[] = new Array(selected.length); + let cursor = 0; + const runNext = async (): Promise => { + for (let index = cursor++; index < selected.length; index = cursor++) { + const testCase = selected[index]; + if (!testCase) continue; + const startedAt = Date.now(); + // `label` distinguishes the required-flags run from the all-flags run of the same command; + // it is omitted entirely when the command contributed only one case. + const identity = { + operation: testCase.operation, + method: testCase.method, + path: testCase.path, + ...(testCase.label ? { label: testCase.label } : {}), + }; + try { + // Pass the current environment through so the embedded SDK picks up the base URL and + // credentials; node runs the built bin exactly as the published executable would. + const args = testCase.args.map((arg) => + arg === '__scalar_smoke_file__' && smokeFilePath ? smokeFilePath : arg, + ); + await execFileAsync('node', [binPath, ...args], { + env: process.env, + timeout: COMMAND_TIMEOUT_MS, + maxBuffer: 1024 * 1024 * 20, + }); + results[index] = { ...identity, status: 'passed', durationMs: Date.now() - startedAt }; + } catch (error) { + // Surface stderr (commander/runtime error output) when present; fall back to the message. + const detail = + error && typeof error === 'object' && 'stderr' in error + ? String((error as { stderr?: unknown }).stderr ?? '') + : ''; + const message = + detail.trim() || (error instanceof Error ? (error.stack ?? error.message) : String(error)); + results[index] = { + ...identity, + status: 'failed', + durationMs: Date.now() - startedAt, + error: message, + }; + } + } + }; + await Promise.all(Array.from({ length: smokeConcurrency(selected.length) }, runNext)); + const failed = results.filter((result) => result.status === 'failed'); + + // With SCALAR_SMOKE_REPORT set, write a machine-readable report; otherwise print a table. + const reportPath = process.env['SCALAR_SMOKE_REPORT']; + if (reportPath) { + writeFileSync(reportPath, JSON.stringify({ total: results.length, failed: failed.length, results })); + } else { + for (const result of results) { + const suffix = result.label ? ` [${result.label}]` : ''; + if (result.status === 'passed') + console.log( + `\u2714 ${result.operation}${suffix} (${result.method} ${result.path}) ${result.durationMs}ms`, + ); + else + console.error( + `\u2718 ${result.operation}${suffix} (${result.method} ${result.path})\n${result.error ?? ''}`, + ); + } + if (results.length === 0) { + console.error('No commands ran (empty SDK or a SCALAR_SMOKE_FILTER that matched nothing).'); + } else { + console.log(`\n${results.length - failed.length}/${results.length} commands passed`); + } + } + + // An empty run (no operations, or a filter that matched nothing) is a failure, not a vacuous pass. + if (failed.length > 0 || results.length === 0) process.exitCode = 1; +}; + +void main(); diff --git a/tests/ssh-paths.test.mjs b/tests/ssh-paths.test.mjs new file mode 100644 index 0000000..c52a3da --- /dev/null +++ b/tests/ssh-paths.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import childProcess from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { syncBuiltinESMExports } from 'node:module' +import os from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' + +import { connectMachine } from '../dist/esm/custom/ssh.js' + +test('invariant_ssh_preserves_temporary_file_paths', async (t) => { + const prefix = process.platform === 'win32' ? 'dedalus ssh paths-' : 'dedalus ssh "paths" \\-' + const directory = await mkdtemp(join(os.tmpdir(), prefix)) + const spawn = childProcess.spawn + let config = '' + let diagnostics = '' + let keyPath + t.mock.method(os, 'tmpdir', () => directory) + t.mock.method(childProcess, 'spawn', (command, args, options) => { + if (command !== 'ssh') return spawn(command, args, options) + keyPath = args[args.indexOf('-i') + 1] + // Parse the production arguments with OpenSSH without opening a connection. + const child = spawn(command, ['-G', '-F', 'none', ...args], { stdio: ['ignore', 'pipe', 'pipe'] }) + child.stdout.on('data', (chunk) => { config += chunk }) + child.stderr.on('data', (chunk) => { diagnostics += chunk }) + return child + }) + syncBuiltinESMExports() + try { + await connectMachine({ createSSHSession: async () => ({ + machine_id: '00000000-0000-4000-8000-000000000111', + session_id: '00000000-0000-4000-8000-000000000012', status: 'ready', + connection: { + endpoint: 'localhost', port: 22, ssh_username: 'test', user_certificate: 'test-certificate', + host_trust: { host_pattern: 'localhost', public_key: 'test-host-key' }, + }, + }) }, 'machine') + assert.equal(process.exitCode, undefined, diagnostics) + assert.ok(keyPath.startsWith(directory)) + const lines = config.split('\n') + assert.ok(lines.includes(`identityfile ${keyPath}`), config) + assert.ok(lines.includes(`certificatefile ${keyPath}-cert.pub`), config) + assert.ok(lines.includes(`userknownhostsfile ${join(keyPath, '..', 'known_hosts')}`), config) + } finally { + t.mock.restoreAll() + syncBuiltinESMExports() + process.exitCode = undefined + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/tests/ssh-picker-pty.py b/tests/ssh-picker-pty.py new file mode 100644 index 0000000..779ae30 --- /dev/null +++ b/tests/ssh-picker-pty.py @@ -0,0 +1,74 @@ +"""Exercise the real picker against a pseudo-terminal after `npm run build`.""" + +import errno +import json +import os +import pty +import select +import subprocess +import time + + +SCRIPT = """ +import { pickSSHMachine } from './dist/esm/custom/ssh-picker.js'; +const mode = process.argv[1]; +const api = {listMachines: (_cursor, signal) => mode === 'loading' + ? new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(signal.reason))) + : Promise.resolve({items: [ + {machine_id: '00000000-0000-4000-8000-000000000001', name: 'alpha', phase: 'running'}, + {machine_id: '00000000-0000-4000-8000-000000000002', name: 'beta', phase: 'sleeping'}, + ], next_cursor: null})}; +const selected = await pickSSHMachine(api); +console.log('RESULT:' + JSON.stringify({selected: selected ?? null, raw: process.stdin.isRaw})); +""" + + +def scenario(name, mode, keys, expected): + master, slave = pty.openpty() + process = subprocess.Popen( + ['node', '--input-type=module', '-e', SCRIPT, mode], + stdin=slave, stdout=slave, stderr=slave, + ) + os.close(slave) + transcript = b'' + sent = False + deadline = time.monotonic() + 5 + try: + while time.monotonic() < deadline: + if not select.select([master], [], [], 0.1)[0]: + if process.poll() is not None: + break + continue + try: + chunk = os.read(master, 65536) + except OSError as error: + if error.errno == errno.EIO: + break + raise + if not chunk: + break + transcript += chunk + ready = b'Loading machines' if mode == 'loading' else b'Esc cancel' + if not sent and ready in transcript and (mode == 'loading' or b'00000000-0000-4000-8000-000000000002' in transcript): + os.write(master, keys) + sent = True + process.wait(timeout=1) + assert process.returncode == 0, transcript.decode() + assert sent, transcript.decode() + result = transcript.decode().split('RESULT:', 1)[1].splitlines()[0] + assert json.loads(result) == {'selected': expected, 'raw': False}, result + assert b'\x1b[?25h\x1b[?1049l' in transcript, 'terminal not restored' + print(f'{name}: passed') + return transcript.decode() + finally: + if process.poll() is None: + process.kill() + process.wait() + os.close(master) + + +scenario('search and Enter select stable ID', 'ready', b'beta\r', '00000000-0000-4000-8000-000000000002') +scenario('arrow keys select machine', 'ready', b'\x1b[B\x1b[A\x1b[B\r', '00000000-0000-4000-8000-000000000002') +scenario('Escape cancels', 'ready', b'\x1b', None) +scenario('Ctrl-C cancels', 'ready', b'\x03', None) +scenario('Escape aborts pending request', 'loading', b'\x1b', None) diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..15335eb --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "CommonJS", + "lib": ["ES2023", "DOM"], + "rootDir": "src", + "outDir": "./dist/cjs", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..890bfcd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2023", "DOM"], + "rootDir": "src", + "outDir": "./dist/esm", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}