From 37d766b71b22f39d58066b8863e8c3ac75c77cf1 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Sun, 26 Jul 2026 21:15:39 -0400 Subject: [PATCH] extract client package --- .claude-plugin/marketplace.json | 2 +- .claude/settings.json | 5 + .claude/skills/add-e2e-test/SKILL.md | 103 +- .claude/skills/add-resource-command/SKILL.md | 405 ++++-- .claude/skills/audit-architecture/SKILL.md | 143 +- .claude/skills/review/SKILL.md | 239 +++- .github/workflows/lint.yml | 19 +- .github/workflows/release-alpha.yml | 8 +- .github/workflows/release.yml | 5 +- .github/workflows/test.yml | 20 +- .gitignore | 5 +- .oxfmtrc.json | 2 +- .skillsaw.yaml | 9 +- CLAUDE.md | 201 +-- README.md | 147 +- bin/mb-dev | 2 +- bun.lock | 271 ++-- docs/architecture.md | 73 + package.json | 58 +- packages/cli/LICENSE | 661 +++++++++ packages/cli/README.md | 11 + packages/cli/package.json | 44 + .../cli/skill-data}/core/SKILL.md | 47 +- .../cli/skill-data}/dashboard/SKILL.md | 8 +- .../cli/skill-data}/data-workflow/SKILL.md | 0 .../references/answering-questions.md | 0 .../references/building-clean-tables.md | 0 .../references/reusable-definitions.md | 0 .../cli/skill-data}/document/SKILL.md | 4 +- .../cli/skill-data}/git-sync/SKILL.md | 0 .../cli/skill-data}/mbql/SKILL.md | 0 .../skill-data}/mbql/references/operators.md | 0 .../cli/skill-data}/metadata/SKILL.md | 0 .../metadata/references/semantic-types.md | 0 .../cli/skill-data}/native-sql/SKILL.md | 0 .../native-sql/references/template-tags.md | 0 .../cli/skill-data}/notification/SKILL.md | 0 .../cli/skill-data}/transform/SKILL.md | 2 +- .../cli/skill-data}/visualization/SKILL.md | 0 .../visualization/references/settings.md | 0 .../cli/skills}/metabase-cli/SKILL.md | 0 .../cli/src/architecture.test.ts | 48 +- {src => packages/cli/src}/cli.ts | 7 +- .../cli/src}/commands/alert/archive.ts | 6 +- .../cli/src}/commands/alert/create.ts | 8 +- .../cli/src}/commands/alert/get.ts | 7 +- .../cli/src}/commands/alert/index.ts | 0 .../cli/src}/commands/alert/list.ts | 33 +- .../cli/src}/commands/alert/send.ts | 12 +- .../cli/src}/commands/alert/summary.ts | 2 +- .../cli/src}/commands/alert/update.ts | 6 +- .../cli/src}/commands/auth/index.ts | 0 .../cli/src}/commands/auth/list.test.ts | 87 +- .../cli/src}/commands/auth/list.ts | 47 +- .../cli/src}/commands/auth/login.ts | 56 +- .../cli/src}/commands/auth/logout.test.ts | 28 +- .../cli/src}/commands/auth/logout.ts | 12 +- .../cli/src}/commands/auth/render.ts | 6 +- .../cli/src}/commands/auth/status.test.ts | 2 +- .../cli/src}/commands/auth/status.ts | 13 +- .../cli/src}/commands/body-flags.ts | 0 .../cli/src}/commands/card/alerts.ts | 22 +- .../cli/src}/commands/card/archive.ts | 8 +- .../cli/src}/commands/card/create.ts | 8 +- .../cli/src}/commands/card/get.ts | 5 +- .../cli/src}/commands/card/index.ts | 0 .../cli/src}/commands/card/list.ts | 30 +- .../cli/src}/commands/card/query.ts | 61 +- .../cli/src}/commands/card/update.ts | 8 +- .../cli/src}/commands/collection/archive.ts | 8 +- .../cli/src}/commands/collection/create.ts | 11 +- .../cli/src}/commands/collection/get.ts | 5 +- .../cli/src}/commands/collection/index.ts | 0 .../cli/src}/commands/collection/items.ts | 44 +- .../cli/src}/commands/collection/list.ts | 28 +- .../commands/collection/parse-ref.test.ts | 2 +- .../cli/src}/commands/collection/parse-ref.ts | 2 +- .../cli/src}/commands/collection/tree.ts | 8 +- .../cli/src}/commands/context.test.ts | 7 +- {src => packages/cli/src}/commands/context.ts | 39 +- .../cli/src}/commands/dashboard/archive.ts | 8 +- .../cli/src}/commands/dashboard/cards.ts | 13 +- .../cli/src}/commands/dashboard/create.ts | 18 +- .../cli/src}/commands/dashboard/get.ts | 5 +- .../cli/src}/commands/dashboard/index.ts | 0 .../cli/src}/commands/dashboard/list.ts | 21 +- .../commands/dashboard/parameter-values.ts | 16 +- .../src}/commands/dashboard/preflight.test.ts | 179 +-- .../cli/src/commands/dashboard/preflight.ts | 45 + .../src}/commands/dashboard/subscriptions.ts | 18 +- .../commands/dashboard/update-dashcard.ts | 36 +- .../cli/src}/commands/dashboard/update.ts | 8 +- {src => packages/cli/src}/commands/db/get.ts | 13 +- .../cli/src}/commands/db/hints.ts | 0 .../cli/src}/commands/db/index.ts | 0 {src => packages/cli/src}/commands/db/list.ts | 31 +- .../cli/src}/commands/db/rescan-values.ts | 11 +- .../cli/src}/commands/db/schema-tables.ts | 20 +- .../cli/src}/commands/db/schemas.ts | 16 +- .../cli/src}/commands/db/sync-schema.ts | 44 +- .../cli/src}/commands/delete-runtime.test.ts | 23 +- .../cli/src}/commands/delete-runtime.ts | 23 +- .../cli/src}/commands/document/archive.ts | 8 +- .../cli/src}/commands/document/create.ts | 8 +- .../cli/src}/commands/document/get.ts | 5 +- .../cli/src}/commands/document/index.ts | 0 packages/cli/src/commands/document/list.ts | 22 + .../cli/src}/commands/document/update.ts | 8 +- {src => packages/cli/src}/commands/eid.ts | 13 +- .../cli/src}/commands/field/get.ts | 5 +- .../cli/src}/commands/field/index.ts | 0 .../cli/src}/commands/field/summary.ts | 9 +- .../cli/src}/commands/field/update.ts | 8 +- .../cli/src}/commands/field/values.ts | 5 +- .../cli/src}/commands/flag-pair.test.ts | 2 +- .../cli/src}/commands/flag-pair.ts | 6 +- {src => packages/cli/src}/commands/flags.ts | 26 + .../src}/commands/git-sync/add-collection.ts | 36 +- .../cli/src/commands/git-sync/branches.ts | 32 + .../cli/src}/commands/git-sync/cancel-task.ts | 12 +- .../src}/commands/git-sync/create-branch.ts | 33 +- .../src}/commands/git-sync/current-task.ts | 14 +- packages/cli/src/commands/git-sync/dirty.ts | 23 + .../cli/src}/commands/git-sync/export.ts | 64 +- .../commands/git-sync/has-remote-changes.ts | 38 + packages/cli/src/commands/git-sync/import.ts | 71 + .../cli/src}/commands/git-sync/index.ts | 0 .../cli/src}/commands/git-sync/is-dirty.ts | 8 +- .../commands/git-sync/remove-collection.ts | 13 +- .../cli/src}/commands/git-sync/stash.ts | 68 +- .../cli/src}/commands/git-sync/status.ts | 40 +- .../src/commands/git-sync/sync-scope.test.ts | 38 + .../cli/src/commands/git-sync/sync-scope.ts | 42 + .../src/commands/git-sync/sync-task.test.ts | 68 + .../cli/src/commands/git-sync/sync-task.ts | 59 + .../cli/src}/commands/git-sync/wait.ts | 18 +- .../cli/src}/commands/global-flags.test.ts | 20 + .../cli/src}/commands/global-flags.ts | 9 +- {src => packages/cli/src}/commands/group.ts | 2 +- .../cli/src}/commands/known-flags.test.ts | 3 +- .../cli/src}/commands/known-flags.ts | 2 +- .../cli/src}/commands/library/create.ts | 7 +- .../cli/src}/commands/library/get.ts | 7 +- .../cli/src}/commands/library/index.ts | 0 .../cli/src}/commands/library/publish.ts | 26 +- .../src}/commands/library/selectors.test.ts | 2 +- .../cli/src}/commands/library/selectors.ts | 15 +- .../cli/src}/commands/library/unpublish.ts | 10 +- .../cli/src}/commands/measure/archive.ts | 8 +- .../cli/src}/commands/measure/create.ts | 8 +- .../cli/src}/commands/measure/get.ts | 5 +- .../cli/src}/commands/measure/index.ts | 0 packages/cli/src/commands/measure/list.ts | 22 + .../cli/src}/commands/measure/update.ts | 8 +- .../cli/src}/commands/parse-enum.test.ts | 2 +- .../cli/src}/commands/parse-enum.ts | 2 +- .../cli/src}/commands/parse-id.ts | 0 .../cli/src}/commands/parse-integer.test.ts | 8 +- .../cli/src}/commands/parse-integer.ts | 9 +- {src => packages/cli/src}/commands/query.ts | 14 +- .../src}/commands/revision-message-flag.ts | 2 +- .../cli/src}/commands/runtime.test.ts | 85 +- {src => packages/cli/src}/commands/runtime.ts | 60 +- {src => packages/cli/src}/commands/search.ts | 59 +- .../cli/src}/commands/segment/archive.ts | 8 +- .../cli/src}/commands/segment/create.ts | 8 +- .../cli/src}/commands/segment/get.ts | 5 +- .../cli/src}/commands/segment/index.ts | 0 packages/cli/src/commands/segment/list.ts | 22 + .../cli/src}/commands/segment/update.ts | 8 +- .../cli/src}/commands/setting/get.ts | 27 +- .../cli/src}/commands/setting/index.ts | 0 packages/cli/src/commands/setting/key.test.ts | 29 + packages/cli/src/commands/setting/key.ts | 11 + packages/cli/src/commands/setting/list.ts | 22 + .../cli/src}/commands/setting/set.ts | 13 +- {src => packages/cli/src}/commands/setup.ts | 8 +- packages/cli/src/commands/skills/get.test.ts | 62 + .../cli/src}/commands/skills/get.ts | 22 +- .../cli/src}/commands/skills/index.ts | 0 packages/cli/src/commands/skills/list.test.ts | 38 + .../cli/src}/commands/skills/list.ts | 20 +- .../cli/src}/commands/skills/path.ts | 16 +- .../cli/src}/commands/snippet/archive.ts | 8 +- .../cli/src}/commands/snippet/create.ts | 8 +- .../cli/src}/commands/snippet/get.ts | 5 +- .../cli/src}/commands/snippet/index.ts | 0 .../cli/src}/commands/snippet/list.ts | 19 +- .../cli/src}/commands/snippet/update.ts | 8 +- .../cli/src}/commands/subscription/archive.ts | 7 +- .../cli/src}/commands/subscription/create.ts | 8 +- .../cli/src}/commands/subscription/get.ts | 5 +- .../cli/src}/commands/subscription/index.ts | 0 .../cli/src}/commands/subscription/list.ts | 23 +- .../cli/src}/commands/subscription/update.ts | 7 +- .../cli/src}/commands/table/fields.ts | 15 +- .../cli/src}/commands/table/get.ts | 14 +- .../cli/src}/commands/table/hints.ts | 0 .../cli/src}/commands/table/index.ts | 0 .../cli/src}/commands/table/list.ts | 20 +- .../cli/src}/commands/table/update.ts | 8 +- .../src}/commands/timeline-event/archive.ts | 8 +- .../src}/commands/timeline-event/create.ts | 8 +- .../src}/commands/timeline-event/delete.ts | 3 +- .../cli/src}/commands/timeline-event/get.ts | 5 +- .../cli/src}/commands/timeline-event/index.ts | 0 .../src}/commands/timeline-event/update.ts | 8 +- .../cli/src}/commands/timeline/archive.ts | 8 +- .../cli/src}/commands/timeline/create.ts | 8 +- .../cli/src}/commands/timeline/delete.ts | 3 +- .../cli/src}/commands/timeline/events.ts | 17 +- .../cli/src}/commands/timeline/get.ts | 5 +- .../cli/src}/commands/timeline/index.ts | 0 .../cli/src}/commands/timeline/list.ts | 19 +- .../cli/src}/commands/timeline/update.ts | 8 +- .../cli/src}/commands/transform-job/create.ts | 12 +- .../cli/src}/commands/transform-job/delete.ts | 3 +- .../cli/src}/commands/transform-job/get.ts | 5 +- .../cli/src}/commands/transform-job/index.ts | 0 .../cli/src/commands/transform-job/list.ts | 22 + .../cli/src}/commands/transform-job/run.ts | 24 +- .../src}/commands/transform-job/set-active.ts | 22 +- .../src}/commands/transform-job/transforms.ts | 20 +- .../cli/src}/commands/transform-job/update.ts | 12 +- .../cli/src}/commands/transform-tag/create.ts | 12 +- .../cli/src}/commands/transform-tag/delete.ts | 3 +- .../cli/src}/commands/transform-tag/index.ts | 0 .../cli/src/commands/transform-tag/list.ts | 22 + .../cli/src}/commands/transform-tag/update.ts | 12 +- .../cli/src}/commands/transform/cancel.ts | 9 +- .../transform/collection-namespace.test.ts | 4 +- .../transform/collection-namespace.ts | 4 +- .../cli/src}/commands/transform/create.ts | 11 +- .../src}/commands/transform/delete-table.ts | 3 +- .../cli/src}/commands/transform/delete.ts | 3 +- .../src}/commands/transform/dependencies.ts | 17 +- .../cli/src}/commands/transform/get-run.ts | 5 +- .../cli/src}/commands/transform/get.ts | 5 +- .../cli/src}/commands/transform/index.ts | 0 packages/cli/src/commands/transform/list.ts | 22 + packages/cli/src/commands/transform/run.ts | 97 ++ .../cli/src}/commands/transform/runs.ts | 31 +- .../cli/src}/commands/transform/update.ts | 11 +- {src => packages/cli/src}/commands/upgrade.ts | 21 +- .../cli/src}/commands/upload/append.ts | 8 +- .../cli/src}/commands/upload/csv.ts | 18 +- .../cli/src}/commands/upload/index.ts | 0 .../cli/src}/commands/upload/replace.ts | 8 +- .../cli/src}/commands/uuid.test.ts | 2 +- {src => packages/cli/src}/commands/uuid.ts | 2 +- .../cli/src}/commands/validate-query.test.ts | 6 +- .../cli/src}/commands/validate-query.ts | 6 +- .../cli/src}/commands/wait-flags.test.ts | 19 +- .../cli/src}/commands/wait-flags.ts | 29 +- .../cli/src}/core/auth/keyring-mock.ts | 4 +- .../cli/src}/core/auth/profile-record.test.ts | 2 +- .../cli/src}/core/auth/profile-record.ts | 6 +- .../cli/src}/core/auth/storage.test.ts | 6 +- .../cli/src}/core/auth/storage.ts | 31 +- .../cli/src}/core/auth/temp-config-home.ts | 0 packages/cli/src/core/auth/verify.test.ts | 106 ++ packages/cli/src/core/auth/verify.ts | 109 ++ {src => packages/cli/src}/core/config.test.ts | 7 +- {src => packages/cli/src}/core/config.ts | 18 +- {src => packages/cli/src}/core/env.test.ts | 0 {src => packages/cli/src}/core/env.ts | 1 + .../cli/src}/core/install-method.test.ts | 0 .../cli/src}/core/install-method.ts | 6 +- .../cli/src/core}/npm-registry.test.ts | 31 +- .../cli/src/core}/npm-registry.ts | 23 +- {src => packages/cli/src}/core/paths.test.ts | 0 {src => packages/cli/src}/core/paths.ts | 0 .../cli/src}/core/schema/data/LICENSE.txt | 0 .../core/schema/data/schemas/common/id.json | 10 +- .../schema/data/schemas/common/parameter.json | 44 +- .../schema/data/schemas/common/query.json | 279 +++- .../core/schema/data/schemas/common/ref.json | 28 +- .../schemas/common/temporal_bucketing.json | 12 +- .../cli/src}/core/schema/validate.test.ts | 0 .../cli/src}/core/schema/validate.ts | 8 +- {src => packages/cli/src}/core/semver.test.ts | 0 {src => packages/cli/src}/core/semver.ts | 2 +- {src => packages/cli/src}/core/skills.test.ts | 16 +- {src => packages/cli/src}/core/skills.ts | 34 +- .../cli/src/core}/system-ca.ts | 0 packages/cli/src/core/temp-skills-dir.ts | 43 + packages/cli/src/core/user-agent.ts | 9 + {src => packages/cli/src}/main.ts | 0 packages/cli/src/output/cap.test.ts | 233 ++++ packages/cli/src/output/cap.ts | 63 + packages/cli/src/output/error.test.ts | 377 +++++ packages/cli/src/output/error.ts | 134 ++ .../cli/src}/output/format.test.ts | 2 +- {src => packages/cli/src}/output/format.ts | 4 +- {src => packages/cli/src}/output/help.test.ts | 33 +- {src => packages/cli/src}/output/help.ts | 5 +- .../cli/src}/output/notice.test.ts | 12 + packages/cli/src/output/notice.ts | 23 + .../cli/src}/output/projection.test.ts | 4 +- packages/cli/src/output/projection.ts | 111 ++ .../cli/src}/output/prompt.test.ts | 2 +- {src => packages/cli/src}/output/prompt.ts | 16 +- .../cli/src}/output/query-result.test.ts | 2 +- .../cli/src}/output/query-result.ts | 2 +- .../cli/src}/output/render.test.ts | 209 ++- {src => packages/cli/src}/output/render.ts | 93 +- .../cli/src}/output/skill-list.test.ts | 0 .../cli/src}/output/skill-list.ts | 0 .../cli/src}/output/stream.test.ts | 0 {src => packages/cli/src}/output/stream.ts | 0 .../cli/src}/output/table.test.ts | 2 +- {src => packages/cli/src}/output/table.ts | 10 +- packages/cli/src/output/types.test.ts | 109 ++ packages/cli/src/output/types.ts | 102 ++ .../cli/src/output}/view.ts | 2 +- packages/cli/src/output/views.test.ts | 74 + packages/cli/src/output/views/card.ts | 26 + packages/cli/src/output/views/collection.ts | 31 + packages/cli/src/output/views/dashboard.ts | 31 + packages/cli/src/output/views/database.ts | 25 + packages/cli/src/output/views/document.ts | 14 + .../cli/src/output/views/eid-translation.ts | 11 + packages/cli/src/output/views/field.ts | 48 + packages/cli/src/output/views/git-sync.ts | 88 ++ packages/cli/src/output/views/library.ts | 12 + packages/cli/src/output/views/measure.ts | 14 + .../cli/src/output/views/notification.test.ts | 50 + packages/cli/src/output/views/notification.ts | 83 ++ packages/cli/src/output/views/parameter.ts | 8 + packages/cli/src/output/views/pulse.test.ts | 50 + packages/cli/src/output/views/pulse.ts | 61 + packages/cli/src/output/views/search.ts | 12 + packages/cli/src/output/views/segment.ts | 14 + packages/cli/src/output/views/setting.ts | 21 + packages/cli/src/output/views/setup.ts | 8 + packages/cli/src/output/views/snippet.ts | 14 + packages/cli/src/output/views/table.ts | 16 + packages/cli/src/output/views/timeline.ts | 33 + .../cli/src/output/views/transform-job.ts | 16 + .../cli/src/output/views/transform-tag.ts | 12 + .../cli/src/output/views/transform.test.ts | 36 + packages/cli/src/output/views/transform.ts | 44 + packages/cli/src/output/views/upload.ts | 19 + packages/cli/src/output/views/user.test.ts | 14 + packages/cli/src/output/views/user.ts | 13 + packages/cli/src/output/window.test.ts | 494 +++++++ packages/cli/src/output/window.ts | 133 ++ .../cli/src}/runtime/body.test.ts | 2 +- {src => packages/cli/src}/runtime/body.ts | 6 +- .../cli/src}/runtime/citty.test.ts | 0 {src => packages/cli/src}/runtime/citty.ts | 2 +- .../cli/src}/runtime/command-augment.test.ts | 2 +- .../cli/src}/runtime/command-augment.ts | 2 +- .../cli/src}/runtime/command-help.test.ts | 102 +- .../cli/src}/runtime/command-help.ts | 38 +- {src => packages/cli/src}/runtime/csv.test.ts | 0 {src => packages/cli/src}/runtime/csv.ts | 2 +- .../cli/src}/runtime/input.test.ts | 11 +- {src => packages/cli/src}/runtime/input.ts | 11 +- packages/cli/src/runtime/interrupt.test.ts | 97 ++ packages/cli/src/runtime/interrupt.ts | 38 + .../cli/src}/runtime/process.test.ts | 0 {src => packages/cli/src}/runtime/process.ts | 16 +- .../cli/src}/runtime/upload.test.ts | 19 +- {src => packages/cli/src}/runtime/upload.ts | 18 +- .../cli/src}/runtime/yaml.test.ts | 2 +- {src => packages/cli/src}/runtime/yaml.ts | 6 +- packages/cli/tsconfig.json | 4 + .../cli/tsdown.config.ts | 2 +- packages/client/CHANGELOG.md | 12 + packages/client/LICENSE | 661 +++++++++ packages/client/README.md | 428 ++++++ packages/client/package.json | 64 + packages/client/src/architecture.test.ts | 210 +++ .../client/src}/auth/callback-server.test.ts | 0 .../client/src}/auth/callback-server.ts | 2 +- .../client/src}/auth/credential.test.ts | 0 .../client/src}/auth/credential.ts | 32 + .../client/src}/auth/oauth-login.test.ts | 76 +- .../client/src}/auth/oauth-login.ts | 22 +- .../client/src}/auth/oauth-session.test.ts | 21 +- .../client/src}/auth/oauth-session.ts | 9 +- .../client/src}/auth/pkce.test.ts | 0 .../core => packages/client/src}/auth/pkce.ts | 0 packages/client/src/client.ts | 68 + {src => packages/client/src}/domain/card.ts | 40 +- .../client/src}/domain/collection.ts | 39 +- {src => packages/client/src}/domain/cron.ts | 0 .../client/src}/domain/dashboard.ts | 28 +- .../client/src}/domain/database.ts | 25 +- .../client/src}/domain/document.test.ts | 0 .../client/src}/domain/document.ts | 15 +- .../client/src}/domain/eid-translation.ts | 7 - .../client/src}/domain/embedding.ts | 0 {src => packages/client/src}/domain/field.ts | 47 - packages/client/src/domain/git-sync.test.ts | 26 + packages/client/src/domain/git-sync.ts | 131 ++ .../client/src}/domain/library.ts | 10 - .../client/src}/domain/measure.ts | 17 +- .../client/src}/domain/notification.ts | 68 - .../client/src}/domain/parameter.ts | 7 - {src => packages/client/src}/domain/pulse.ts | 56 - {src => packages/client/src}/domain/search.ts | 11 - .../client/src}/domain/segment.ts | 15 +- .../client/src}/domain/session-properties.ts | 0 .../client/src}/domain/setting.ts | 20 - {src => packages/client/src}/domain/setup.ts | 7 - .../client/src}/domain/snippet.ts | 13 - {src => packages/client/src}/domain/table.ts | 17 +- .../client/src}/domain/timeline.ts | 27 - .../client/src}/domain/transform-job.ts | 29 +- .../client/src}/domain/transform-tag.ts | 11 - packages/client/src/domain/transform.test.ts | 26 + .../client/src}/domain/transform.ts | 75 +- .../client/src/domain/upload.ts | 21 +- .../client/src}/domain/user.test.ts | 13 +- {src => packages/client/src}/domain/user.ts | 12 - .../client/src}/errors.test.ts | 26 +- {src/core => packages/client/src}/errors.ts | 73 +- .../client/src}/http/errors.test.ts | 158 ++- .../client/src}/http/errors.ts | 129 +- .../client/src}/http/network-error.ts | 37 +- .../client/src}/http/oauth.test.ts | 41 +- .../client/src}/http/oauth.ts | 31 +- packages/client/src/http/response-shape.ts | 39 + .../client/src}/http/retry.test.ts | 4 +- .../client/src}/http/retry.ts | 0 .../client/src}/http/sanitize.test.ts | 0 .../client/src}/http/sanitize.ts | 0 packages/client/src/http/transport.test.ts | 1229 +++++++++++++++++ .../client/src/http/transport.ts | 130 +- packages/client/src/index.test.ts | 362 +++++ packages/client/src/index.ts | 275 ++++ .../client/src}/json-pointer.test.ts | 0 .../client/src}/json-pointer.ts | 0 .../client/src}/json.test.ts | 10 +- {src/runtime => packages/client/src}/json.ts | 4 +- packages/client/src/list.ts | 7 + packages/client/src/paginate.test.ts | 682 +++++++++ packages/client/src/paginate.ts | 95 ++ .../client/src}/poll.test.ts | 28 +- {src/runtime => packages/client/src}/poll.ts | 10 +- .../client/src}/predicates.ts | 0 packages/client/src/resources/card.test.ts | 220 +++ packages/client/src/resources/card.ts | 114 ++ .../client/src/resources/collection.test.ts | 267 ++++ packages/client/src/resources/collection.ts | 160 +++ packages/client/src/resources/csv-upload.ts | 14 + .../client/src/resources/dashboard.test.ts | 378 +++++ packages/client/src/resources/dashboard.ts | 302 ++++ .../client/src/resources/database.test.ts | 214 +++ packages/client/src/resources/database.ts | 138 ++ packages/client/src/resources/dataset.test.ts | 47 + packages/client/src/resources/dataset.ts | 18 + .../client/src/resources/document.test.ts | 132 ++ packages/client/src/resources/document.ts | 56 + .../src/resources/eid-translation.test.ts | 63 + .../client/src/resources/eid-translation.ts | 22 + packages/client/src/resources/field.test.ts | 121 ++ packages/client/src/resources/field.ts | 48 + .../client/src/resources/git-sync.test.ts | 565 ++++++++ packages/client/src/resources/git-sync.ts | 320 +++++ packages/client/src/resources/library.test.ts | 290 ++++ packages/client/src/resources/library.ts | 151 ++ packages/client/src/resources/measure.test.ts | 132 ++ packages/client/src/resources/measure.ts | 68 + .../client/src/resources/notification.test.ts | 306 ++++ packages/client/src/resources/notification.ts | 145 ++ .../client/src/resources/optional-parsed.ts | 29 + packages/client/src/resources/pulse.test.ts | 217 +++ packages/client/src/resources/pulse.ts | 81 ++ packages/client/src/resources/search.test.ts | 82 ++ packages/client/src/resources/search.ts | 58 + packages/client/src/resources/segment.test.ts | 135 ++ packages/client/src/resources/segment.ts | 69 + packages/client/src/resources/setting.test.ts | 156 +++ packages/client/src/resources/setting.ts | 55 + packages/client/src/resources/setup.test.ts | 63 + packages/client/src/resources/setup.ts | 18 + packages/client/src/resources/snippet.test.ts | 144 ++ packages/client/src/resources/snippet.ts | 67 + packages/client/src/resources/table.test.ts | 182 +++ packages/client/src/resources/table.ts | 98 ++ .../src/resources/timeline-event.test.ts | 144 ++ .../client/src/resources/timeline-event.ts | 60 + .../client/src/resources/timeline.test.ts | 205 +++ packages/client/src/resources/timeline.ts | 108 ++ .../src/resources/transform-job.test.ts | 221 +++ .../client/src/resources/transform-job.ts | 122 ++ .../src/resources/transform-tag.test.ts | 118 ++ .../client/src/resources/transform-tag.ts | 60 + .../client/src/resources/transform.test.ts | 413 ++++++ packages/client/src/resources/transform.ts | 219 +++ packages/client/src/resources/upload.test.ts | 129 ++ packages/client/src/resources/upload.ts | 66 + packages/client/src/resources/user.test.ts | 55 + packages/client/src/resources/user.ts | 11 + packages/client/src/signal.test.ts | 71 + packages/client/src/signal.ts | 24 + .../client/src/testing/fake-client.test.ts | 132 ++ packages/client/src/testing/fake-client.ts | 90 ++ .../client/src/testing}/fetch-capture.ts | 45 +- {src/core => packages/client/src}/url.test.ts | 1 - {src/core => packages/client/src}/url.ts | 0 .../client/src}/version/capabilities.test.ts | 4 +- .../client/src}/version/capabilities.ts | 16 +- .../client/src}/version/preflight-error.ts | 1 - .../client/src}/version/probe.test.ts | 27 +- .../client/src}/version/probe.ts | 6 +- .../client/src}/version/tag.test.ts | 2 + packages/client/src/version/tag.ts | 30 + .../client/tests}/fixtures/user/sample.json | 0 packages/client/tsconfig.json | 4 + packages/client/tsdown.config.ts | 46 + scripts/e2e-matrix.ts | 4 +- scripts/sync-representations.ts | 10 +- src/commands/alert/patch.test.ts | 110 -- src/commands/alert/patch.ts | 67 - src/commands/collection/listing.ts | 14 - src/commands/dashboard/preflight.ts | 123 -- src/commands/document/list.ts | 24 - src/commands/git-sync/branches.ts | 40 - src/commands/git-sync/dirty.ts | 28 - src/commands/git-sync/has-remote-changes.ts | 62 - src/commands/git-sync/import.ts | 106 -- src/commands/git-sync/poll-task.test.ts | 69 - src/commands/git-sync/poll-task.ts | 112 -- src/commands/git-sync/sync-scope.test.ts | 134 -- src/commands/git-sync/sync-scope.ts | 82 -- src/commands/library/resolve.ts | 85 -- src/commands/measure/list.ts | 24 - src/commands/segment/list.ts | 24 - src/commands/setting/key.test.ts | 64 - src/commands/setting/key.ts | 21 - src/commands/setting/list.ts | 24 - src/commands/subscription/patch.test.ts | 72 - src/commands/subscription/patch.ts | 28 - src/commands/transform-job/list.ts | 24 - src/commands/transform-tag/list.ts | 24 - src/commands/transform/list.ts | 24 - src/commands/transform/run.ts | 169 --- src/commands/upload/requests.test.ts | 22 - src/commands/upload/requests.ts | 49 - src/core/auth/verify.ts | 76 - src/core/http/client.test.ts | 658 --------- src/core/http/fake-client.ts | 43 - src/core/http/probe.ts | 20 - src/core/version/tag.ts | 21 - src/domain/git-sync.ts | 92 -- src/output/cap.test.ts | 115 -- src/output/cap.ts | 40 - src/output/error.test.ts | 168 --- src/output/error.ts | 64 - src/output/notice.ts | 13 - src/output/projection.ts | 72 - src/output/types.test.ts | 85 -- src/output/types.ts | 48 - src/runtime/capabilities.ts | 11 - src/runtime/paginate.test.ts | 386 ------ src/runtime/paginate.ts | 91 -- src/runtime/signal.test.ts | 81 -- src/runtime/signal.ts | 66 - tests/e2e/alert.e2e.test.ts | 37 +- tests/e2e/auth.e2e.test.ts | 15 +- tests/e2e/bootstrap-data.ts | 14 +- tests/e2e/card-query.ts | 2 +- tests/e2e/card.e2e.test.ts | 29 +- tests/e2e/cli-error.ts | 2 +- tests/e2e/collection.e2e.test.ts | 66 +- .../dashboard-parameter-values.e2e.test.ts | 6 +- tests/e2e/dashboard.e2e.test.ts | 26 +- tests/e2e/db.e2e.test.ts | 37 +- tests/e2e/document.e2e.test.ts | 6 +- tests/e2e/eid-translation.e2e.test.ts | 11 +- tests/e2e/field.e2e.test.ts | 18 +- tests/e2e/git-sync.e2e.test.ts | 24 +- tests/e2e/help-json.e2e.test.ts | 8 +- tests/e2e/interrupt.e2e.test.ts | 111 ++ tests/e2e/library.e2e.test.ts | 20 +- tests/e2e/measure.e2e.test.ts | 24 +- tests/e2e/oauth.e2e.test.ts | 24 +- tests/e2e/profiles.e2e.test.ts | 23 +- tests/e2e/query.e2e.test.ts | 9 +- tests/e2e/run-cli.e2e.test.ts | 79 ++ tests/e2e/run-cli.ts | 70 +- tests/e2e/search.e2e.test.ts | 20 +- tests/e2e/segment.e2e.test.ts | 21 +- tests/e2e/server-gate.ts | 95 +- tests/e2e/setting.e2e.test.ts | 25 +- tests/e2e/setup/bootstrap.ts | 306 +++- tests/e2e/setup/global-setup.ts | 36 +- tests/e2e/setup/oauth-harness.ts | 9 +- tests/e2e/setup/reset.ts | 16 +- tests/e2e/skills.e2e.test.ts | 64 +- tests/e2e/snippet.e2e.test.ts | 18 +- tests/e2e/subscription.e2e.test.ts | 30 +- tests/e2e/table.e2e.test.ts | 75 +- tests/e2e/timeline-event.e2e.test.ts | 6 +- tests/e2e/timeline.e2e.test.ts | 34 +- tests/e2e/transform-job.e2e.test.ts | 108 +- tests/e2e/transform-tag.e2e.test.ts | 10 +- tests/e2e/transform.e2e.test.ts | 143 +- tests/e2e/upgrade.e2e.test.ts | 6 +- tests/e2e/uuid.e2e.test.ts | 4 +- tests/e2e/version.e2e.test.ts | 10 +- tsconfig.json | 17 +- vitest.workspace.ts | 17 +- 607 files changed, 22460 insertions(+), 7312 deletions(-) create mode 100644 .claude/settings.json create mode 100644 docs/architecture.md create mode 100644 packages/cli/LICENSE create mode 100644 packages/cli/README.md create mode 100644 packages/cli/package.json rename {skill-data => packages/cli/skill-data}/core/SKILL.md (81%) rename {skill-data => packages/cli/skill-data}/dashboard/SKILL.md (87%) rename {skill-data => packages/cli/skill-data}/data-workflow/SKILL.md (100%) rename {skill-data => packages/cli/skill-data}/data-workflow/references/answering-questions.md (100%) rename {skill-data => packages/cli/skill-data}/data-workflow/references/building-clean-tables.md (100%) rename {skill-data => packages/cli/skill-data}/data-workflow/references/reusable-definitions.md (100%) rename {skill-data => packages/cli/skill-data}/document/SKILL.md (94%) rename {skill-data => packages/cli/skill-data}/git-sync/SKILL.md (100%) rename {skill-data => packages/cli/skill-data}/mbql/SKILL.md (100%) rename {skill-data => packages/cli/skill-data}/mbql/references/operators.md (100%) rename {skill-data => packages/cli/skill-data}/metadata/SKILL.md (100%) rename {skill-data => packages/cli/skill-data}/metadata/references/semantic-types.md (100%) rename {skill-data => packages/cli/skill-data}/native-sql/SKILL.md (100%) rename {skill-data => packages/cli/skill-data}/native-sql/references/template-tags.md (100%) rename {skill-data => packages/cli/skill-data}/notification/SKILL.md (100%) rename {skill-data => packages/cli/skill-data}/transform/SKILL.md (99%) rename {skill-data => packages/cli/skill-data}/visualization/SKILL.md (100%) rename {skill-data => packages/cli/skill-data}/visualization/references/settings.md (100%) rename {skills => packages/cli/skills}/metabase-cli/SKILL.md (100%) rename tests/structure.test.ts => packages/cli/src/architecture.test.ts (74%) rename {src => packages/cli/src}/cli.ts (86%) rename {src => packages/cli/src}/commands/alert/archive.ts (84%) rename {src => packages/cli/src}/commands/alert/create.ts (89%) rename {src => packages/cli/src}/commands/alert/get.ts (82%) rename {src => packages/cli/src}/commands/alert/index.ts (100%) rename {src => packages/cli/src}/commands/alert/list.ts (69%) rename {src => packages/cli/src}/commands/alert/send.ts (80%) rename {src => packages/cli/src}/commands/alert/summary.ts (75%) rename {src => packages/cli/src}/commands/alert/update.ts (88%) rename {src => packages/cli/src}/commands/auth/index.ts (100%) rename {src => packages/cli/src}/commands/auth/list.test.ts (67%) rename {src => packages/cli/src}/commands/auth/list.ts (81%) rename {src => packages/cli/src}/commands/auth/login.ts (83%) rename {src => packages/cli/src}/commands/auth/logout.test.ts (86%) rename {src => packages/cli/src}/commands/auth/logout.ts (89%) rename {src => packages/cli/src}/commands/auth/render.ts (86%) rename {src => packages/cli/src}/commands/auth/status.test.ts (98%) rename {src => packages/cli/src}/commands/auth/status.ts (89%) rename {src => packages/cli/src}/commands/body-flags.ts (100%) rename {src => packages/cli/src}/commands/card/alerts.ts (63%) rename {src => packages/cli/src}/commands/card/archive.ts (81%) rename {src => packages/cli/src}/commands/card/create.ts (92%) rename {src => packages/cli/src}/commands/card/get.ts (83%) rename {src => packages/cli/src}/commands/card/index.ts (100%) rename {src => packages/cli/src}/commands/card/list.ts (66%) rename {src => packages/cli/src}/commands/card/query.ts (64%) rename {src => packages/cli/src}/commands/card/update.ts (91%) rename {src => packages/cli/src}/commands/collection/archive.ts (80%) rename {src => packages/cli/src}/commands/collection/create.ts (89%) rename {src => packages/cli/src}/commands/collection/get.ts (84%) rename {src => packages/cli/src}/commands/collection/index.ts (100%) rename {src => packages/cli/src}/commands/collection/items.ts (65%) rename {src => packages/cli/src}/commands/collection/list.ts (55%) rename {src => packages/cli/src}/commands/collection/parse-ref.test.ts (98%) rename {src => packages/cli/src}/commands/collection/parse-ref.ts (92%) rename {src => packages/cli/src}/commands/collection/tree.ts (78%) rename {src => packages/cli/src}/commands/context.test.ts (97%) rename {src => packages/cli/src}/commands/context.ts (51%) rename {src => packages/cli/src}/commands/dashboard/archive.ts (80%) rename {src => packages/cli/src}/commands/dashboard/cards.ts (61%) rename {src => packages/cli/src}/commands/dashboard/create.ts (83%) rename {src => packages/cli/src}/commands/dashboard/get.ts (81%) rename {src => packages/cli/src}/commands/dashboard/index.ts (100%) rename {src => packages/cli/src}/commands/dashboard/list.ts (63%) rename {src => packages/cli/src}/commands/dashboard/parameter-values.ts (78%) rename {src => packages/cli/src}/commands/dashboard/preflight.test.ts (58%) create mode 100644 packages/cli/src/commands/dashboard/preflight.ts rename {src => packages/cli/src}/commands/dashboard/subscriptions.ts (70%) rename {src => packages/cli/src}/commands/dashboard/update-dashcard.ts (56%) rename {src => packages/cli/src}/commands/dashboard/update.ts (89%) rename {src => packages/cli/src}/commands/db/get.ts (85%) rename {src => packages/cli/src}/commands/db/hints.ts (100%) rename {src => packages/cli/src}/commands/db/index.ts (100%) rename {src => packages/cli/src}/commands/db/list.ts (58%) rename {src => packages/cli/src}/commands/db/rescan-values.ts (76%) rename {src => packages/cli/src}/commands/db/schema-tables.ts (64%) rename {src => packages/cli/src}/commands/db/schemas.ts (68%) rename {src => packages/cli/src}/commands/db/sync-schema.ts (56%) rename {src => packages/cli/src}/commands/delete-runtime.test.ts (68%) rename {src => packages/cli/src}/commands/delete-runtime.ts (70%) rename {src => packages/cli/src}/commands/document/archive.ts (80%) rename {src => packages/cli/src}/commands/document/create.ts (83%) rename {src => packages/cli/src}/commands/document/get.ts (81%) rename {src => packages/cli/src}/commands/document/index.ts (100%) create mode 100644 packages/cli/src/commands/document/list.ts rename {src => packages/cli/src}/commands/document/update.ts (85%) rename {src => packages/cli/src}/commands/eid.ts (91%) rename {src => packages/cli/src}/commands/field/get.ts (82%) rename {src => packages/cli/src}/commands/field/index.ts (100%) rename {src => packages/cli/src}/commands/field/summary.ts (73%) rename {src => packages/cli/src}/commands/field/update.ts (87%) rename {src => packages/cli/src}/commands/field/values.ts (89%) rename {src => packages/cli/src}/commands/flag-pair.test.ts (96%) rename {src => packages/cli/src}/commands/flag-pair.ts (86%) rename {src => packages/cli/src}/commands/flags.ts (56%) rename {src => packages/cli/src}/commands/git-sync/add-collection.ts (53%) create mode 100644 packages/cli/src/commands/git-sync/branches.ts rename {src => packages/cli/src}/commands/git-sync/cancel-task.ts (72%) rename {src => packages/cli/src}/commands/git-sync/create-branch.ts (55%) rename {src => packages/cli/src}/commands/git-sync/current-task.ts (77%) create mode 100644 packages/cli/src/commands/git-sync/dirty.ts rename {src => packages/cli/src}/commands/git-sync/export.ts (55%) create mode 100644 packages/cli/src/commands/git-sync/has-remote-changes.ts create mode 100644 packages/cli/src/commands/git-sync/import.ts rename {src => packages/cli/src}/commands/git-sync/index.ts (100%) rename {src => packages/cli/src}/commands/git-sync/is-dirty.ts (80%) rename {src => packages/cli/src}/commands/git-sync/remove-collection.ts (82%) rename {src => packages/cli/src}/commands/git-sync/stash.ts (52%) rename {src => packages/cli/src}/commands/git-sync/status.ts (69%) create mode 100644 packages/cli/src/commands/git-sync/sync-scope.test.ts create mode 100644 packages/cli/src/commands/git-sync/sync-scope.ts create mode 100644 packages/cli/src/commands/git-sync/sync-task.test.ts create mode 100644 packages/cli/src/commands/git-sync/sync-task.ts rename {src => packages/cli/src}/commands/git-sync/wait.ts (75%) rename {src => packages/cli/src}/commands/global-flags.test.ts (82%) rename {src => packages/cli/src}/commands/global-flags.ts (91%) rename {src => packages/cli/src}/commands/group.ts (96%) rename {src => packages/cli/src}/commands/known-flags.test.ts (97%) rename {src => packages/cli/src}/commands/known-flags.ts (97%) rename {src => packages/cli/src}/commands/library/create.ts (84%) rename {src => packages/cli/src}/commands/library/get.ts (86%) rename {src => packages/cli/src}/commands/library/index.ts (100%) rename {src => packages/cli/src}/commands/library/publish.ts (75%) rename {src => packages/cli/src}/commands/library/selectors.test.ts (97%) rename {src => packages/cli/src}/commands/library/selectors.ts (80%) rename {src => packages/cli/src}/commands/library/unpublish.ts (88%) rename {src => packages/cli/src}/commands/measure/archive.ts (79%) rename {src => packages/cli/src}/commands/measure/create.ts (89%) rename {src => packages/cli/src}/commands/measure/get.ts (81%) rename {src => packages/cli/src}/commands/measure/index.ts (100%) create mode 100644 packages/cli/src/commands/measure/list.ts rename {src => packages/cli/src}/commands/measure/update.ts (90%) rename {src => packages/cli/src}/commands/parse-enum.test.ts (93%) rename {src => packages/cli/src}/commands/parse-enum.ts (89%) rename {src => packages/cli/src}/commands/parse-id.ts (100%) rename {src => packages/cli/src}/commands/parse-integer.test.ts (85%) rename {src => packages/cli/src}/commands/parse-integer.ts (65%) rename {src => packages/cli/src}/commands/query.ts (92%) rename {src => packages/cli/src}/commands/revision-message-flag.ts (76%) rename {src => packages/cli/src}/commands/runtime.test.ts (79%) rename {src => packages/cli/src}/commands/runtime.ts (80%) rename {src => packages/cli/src}/commands/search.ts (63%) rename {src => packages/cli/src}/commands/segment/archive.ts (79%) rename {src => packages/cli/src}/commands/segment/create.ts (89%) rename {src => packages/cli/src}/commands/segment/get.ts (81%) rename {src => packages/cli/src}/commands/segment/index.ts (100%) create mode 100644 packages/cli/src/commands/segment/list.ts rename {src => packages/cli/src}/commands/segment/update.ts (90%) rename {src => packages/cli/src}/commands/setting/get.ts (50%) rename {src => packages/cli/src}/commands/setting/index.ts (100%) create mode 100644 packages/cli/src/commands/setting/key.test.ts create mode 100644 packages/cli/src/commands/setting/key.ts create mode 100644 packages/cli/src/commands/setting/list.ts rename {src => packages/cli/src}/commands/setting/set.ts (82%) rename {src => packages/cli/src}/commands/setup.ts (84%) create mode 100644 packages/cli/src/commands/skills/get.test.ts rename {src => packages/cli/src}/commands/skills/get.ts (77%) rename {src => packages/cli/src}/commands/skills/index.ts (100%) create mode 100644 packages/cli/src/commands/skills/list.test.ts rename {src => packages/cli/src}/commands/skills/list.ts (68%) rename {src => packages/cli/src}/commands/skills/path.ts (77%) rename {src => packages/cli/src}/commands/snippet/archive.ts (79%) rename {src => packages/cli/src}/commands/snippet/create.ts (83%) rename {src => packages/cli/src}/commands/snippet/get.ts (82%) rename {src => packages/cli/src}/commands/snippet/index.ts (100%) rename {src => packages/cli/src}/commands/snippet/list.ts (58%) rename {src => packages/cli/src}/commands/snippet/update.ts (85%) rename {src => packages/cli/src}/commands/subscription/archive.ts (85%) rename {src => packages/cli/src}/commands/subscription/create.ts (91%) rename {src => packages/cli/src}/commands/subscription/get.ts (82%) rename {src => packages/cli/src}/commands/subscription/index.ts (100%) rename {src => packages/cli/src}/commands/subscription/list.ts (67%) rename {src => packages/cli/src}/commands/subscription/update.ts (90%) rename {src => packages/cli/src}/commands/table/fields.ts (63%) rename {src => packages/cli/src}/commands/table/get.ts (78%) rename {src => packages/cli/src}/commands/table/hints.ts (100%) rename {src => packages/cli/src}/commands/table/index.ts (100%) rename {src => packages/cli/src}/commands/table/list.ts (59%) rename {src => packages/cli/src}/commands/table/update.ts (87%) rename {src => packages/cli/src}/commands/timeline-event/archive.ts (80%) rename {src => packages/cli/src}/commands/timeline-event/create.ts (86%) rename {src => packages/cli/src}/commands/timeline-event/delete.ts (94%) rename {src => packages/cli/src}/commands/timeline-event/get.ts (81%) rename {src => packages/cli/src}/commands/timeline-event/index.ts (100%) rename {src => packages/cli/src}/commands/timeline-event/update.ts (86%) rename {src => packages/cli/src}/commands/timeline/archive.ts (80%) rename {src => packages/cli/src}/commands/timeline/create.ts (85%) rename {src => packages/cli/src}/commands/timeline/delete.ts (95%) rename {src => packages/cli/src}/commands/timeline/events.ts (61%) rename {src => packages/cli/src}/commands/timeline/get.ts (81%) rename {src => packages/cli/src}/commands/timeline/index.ts (100%) rename {src => packages/cli/src}/commands/timeline/list.ts (58%) rename {src => packages/cli/src}/commands/timeline/update.ts (86%) rename {src => packages/cli/src}/commands/transform-job/create.ts (82%) rename {src => packages/cli/src}/commands/transform-job/delete.ts (94%) rename {src => packages/cli/src}/commands/transform-job/get.ts (80%) rename {src => packages/cli/src}/commands/transform-job/index.ts (100%) create mode 100644 packages/cli/src/commands/transform-job/list.ts rename {src => packages/cli/src}/commands/transform-job/run.ts (67%) rename {src => packages/cli/src}/commands/transform-job/set-active.ts (77%) rename {src => packages/cli/src}/commands/transform-job/transforms.ts (66%) rename {src => packages/cli/src}/commands/transform-job/update.ts (83%) rename {src => packages/cli/src}/commands/transform-tag/create.ts (82%) rename {src => packages/cli/src}/commands/transform-tag/delete.ts (94%) rename {src => packages/cli/src}/commands/transform-tag/index.ts (100%) create mode 100644 packages/cli/src/commands/transform-tag/list.ts rename {src => packages/cli/src}/commands/transform-tag/update.ts (83%) rename {src => packages/cli/src}/commands/transform/cancel.ts (82%) rename {src => packages/cli/src}/commands/transform/collection-namespace.test.ts (92%) rename {src => packages/cli/src}/commands/transform/collection-namespace.ts (89%) rename {src => packages/cli/src}/commands/transform/create.ts (86%) rename {src => packages/cli/src}/commands/transform/delete-table.ts (95%) rename {src => packages/cli/src}/commands/transform/delete.ts (94%) rename {src => packages/cli/src}/commands/transform/dependencies.ts (67%) rename {src => packages/cli/src}/commands/transform/get-run.ts (84%) rename {src => packages/cli/src}/commands/transform/get.ts (81%) rename {src => packages/cli/src}/commands/transform/index.ts (100%) create mode 100644 packages/cli/src/commands/transform/list.ts create mode 100644 packages/cli/src/commands/transform/run.ts rename {src => packages/cli/src}/commands/transform/runs.ts (54%) rename {src => packages/cli/src}/commands/transform/update.ts (87%) rename {src => packages/cli/src}/commands/upgrade.ts (91%) rename {src => packages/cli/src}/commands/upload/append.ts (87%) rename {src => packages/cli/src}/commands/upload/csv.ts (75%) rename {src => packages/cli/src}/commands/upload/index.ts (100%) rename {src => packages/cli/src}/commands/upload/replace.ts (87%) rename {src => packages/cli/src}/commands/uuid.test.ts (97%) rename {src => packages/cli/src}/commands/uuid.ts (96%) rename {src => packages/cli/src}/commands/validate-query.test.ts (97%) rename {src => packages/cli/src}/commands/validate-query.ts (93%) rename {src => packages/cli/src}/commands/wait-flags.test.ts (55%) rename {src => packages/cli/src}/commands/wait-flags.ts (68%) rename {src => packages/cli/src}/core/auth/keyring-mock.ts (94%) rename {src => packages/cli/src}/core/auth/profile-record.test.ts (83%) rename {src => packages/cli/src}/core/auth/profile-record.ts (93%) rename {src => packages/cli/src}/core/auth/storage.test.ts (99%) rename {src => packages/cli/src}/core/auth/storage.ts (95%) rename {src => packages/cli/src}/core/auth/temp-config-home.ts (100%) create mode 100644 packages/cli/src/core/auth/verify.test.ts create mode 100644 packages/cli/src/core/auth/verify.ts rename {src => packages/cli/src}/core/config.test.ts (98%) rename {src => packages/cli/src}/core/config.ts (94%) rename {src => packages/cli/src}/core/env.test.ts (100%) rename {src => packages/cli/src}/core/env.ts (96%) rename {src => packages/cli/src}/core/install-method.test.ts (100%) rename {src => packages/cli/src}/core/install-method.ts (95%) rename {src/core/http => packages/cli/src/core}/npm-registry.test.ts (84%) rename {src/core/http => packages/cli/src/core}/npm-registry.ts (78%) rename {src => packages/cli/src}/core/paths.test.ts (100%) rename {src => packages/cli/src}/core/paths.ts (100%) rename {src => packages/cli/src}/core/schema/data/LICENSE.txt (100%) rename {src => packages/cli/src}/core/schema/data/schemas/common/id.json (92%) rename {src => packages/cli/src}/core/schema/data/schemas/common/parameter.json (91%) rename {src => packages/cli/src}/core/schema/data/schemas/common/query.json (86%) rename {src => packages/cli/src}/core/schema/data/schemas/common/ref.json (90%) rename {src => packages/cli/src}/core/schema/data/schemas/common/temporal_bucketing.json (87%) rename {src => packages/cli/src}/core/schema/validate.test.ts (100%) rename {src => packages/cli/src}/core/schema/validate.ts (97%) rename {src => packages/cli/src}/core/semver.test.ts (100%) rename {src => packages/cli/src}/core/semver.ts (91%) rename {src => packages/cli/src}/core/skills.test.ts (96%) rename {src => packages/cli/src}/core/skills.ts (89%) rename {src/core/http => packages/cli/src/core}/system-ca.ts (100%) create mode 100644 packages/cli/src/core/temp-skills-dir.ts create mode 100644 packages/cli/src/core/user-agent.ts rename {src => packages/cli/src}/main.ts (100%) create mode 100644 packages/cli/src/output/cap.test.ts create mode 100644 packages/cli/src/output/cap.ts create mode 100644 packages/cli/src/output/error.test.ts create mode 100644 packages/cli/src/output/error.ts rename {src => packages/cli/src}/output/format.test.ts (96%) rename {src => packages/cli/src}/output/format.ts (89%) rename {src => packages/cli/src}/output/help.test.ts (94%) rename {src => packages/cli/src}/output/help.ts (98%) rename {src => packages/cli/src}/output/notice.test.ts (74%) create mode 100644 packages/cli/src/output/notice.ts rename {src => packages/cli/src}/output/projection.test.ts (98%) create mode 100644 packages/cli/src/output/projection.ts rename {src => packages/cli/src}/output/prompt.test.ts (98%) rename {src => packages/cli/src}/output/prompt.ts (86%) rename {src => packages/cli/src}/output/query-result.test.ts (97%) rename {src => packages/cli/src}/output/query-result.ts (92%) rename {src => packages/cli/src}/output/render.test.ts (70%) rename {src => packages/cli/src}/output/render.ts (66%) rename {src => packages/cli/src}/output/skill-list.test.ts (100%) rename {src => packages/cli/src}/output/skill-list.ts (100%) rename {src => packages/cli/src}/output/stream.test.ts (100%) rename {src => packages/cli/src}/output/stream.ts (100%) rename {src => packages/cli/src}/output/table.test.ts (97%) rename {src => packages/cli/src}/output/table.ts (77%) create mode 100644 packages/cli/src/output/types.test.ts create mode 100644 packages/cli/src/output/types.ts rename {src/domain => packages/cli/src/output}/view.ts (93%) create mode 100644 packages/cli/src/output/views.test.ts create mode 100644 packages/cli/src/output/views/card.ts create mode 100644 packages/cli/src/output/views/collection.ts create mode 100644 packages/cli/src/output/views/dashboard.ts create mode 100644 packages/cli/src/output/views/database.ts create mode 100644 packages/cli/src/output/views/document.ts create mode 100644 packages/cli/src/output/views/eid-translation.ts create mode 100644 packages/cli/src/output/views/field.ts create mode 100644 packages/cli/src/output/views/git-sync.ts create mode 100644 packages/cli/src/output/views/library.ts create mode 100644 packages/cli/src/output/views/measure.ts create mode 100644 packages/cli/src/output/views/notification.test.ts create mode 100644 packages/cli/src/output/views/notification.ts create mode 100644 packages/cli/src/output/views/parameter.ts create mode 100644 packages/cli/src/output/views/pulse.test.ts create mode 100644 packages/cli/src/output/views/pulse.ts create mode 100644 packages/cli/src/output/views/search.ts create mode 100644 packages/cli/src/output/views/segment.ts create mode 100644 packages/cli/src/output/views/setting.ts create mode 100644 packages/cli/src/output/views/setup.ts create mode 100644 packages/cli/src/output/views/snippet.ts create mode 100644 packages/cli/src/output/views/table.ts create mode 100644 packages/cli/src/output/views/timeline.ts create mode 100644 packages/cli/src/output/views/transform-job.ts create mode 100644 packages/cli/src/output/views/transform-tag.ts create mode 100644 packages/cli/src/output/views/transform.test.ts create mode 100644 packages/cli/src/output/views/transform.ts create mode 100644 packages/cli/src/output/views/upload.ts create mode 100644 packages/cli/src/output/views/user.test.ts create mode 100644 packages/cli/src/output/views/user.ts create mode 100644 packages/cli/src/output/window.test.ts create mode 100644 packages/cli/src/output/window.ts rename {src => packages/cli/src}/runtime/body.test.ts (98%) rename {src => packages/cli/src}/runtime/body.ts (85%) rename {src => packages/cli/src}/runtime/citty.test.ts (100%) rename {src => packages/cli/src}/runtime/citty.ts (93%) rename {src => packages/cli/src}/runtime/command-augment.test.ts (97%) rename {src => packages/cli/src}/runtime/command-augment.ts (89%) rename {src => packages/cli/src}/runtime/command-help.test.ts (83%) rename {src => packages/cli/src}/runtime/command-help.ts (81%) rename {src => packages/cli/src}/runtime/csv.test.ts (100%) rename {src => packages/cli/src}/runtime/csv.ts (96%) rename {src => packages/cli/src}/runtime/input.test.ts (91%) rename {src => packages/cli/src}/runtime/input.ts (86%) create mode 100644 packages/cli/src/runtime/interrupt.test.ts create mode 100644 packages/cli/src/runtime/interrupt.ts rename {src => packages/cli/src}/runtime/process.test.ts (100%) rename {src => packages/cli/src}/runtime/process.ts (94%) rename {src => packages/cli/src}/runtime/upload.test.ts (72%) rename {src => packages/cli/src}/runtime/upload.ts (60%) rename {src => packages/cli/src}/runtime/yaml.test.ts (97%) rename {src => packages/cli/src}/runtime/yaml.ts (90%) create mode 100644 packages/cli/tsconfig.json rename tsdown.config.ts => packages/cli/tsdown.config.ts (88%) create mode 100644 packages/client/CHANGELOG.md create mode 100644 packages/client/LICENSE create mode 100644 packages/client/README.md create mode 100644 packages/client/package.json create mode 100644 packages/client/src/architecture.test.ts rename {src/core => packages/client/src}/auth/callback-server.test.ts (100%) rename {src/core => packages/client/src}/auth/callback-server.ts (98%) rename {src/core => packages/client/src}/auth/credential.test.ts (100%) rename {src/core => packages/client/src}/auth/credential.ts (65%) rename {src/core => packages/client/src}/auth/oauth-login.test.ts (78%) rename {src/core => packages/client/src}/auth/oauth-login.ts (86%) rename {src/core => packages/client/src}/auth/oauth-session.test.ts (81%) rename {src/core => packages/client/src}/auth/oauth-session.ts (89%) rename {src/core => packages/client/src}/auth/pkce.test.ts (100%) rename {src/core => packages/client/src}/auth/pkce.ts (100%) create mode 100644 packages/client/src/client.ts rename {src => packages/client/src}/domain/card.ts (89%) rename {src => packages/client/src}/domain/collection.ts (87%) rename {src => packages/client/src}/domain/cron.ts (100%) rename {src => packages/client/src}/domain/dashboard.ts (89%) rename {src => packages/client/src}/domain/database.ts (59%) rename {src => packages/client/src}/domain/document.test.ts (100%) rename {src => packages/client/src}/domain/document.ts (90%) rename {src => packages/client/src}/domain/eid-translation.ts (86%) rename {src => packages/client/src}/domain/embedding.ts (100%) rename {src => packages/client/src}/domain/field.ts (79%) create mode 100644 packages/client/src/domain/git-sync.test.ts create mode 100644 packages/client/src/domain/git-sync.ts rename {src => packages/client/src}/domain/library.ts (70%) rename {src => packages/client/src}/domain/measure.ts (77%) rename {src => packages/client/src}/domain/notification.ts (76%) rename {src => packages/client/src}/domain/parameter.ts (94%) rename {src => packages/client/src}/domain/pulse.ts (76%) rename {src => packages/client/src}/domain/search.ts (80%) rename {src => packages/client/src}/domain/segment.ts (79%) rename {src => packages/client/src}/domain/session-properties.ts (100%) rename {src => packages/client/src}/domain/setting.ts (54%) rename {src => packages/client/src}/domain/setup.ts (83%) rename {src => packages/client/src}/domain/snippet.ts (81%) rename {src => packages/client/src}/domain/table.ts (84%) rename {src => packages/client/src}/domain/timeline.ts (81%) rename {src => packages/client/src}/domain/transform-job.ts (76%) rename {src => packages/client/src}/domain/transform-tag.ts (72%) create mode 100644 packages/client/src/domain/transform.test.ts rename {src => packages/client/src}/domain/transform.ts (79%) rename src/commands/upload/results.ts => packages/client/src/domain/upload.ts (51%) rename {src => packages/client/src}/domain/user.test.ts (72%) rename {src => packages/client/src}/domain/user.ts (58%) rename {src/core => packages/client/src}/errors.test.ts (93%) rename {src/core => packages/client/src}/errors.ts (79%) rename {src/core => packages/client/src}/http/errors.test.ts (62%) rename {src/core => packages/client/src}/http/errors.ts (61%) rename {src/core => packages/client/src}/http/network-error.ts (64%) rename {src/core => packages/client/src}/http/oauth.test.ts (90%) rename {src/core => packages/client/src}/http/oauth.ts (92%) create mode 100644 packages/client/src/http/response-shape.ts rename {src/core => packages/client/src}/http/retry.test.ts (93%) rename {src/core => packages/client/src}/http/retry.ts (100%) rename {src/core => packages/client/src}/http/sanitize.test.ts (100%) rename {src/core => packages/client/src}/http/sanitize.ts (100%) create mode 100644 packages/client/src/http/transport.test.ts rename src/core/http/client.ts => packages/client/src/http/transport.ts (69%) create mode 100644 packages/client/src/index.test.ts create mode 100644 packages/client/src/index.ts rename {src/core => packages/client/src}/json-pointer.test.ts (100%) rename {src/core => packages/client/src}/json-pointer.ts (100%) rename {src/runtime => packages/client/src}/json.test.ts (95%) rename {src/runtime => packages/client/src}/json.ts (93%) create mode 100644 packages/client/src/list.ts create mode 100644 packages/client/src/paginate.test.ts create mode 100644 packages/client/src/paginate.ts rename {src/runtime => packages/client/src}/poll.test.ts (89%) rename {src/runtime => packages/client/src}/poll.ts (88%) rename {src/runtime => packages/client/src}/predicates.ts (100%) create mode 100644 packages/client/src/resources/card.test.ts create mode 100644 packages/client/src/resources/card.ts create mode 100644 packages/client/src/resources/collection.test.ts create mode 100644 packages/client/src/resources/collection.ts create mode 100644 packages/client/src/resources/csv-upload.ts create mode 100644 packages/client/src/resources/dashboard.test.ts create mode 100644 packages/client/src/resources/dashboard.ts create mode 100644 packages/client/src/resources/database.test.ts create mode 100644 packages/client/src/resources/database.ts create mode 100644 packages/client/src/resources/dataset.test.ts create mode 100644 packages/client/src/resources/dataset.ts create mode 100644 packages/client/src/resources/document.test.ts create mode 100644 packages/client/src/resources/document.ts create mode 100644 packages/client/src/resources/eid-translation.test.ts create mode 100644 packages/client/src/resources/eid-translation.ts create mode 100644 packages/client/src/resources/field.test.ts create mode 100644 packages/client/src/resources/field.ts create mode 100644 packages/client/src/resources/git-sync.test.ts create mode 100644 packages/client/src/resources/git-sync.ts create mode 100644 packages/client/src/resources/library.test.ts create mode 100644 packages/client/src/resources/library.ts create mode 100644 packages/client/src/resources/measure.test.ts create mode 100644 packages/client/src/resources/measure.ts create mode 100644 packages/client/src/resources/notification.test.ts create mode 100644 packages/client/src/resources/notification.ts create mode 100644 packages/client/src/resources/optional-parsed.ts create mode 100644 packages/client/src/resources/pulse.test.ts create mode 100644 packages/client/src/resources/pulse.ts create mode 100644 packages/client/src/resources/search.test.ts create mode 100644 packages/client/src/resources/search.ts create mode 100644 packages/client/src/resources/segment.test.ts create mode 100644 packages/client/src/resources/segment.ts create mode 100644 packages/client/src/resources/setting.test.ts create mode 100644 packages/client/src/resources/setting.ts create mode 100644 packages/client/src/resources/setup.test.ts create mode 100644 packages/client/src/resources/setup.ts create mode 100644 packages/client/src/resources/snippet.test.ts create mode 100644 packages/client/src/resources/snippet.ts create mode 100644 packages/client/src/resources/table.test.ts create mode 100644 packages/client/src/resources/table.ts create mode 100644 packages/client/src/resources/timeline-event.test.ts create mode 100644 packages/client/src/resources/timeline-event.ts create mode 100644 packages/client/src/resources/timeline.test.ts create mode 100644 packages/client/src/resources/timeline.ts create mode 100644 packages/client/src/resources/transform-job.test.ts create mode 100644 packages/client/src/resources/transform-job.ts create mode 100644 packages/client/src/resources/transform-tag.test.ts create mode 100644 packages/client/src/resources/transform-tag.ts create mode 100644 packages/client/src/resources/transform.test.ts create mode 100644 packages/client/src/resources/transform.ts create mode 100644 packages/client/src/resources/upload.test.ts create mode 100644 packages/client/src/resources/upload.ts create mode 100644 packages/client/src/resources/user.test.ts create mode 100644 packages/client/src/resources/user.ts create mode 100644 packages/client/src/signal.test.ts create mode 100644 packages/client/src/signal.ts create mode 100644 packages/client/src/testing/fake-client.test.ts create mode 100644 packages/client/src/testing/fake-client.ts rename {src/core/http => packages/client/src/testing}/fetch-capture.ts (64%) rename {src/core => packages/client/src}/url.test.ts (99%) rename {src/core => packages/client/src}/url.ts (100%) rename {src/core => packages/client/src}/version/capabilities.test.ts (93%) rename {src/core => packages/client/src}/version/capabilities.ts (69%) rename {src/core => packages/client/src}/version/preflight-error.ts (94%) rename {src/core => packages/client/src}/version/probe.test.ts (72%) rename {src/core => packages/client/src}/version/probe.ts (72%) rename {src/core => packages/client/src}/version/tag.test.ts (87%) create mode 100644 packages/client/src/version/tag.ts rename {tests => packages/client/tests}/fixtures/user/sample.json (100%) create mode 100644 packages/client/tsconfig.json create mode 100644 packages/client/tsdown.config.ts delete mode 100644 src/commands/alert/patch.test.ts delete mode 100644 src/commands/alert/patch.ts delete mode 100644 src/commands/collection/listing.ts delete mode 100644 src/commands/dashboard/preflight.ts delete mode 100644 src/commands/document/list.ts delete mode 100644 src/commands/git-sync/branches.ts delete mode 100644 src/commands/git-sync/dirty.ts delete mode 100644 src/commands/git-sync/has-remote-changes.ts delete mode 100644 src/commands/git-sync/import.ts delete mode 100644 src/commands/git-sync/poll-task.test.ts delete mode 100644 src/commands/git-sync/poll-task.ts delete mode 100644 src/commands/git-sync/sync-scope.test.ts delete mode 100644 src/commands/git-sync/sync-scope.ts delete mode 100644 src/commands/library/resolve.ts delete mode 100644 src/commands/measure/list.ts delete mode 100644 src/commands/segment/list.ts delete mode 100644 src/commands/setting/key.test.ts delete mode 100644 src/commands/setting/key.ts delete mode 100644 src/commands/setting/list.ts delete mode 100644 src/commands/subscription/patch.test.ts delete mode 100644 src/commands/subscription/patch.ts delete mode 100644 src/commands/transform-job/list.ts delete mode 100644 src/commands/transform-tag/list.ts delete mode 100644 src/commands/transform/list.ts delete mode 100644 src/commands/transform/run.ts delete mode 100644 src/commands/upload/requests.test.ts delete mode 100644 src/commands/upload/requests.ts delete mode 100644 src/core/auth/verify.ts delete mode 100644 src/core/http/client.test.ts delete mode 100644 src/core/http/fake-client.ts delete mode 100644 src/core/http/probe.ts delete mode 100644 src/core/version/tag.ts delete mode 100644 src/domain/git-sync.ts delete mode 100644 src/output/cap.test.ts delete mode 100644 src/output/cap.ts delete mode 100644 src/output/error.test.ts delete mode 100644 src/output/error.ts delete mode 100644 src/output/notice.ts delete mode 100644 src/output/projection.ts delete mode 100644 src/output/types.test.ts delete mode 100644 src/output/types.ts delete mode 100644 src/runtime/capabilities.ts delete mode 100644 src/runtime/paginate.test.ts delete mode 100644 src/runtime/paginate.ts delete mode 100644 src/runtime/signal.test.ts delete mode 100644 src/runtime/signal.ts create mode 100644 tests/e2e/interrupt.e2e.test.ts create mode 100644 tests/e2e/run-cli.e2e.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e44b5e6..25e7898 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ { "name": "metabase-cli", "description": "Be your data analyst / data engineer for Metabase, from the terminal via the `mb` CLI. Go from raw data to something a non-technical person can use: clean tables, reusable metrics, dashboards, and written answers. Use when someone wants to \"make sense of my data\", \"build a data model\", \"go from raw data to a dashboard\", \"answer questions about my data\", \"report on who registered / signed up / responded\", or \"set up analytics for X\". Also full CRUD on every Metabase resource (cards, dashboards, transforms, queries), git-sync content to and from a remote, and on-demand workflow skills served by `mb skills get`.", - "source": "./", + "source": "./packages/cli", "strict": false, "skills": ["./skills/metabase-cli"], "category": "development" diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..7538464 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "worktree": { + "baseRef": "head" + } +} diff --git a/.claude/skills/add-e2e-test/SKILL.md b/.claude/skills/add-e2e-test/SKILL.md index 9784736..f30038f 100644 --- a/.claude/skills/add-e2e-test/SKILL.md +++ b/.claude/skills/add-e2e-test/SKILL.md @@ -5,7 +5,9 @@ description: Add an end-to-end test that drives the built CLI against a real Met # add-e2e-test -The e2e tier exists to run the **built `dist/cli.mjs`** against a real Metabase, with no mocks. Subtle violations of the harness contract corrupt shared state for every other test in the run. Reading this is required before generating any e2e file. +The e2e tier exists to run the **built `packages/cli/dist/cli.mjs`** against a real Metabase, with no mocks. Subtle violations of the harness contract corrupt shared state for every other test in the run. Reading this is required before generating any e2e file. + +The tier is workspace-wide: it lives at the repo root under `tests/e2e/`, not inside either package, because it exercises the shipped binary rather than a package's source. Unit tests are the mirror image — they sit beside the code under `packages/

/src/**/*.test.ts`. ## Step 0 — Pre-flight (mandatory, do not skip) @@ -13,16 +15,22 @@ Before generating anything, anchor to the existing harness: 1. `ls tests/e2e/` — see the layout and existing nouns. 2. Read **one** existing e2e test end-to-end (e.g. `tests/e2e/auth.e2e.test.ts`). -3. Read `tests/e2e/run-cli.ts` (helpers) and `tests/e2e/bootstrap-data.ts` (the `Bootstrap` schema you must NOT redeclare). +3. Read the harness modules every later step reaches for: + - `tests/e2e/run-cli.ts` — the only sanctioned way to invoke the CLI. + - `tests/e2e/bootstrap-data.ts` — the `Bootstrap` schema you must NOT redeclare. + - `tests/e2e/cli-error.ts` — `cliErrorMessage` / `cliErrorCategory`, how you assert on stderr. + - `tests/e2e/defaults.ts` — base URL, stack id, snapshot name. + - `tests/e2e/server-gate.ts` — `requireServer` / `serverVersionBelow`, how a suite skips. + - `tests/e2e/seed/seeded.ts` and `tests/e2e/seed/ids.ts` — discovered and fixed entity ids. Skip this and you will reinvent harness pieces that already exist, or redeclare the bootstrap schema and silently drift from the writer. ## When to add an e2e test -- A new command was added under `src/commands//.ts`. Every new command needs an e2e test. +- A new command was added under `packages/cli/src/commands//.ts`. Every new command needs an e2e test. - An existing command grew a flag, output mode, or behavior that unit tests cannot exercise (real network round-trip, real auth flow, real polling). -If the command can be fully covered by colocated `src/**/*.test.ts` unit tests, do **not** add an e2e test — the unit tier is faster and more deterministic. +If the command can be fully covered by colocated `packages/

/src/**/*.test.ts` unit tests, do **not** add an e2e test — the unit tier is faster and more deterministic. ## Where the test lives @@ -39,7 +47,7 @@ You must follow all of these. Each rule has bitten the harness before. import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; ``` -- `runCli({ args, configHome, env, stdin, timeoutMs })` spawns `node dist/cli.mjs` via `execa` with an isolated `XDG_CONFIG_HOME`, `MB_CLI_DISABLE_KEYRING=1`, and stripped env (no inherited `MB_*`/`METABASE_*`). +- `runCli({ args, configHome, env, stdin, timeoutMs })` spawns `node packages/cli/dist/cli.mjs` via `execa` with an isolated `XDG_CONFIG_HOME`, `MB_CLI_DISABLE_KEYRING=1`, and stripped env (no inherited `MB_*`/`METABASE_*`). - **Do not** import `execa`, `child_process`, `node:child_process`, or `spawn` directly. - **Do not** call `fetch` against the Metabase instance. Bootstrap owns network setup; tests drive the CLI. - **Do not** spread `process.env` into the `env` param. `env: process.env`, `env: { ...process.env, ... }`, and friends defeat the entire isolation guarantee — they let developer-shell `METABASE_*` leak into the test. Pass only the explicit keys you need. @@ -53,7 +61,7 @@ The whole point of e2e is end-to-end with real I/O. These are forbidden anywhere - `vi.hoisted(...)` - `vi.fn(...)` to stand in for a real dep -If you find yourself wanting a mock, you are in the wrong tier — write a unit test colocated under `src/`. +If you find yourself wanting a mock, you are in the wrong tier — write a unit test colocated under `packages/

/src/`. **2. Read admin credentials only via `readBootstrap()`.** @@ -67,10 +75,34 @@ beforeAll(async () => { ``` - The `Bootstrap` schema lives in `tests/e2e/bootstrap-data.ts`. **Never redeclare it.** If you need a new field, edit the schema there — the writer (`tests/e2e/setup/bootstrap.ts`) consumes the same type, so drift is mechanically prevented. -- The seeded `bootstrap.adminApiKey` authenticates as a synthetic api-key user (email `api-key-user-…@api-key.invalid`). For tests that need a real human admin, run the in-process OAuth login harness (`tests/e2e/setup/oauth-harness.ts` — `consentingBrowser` with `bootstrap.admin`; OAuth-capable servers only, gate with `requireOAuthServer()`) and **explain in the test name why** — don't paper over it. +- The seeded `bootstrap.adminApiKey` authenticates as a synthetic api-key user (email `api-key-user-…@api-key.invalid`). For tests that need a real human admin, run the in-process OAuth login harness (`tests/e2e/setup/oauth-harness.ts` — `consentingBrowser` with `bootstrap.admin`; OAuth-capable servers only, gate with `requireOAuthServer("")`) and **explain in the test name why** — don't paper over it. - **Never invoke the setup wizard from a test.** That mutates global state. Bootstrap runs once per `bun run test:e2e` via `tests/e2e/setup/global-setup.ts`. - **Never hard-code an API key.** Always read from `bootstrap`. +**2a. Seeded entity ids come from `SEEDED`, never a literal.** + +```ts +import { SEEDED } from "./seed/seeded"; + +const tableId = SEEDED.tables.orders; +``` + +- `tests/e2e/seed/seeded.ts` exposes the ids the bootstrap **discovered** by name (warehouse db, collection, card, dashboard, dashcard, plus warehouse tables and fields). Ids are not stable across stacks — a literal `5` passes on your machine and fails in the matrix. +- Constants that genuinely are fixed by Metabase itself (the `All Users` and `Administrators` group ids) live in `tests/e2e/seed/ids.ts`. Import from there rather than inlining the number. + +**2b. Gate the suite on server capability.** + +```ts +import { requireServer } from "./server-gate"; + +const skipReason = requireServer("transform › transform e2e", { minVersion: 59 }); + +describe.skipIf(skipReason !== null)("transform e2e", () => { +``` + +- If the command under test declares `capabilities` above the baseline `{ minVersion: 58 }` — a higher major, or a `tokenFeature` like `remote_sync` or `library` — the suite must gate itself. `requireServer` feeds the persisted server probe through the production `checkCapabilities` and returns a skip reason (or `null`). Its first argument is the lane label naming the describe or test the gate guards; an unmet gate appends it to `.gate-skips..json`, which the closing block prints so a skipped lane is reported rather than counted as a passing one. +- The point is that a lane **passes or skips**, never fails, on a server that cannot satisfy the command. A suite that hits HTTP 402 or 404 because nobody gated it is a broken lane, not a real failure. + **3. Each test gets its own config home.** ```ts @@ -89,33 +121,52 @@ async function makeIsolatedConfigHome(): Promise { - One `XDG_CONFIG_HOME` per test, drained in `afterEach`. Sharing a config home across tests leaks profile state and is a flake source. -**4. Never mutate snapshot state.** +**4. Every test starts from the restored snapshot.** + +`vitest.workspace.ts` registers `setupFiles: ["tests/e2e/setup/restore-each.ts"]` for the e2e project, whose `beforeEach` restores the app-db snapshot (`resetToCliDefault`) and the warehouse (`resetWarehouse`) before **every** test. Two consequences: -- The `/api/testing/snapshot` and `/api/testing/restore` endpoints are reserved for `tests/e2e/setup/bootstrap.ts`. A test that calls them corrupts every other test in the run. -- If your test mutates server state (creates a card, runs a transform), assume the next test sees that state. Either clean up at the end of the `it`, or design the assertion to be resilient. +- **Never rely on state a previous `it` created.** Each test builds the entities it needs, inside itself. A test that passes only when its neighbour ran first will fail the moment either is renamed, skipped, or filtered by `-t`. +- **No manual cleanup.** Don't unwind a created card or transform at the end of the `it` — the restore does it. Teardown code that duplicates the harness is noise, and it hides the state leak it was written to fix. + +The `/api/testing/snapshot` and `/api/testing/restore` endpoints belong to `tests/e2e/setup/**`. A test that calls them directly re-enters the restore path mid-run and corrupts the very isolation it was reaching for. **5. Use `parseJson` for `--json` output, not `JSON.parse + Zod.parse`.** -The schema is the contract. Import it from the production source — never redeclare in the test: +The schema is the contract. Import it from the production source — never redeclare in the test. Client modules come in by package specifier; CLI modules come in by relative path from the repo root: ```ts -import { LoginResult } from "../../src/commands/auth/login"; -import { parseJson } from "../../src/runtime/json"; +import { parseJson } from "@metabase/client/json"; + +import { LoginResult } from "../../packages/cli/src/commands/auth/login"; const result = parseJson(login.stdout, LoginResult); expect(result).toEqual({ profile: "default", ...}); ``` -If the command emits a single domain resource, import the `` / `Compact` schema from `src/domain/.ts`. **For list commands, import the `ListEnvelope` from the command file itself** (`src/commands//list.ts` exports it as a named const built from `listEnvelopeSchema(Compact)`). Never redeclare a `z.object({ data, returned, total })` envelope inline — the envelope shape is owned by `src/output/types.ts:listEnvelopeSchema` and threaded through the command's `outputSchema`. Tests reuse production schemas; copying any shape into the test is silent drift the type-checker can't catch. +If the command emits a single domain resource, import the `` / `Compact` schema from `@metabase/client/domain/`. **For list commands, import the `ListEnvelope` from the command file itself** (`packages/cli/src/commands//list.ts` exports it as a named const built from `listEnvelopeSchema(Compact)`). Never redeclare a `z.object({ data, returned, offset, limit, total, has_more, next_offset, truncated })` envelope inline — the envelope shape is owned by `packages/cli/src/output/types.ts:listEnvelopeSchema` and threaded through the command's `outputSchema`. Tests reuse production schemas; copying any shape into the test is silent drift the type-checker can't catch. + +Which package a schema belongs to is a boundary question, not a stylistic one. A Metabase API resource is client surface and lives in `packages/client/src/domain/`; a shape that exists only because a command renders it that way (`LoginResult`, `AuthStatus`, `ListEnvelope`) is CLI surface and lives with its command. Import from wherever it actually is — do not mirror it into the other package to shorten the specifier. **5b. Assertion strictness.** Every assertion is exact: - **Parsed payloads:** one `toEqual({ ...full expected... })` over the parsed object — never a sequence of `expect(parsed.id).toBe(...)`, `expect(parsed.name).toBe(...)`. The whole point of `parseJson` is that the structure is now in your hands; pinning each field individually leaves the rest untested and lets a regression that flips an unchecked field pass silently. -- **Exit codes:** `expect(result.exitCode).toBe()` with the exact integer. Never `.not.toBe(0)`. The taxonomy is `src/core/errors.ts`: `ConfigError` → 2, `AbortError` → 130, everything else (`HttpError`, `ValidationError`, `NetworkError`, `TimeoutError`, `UnknownError`) → 1. A test that says "non-zero" doesn't distinguish "the right error fired" from "any failure at all". -- **Error strings:** `expect(result.stderr).toContain("")` or `toBe("")`. Never `toMatch(/.../i)` against stderr/stdout. Look up the literal message in `src/` — `src/core/http/errors.ts` for HTTP status messages (e.g. a 404 produces `"Not found."` from the JSON envelope), `src/commands//.ts` for `ConfigError` strings (e.g. `"--yes required to clear credentials non-interactively"`, `"verification failed: …"`), `src/commands/parse-id.ts` for `"invalid id: …"`. Pinning the literal substring catches a refactor that swaps the message; a regex with `\d+` or `.*` does not. +- **Exit codes:** `expect(result.exitCode).toBe()` with the exact integer. Never `.not.toBe(0)`. The taxonomy is `packages/client/src/errors.ts`: `ConfigError` → 2, `CapabilityError` (`packages/client/src/version/preflight-error.ts`) → 2, `AbortError` → 130, everything else (`HttpError`, `ValidationError`, `NetworkError`, `TimeoutError`, `UnknownError`) → 1. A test that says "non-zero" doesn't distinguish "the right error fired" from "any failure at all". +- **Error strings:** the harness is never a TTY, so the CLI renders errors as a JSON envelope on stderr rather than plain text. Assert through `cliErrorMessage(result.stderr)` from `./cli-error`, which unwraps that envelope and hands back the human message: + + ```ts + import { cliErrorMessage } from "./cli-error"; + + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + ``` + + A raw `expect(result.stderr).toContain("…")` also works when the expected text is a plain substring, and plenty of suites use it that way. But `toBe` against raw stderr never matches — stderr carries the whole envelope, plus any leading `warn()` lines. And any message containing a quote, backslash, or newline **requires** `cliErrorMessage`, because the envelope JSON-escapes those characters, so the raw substring is absent from stderr. Never `toMatch(/.../i)` against stderr/stdout. + + Look up the literal message where it is raised — `packages/client/src/http/errors.ts` for HTTP status messages (a 404 produces `` `Not found: ${method} ${path}.` ``; the bare `"Not found."` in that file is the legacy plain-text body older servers send, used only to classify the error kind), `packages/cli/src/commands//.ts` for `ConfigError` strings (e.g. `"--yes required to clear credentials non-interactively"`, `"verification failed: …"`), `packages/cli/src/commands/parse-integer.ts` for `"invalid id: …"`. Pinning the literal substring catches a refactor that swaps the message; a regex with `\d+` or `.*` does not. + - **Dynamic messages:** if the message contains a generated value (a byte count, a created id, a timestamp), build the expected string from the same data the production code consumed and assert with `toBe`/`toContain`. Don't paper over the dynamic part with a regex. -**5a. Command-list test parity.** When you add a new leaf command, update the literal `ALL_COMMANDS` list in `src/runtime/command-help.test.ts` to include the new entry (and remove any path you renamed/deleted). The index is generated by walking `src/main.ts`, so the contract test will fail until the literal matches the new tree. +**5a. Command-list test parity.** When you add a new leaf command, update the literal `ALL_COMMANDS` list in `packages/cli/src/runtime/command-help.test.ts` to include the new entry (and remove any path you renamed/deleted). The index is generated by walking `packages/cli/src/main.ts`, so the contract test will fail until the literal matches the new tree. **6. License token: opaque only.** @@ -138,9 +189,9 @@ import { resolveE2EBaseUrl } from "./defaults"; ```ts import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { LoginResult } from "../../src/commands/auth/login"; -import { parseJson } from "../../src/runtime/json"; +import { parseJson } from "@metabase/client/json"; +import { LoginResult } from "../../packages/cli/src/commands/auth/login"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; @@ -175,7 +226,9 @@ describe(" e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); - expect(parseJson(result.stdout /* schema from src/commands or src/domain */)).toEqual({ + expect( + parseJson(result.stdout /* schema from @metabase/client/domain or the command file */), + ).toEqual({ // ... }); }); @@ -240,10 +293,10 @@ Replace `` with your actual file name. ## Step N — Runnable verification (mandatory) -Type-check is required and cheap; the suite itself is opt-in (slow, brings up docker). +The gate is required and cheap; the e2e suite itself is opt-in (slow, brings up docker). ```sh -npx tsc --noEmit +bun run check ``` Must exit 0. If you ran the suite, also include: @@ -260,14 +313,16 @@ If you did **not** run the e2e suite (it requires `bun run e2e:up && bun run e2e - [ ] File path is `tests/e2e/.e2e.test.ts` (note `.e2e.test.ts`). - [ ] Imports `runCli`, `mkTempConfigHome`, `cleanupConfigHome` from `./run-cli`. No `execa`/`child_process`/`fetch` import. - [ ] Reads creds via `readBootstrap()`. No hard-coded API keys, no `Bootstrap` schema redeclaration, no setup-wizard call. +- [ ] Seeded entity ids come from `SEEDED` (`./seed/seeded`) or `./seed/ids`; no literal id anywhere. +- [ ] If the command's `capabilities` are above `{ minVersion: 58 }`, the suite gates on `requireServer("", {...})` via `describe.skipIf`, with a lane label naming what the gate guards. - [ ] No `vi.mock` / `vi.spyOn` / `vi.hoisted` / `vi.fn` anywhere in the file. - [ ] No `process.env` spread into `runCli({ env: ... })`. Only the explicit keys the test needs. - [ ] Per-test `makeIsolatedConfigHome()` pattern with `tempDirs` + `afterEach` cleanup. -- [ ] `--json` assertions go through `parseJson(stdout, )` where `` is imported from `src/commands/...` or `src/domain/...`. +- [ ] `--json` assertions go through `parseJson(stdout, )` (`parseJson` from `@metabase/client/json`) where `` is imported from `@metabase/client/domain/...` or `../../packages/cli/src/commands/...`. - [ ] Does not call `/api/testing/snapshot` or `/api/testing/restore`. - [ ] If license-touching: token only as opaque stdin; existence check via `=== undefined`; no logging/snapshotting/asserting on the value. - [ ] Self-grep step ran clean (no FAIL lines). -- [ ] `npx tsc --noEmit` exited 0. +- [ ] `bun run check` exited 0. - [ ] If e2e suite was run, it was run after `bun run e2e:up && bun run e2e:bootstrap`. If it was NOT run, that is stated explicitly. If any box is unchecked, the test is unfinished — do not report it as done. State explicitly which box is unchecked and continue working. diff --git a/.claude/skills/add-resource-command/SKILL.md b/.claude/skills/add-resource-command/SKILL.md index c7557ae..b0089a1 100644 --- a/.claude/skills/add-resource-command/SKILL.md +++ b/.claude/skills/add-resource-command/SKILL.md @@ -1,46 +1,58 @@ --- name: add-resource-command -description: Add a Metabase API resource end-to-end — Zod schema in `src/domain/`, list/get commands in `src/commands//`, unit tests where logic warrants them, and a comprehensive e2e suite. Use whenever the user asks to "add a card/dashboard/ command", "wire up `/api/` end-to-end", "add list/get for ", or anything that introduces a new top-level subcommand backed by a previously-untyped Metabase resource. Loading this skill is mandatory before generating any file under `src/domain/`, `src/commands//`, or `tests/e2e/.e2e.test.ts` — the contract is strict and the dependencies between layers are easy to break. +description: Add a Metabase API resource end-to-end — Zod schema in `packages/client/src/domain/`, methods in `packages/client/src/resources/`, list/get commands in `packages/cli/src/commands//`, unit tests where logic warrants them, and a comprehensive e2e suite. Use whenever the user asks to "add a card/dashboard/ command", "wire up `/api/` end-to-end", "add list/get for ", or anything that introduces a new top-level subcommand backed by a previously-untyped Metabase resource. Loading this skill is mandatory before generating any file under `packages/client/src/domain/`, `packages/client/src/resources/`, `packages/cli/src/commands//`, or `tests/e2e/.e2e.test.ts` — the contract is strict and the dependencies between layers are easy to break. --- # add-resource-command -Authoritative contract for adding a resource end-to-end. The work spans four layers — domain schema, commands, unit tests, e2e suite — plus a mandatory close-out (`/review` then `/simplify`) before the task is done. The order matters: earlier layers feed types into later ones, and deviating from house style at any step produces drift the type-checker can't catch. +Authoritative contract for adding a resource end-to-end. The work spans five layers — domain schema, resource methods, presentation view, commands, tests — plus a mandatory close-out (`/review` then `/simplify`) before the task is done. The order matters: earlier layers feed types into later ones, and deviating from house style at any step produces drift the type-checker can't catch. -This skill subsumes what was previously called "add-domain-resource." Adding a domain file alone is no longer the unit of work — a resource without a command isn't useful, and a command without an e2e test isn't trustworthy. +A resource straddles both workspace packages. The schema and the endpoint knowledge are client surface and land in `packages/client/src/domain/` and `packages/client/src/resources/`; the presentation binding and the commands that consume them are CLI surface and land in `packages/cli/`. Adding a domain file alone is not the unit of work — a schema without a method is unreachable, a resource without a command isn't useful, and a command without an e2e test isn't trustworthy. + +**The one-line shape of the whole thing:** a command resolves its flags, calls `client..(…)`, and renders the result. Every `/api/` path, every query-parameter name, and every call to the HTTP transport lives in `packages/client/src/resources/.ts`. ## Step 0 — Pre-flight (mandatory) Anchor to the existing house style. **Skip this and you will produce drift the type-checker won't catch.** -1. `ls src/domain/` and read **one** existing resource file end-to-end (e.g. `src/domain/user.ts`). -2. Read `src/domain/view.ts` (the `ColumnDef` / `ResourceView` contract). -3. Read **one** existing list command and **one** existing get command (look under `src/commands//`). Note how `ListEnvelope`, `outputSchema`, `parseId`, `renderList`, `renderItem`, and `defineMetabaseCommand` compose. -4. Read **one** existing e2e test (`tests/e2e/.e2e.test.ts`) and the harness (`tests/e2e/run-cli.ts`, `tests/e2e/bootstrap-data.ts`). The `add-e2e-test` skill's runtime contract is binding here — re-read it before writing the e2e file. -5. Read `src/output/types.ts` to confirm `listEnvelopeSchema` and the `ListEnvelope` interface. +1. `ls packages/client/src/domain/` and read **one** existing resource schema file end-to-end (e.g. `packages/client/src/domain/segment.ts`). +2. Read the matching `packages/client/src/resources/segment.ts` and its wire test `packages/client/src/resources/segment.test.ts`, then `packages/client/src/client.ts` for how a namespace is composed onto the client. +3. Read `packages/cli/src/output/view.ts` (the CLI-side `ColumnDef` / `ResourceView` contract) and one existing `packages/cli/src/output/views/.ts` presentation binding. +4. Read **one** existing list command and **one** existing get command (look under `packages/cli/src/commands//`). Note how `ListEnvelope`, `outputSchema`, `capabilities`, `parseId`, `renderList`, `renderItem`, and `defineMetabaseCommand` compose. +5. Read **one** existing e2e test (`tests/e2e/.e2e.test.ts`) and the harness (`tests/e2e/run-cli.ts`, `tests/e2e/bootstrap-data.ts`). The `add-e2e-test` skill's runtime contract is binding here — re-read it before writing the e2e file. +6. Read `packages/cli/src/output/types.ts` for `listEnvelopeSchema`, the `ListEnvelope` interface, and `ListRange`; then `packages/cli/src/output/window.ts` for the three window helpers (`windowList`, `windowServerPage`, `collectForOutput`) and the `PageSource` / `PageRequest` contract. ## What you must produce For a typical list/get pair on a new resource: -1. `src/domain/.ts` — the trio (schema + Compact + view). No fixture, no schema-parse unit test. -2. `src/commands//index.ts` — citty group with subcommands. -3. `src/commands//list.ts` — exports `ListEnvelope`, uses `renderList`. -4. `src/commands//get.ts` — uses `parseId` for the positional id, uses `renderItem`. -5. (optional) `src/commands//.test.ts` — unit test **only where there is non-orchestration logic to test**. -6. `tests/e2e/.e2e.test.ts` — comprehensive e2e suite. -7. Updated `src/main.ts` — register the new top-level subcommand. -8. Updated `src/runtime/command-help.test.ts` — add the new leaf paths to the literal `ALL_COMMANDS` list. +1. `packages/client/src/domain/.ts` — the schema pair (`` + `Compact`). No fixture, no schema-parse unit test. +2. Updated `packages/client/src/index.ts` — re-export every value the domain file exports from the public barrel. +3. `packages/client/src/resources/.ts` — `Resource(transport)`, one method per endpoint. +4. Updated `packages/client/src/client.ts` — compose the namespace onto the client as `: Resource(transport)`. +5. `packages/client/src/resources/.test.ts` — the wire test: one `it` per method, asserting the exact URL, method, headers and body through `captureFetch`. +6. `packages/cli/src/output/views/.ts` — the CLI-side `View` presentation binding. +7. `packages/cli/src/commands//index.ts` — citty group with subcommands. +8. `packages/cli/src/commands//list.ts` — exports `ListEnvelope`, uses `renderList`. +9. `packages/cli/src/commands//get.ts` — uses `parseId` for the positional id, uses `renderItem`. +10. (optional) `packages/cli/src/commands//.test.ts` — unit test **only where there is non-orchestration logic to test**. +11. `tests/e2e/.e2e.test.ts` — comprehensive e2e suite. +12. Updated `packages/cli/src/main.ts` — register the new top-level subcommand. +13. Updated `packages/cli/src/runtime/command-help.test.ts` — add the new leaf paths to the literal `ALL_COMMANDS` list. If the resource genuinely has more verbs (e.g. a ` values` for fetching distinct values), add them under the same group; the rules below scale per-verb. -## Step 1 — Domain schema (`src/domain/.ts`) +## Step 1 — Domain schema (`packages/client/src/domain/.ts`) + +The domain file is client surface, so it lives in `@metabase/client`, not the CLI. Two constraints follow from that: -A single file in `src/domain/` may host multiple resources (e.g. `domain/user.ts` exports `CurrentUser` / `CurrentUserCompact` / `userView`). The trio holds **per resource**, not per file. `` is PascalCase; `View` is camelCase. +- **A domain file imports `zod` and sibling `domain/*` files, nothing else.** Not the rest of the client (`../json`, `../poll`), not `node:` builtins, and never `packages/cli` — a client file that reaches into the CLI inverts the dependency between the two packages. The wider `zod` + `semver` + `node:` budget is the client package's ceiling; `domain/` sits well inside it. +- **Nothing CLI-shaped leaks in.** No `@clack/prompts`, no `process.stdout.write`, no `process.exit` — the client never owns presentation or process control. + +A single file in `packages/client/src/domain/` may host multiple resources (e.g. `domain/user.ts` exports `CurrentUser` / `CurrentUserCompact`). The pair holds **per resource**, not per file. `` is PascalCase. ```ts import { z } from "zod"; -import type { ColumnDef, ResourceView } from "./view"; export const Card = z .object({ @@ -54,15 +66,6 @@ export type Card = z.infer; export const CardCompact = Card.pick({ id: true, name: true, archived: true }).strip(); export type CardCompact = z.infer; - -export const cardView: ResourceView = { - compactPick: CardCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "archived", label: "Archived" }, - ], -}; ``` Rules: @@ -70,88 +73,257 @@ Rules: - `.loose()` is the default — Metabase API additions must not break us. Tighten over time, never on first land. (Zod 4: `.passthrough()` is deprecated; use `.loose()`.) - **`.strip()` after `.pick()` is mandatory on the Compact**, not optional. `.pick({...})` on a `.loose()` parent inherits the loose catchall, and the picked schema then _passes every API field through unchanged at parse time_ — your "compact" projection silently leaks the full payload into list output and default (compact) JSON. The bug is invisible until you eyeball the rendered output. Always end with `.strip()`. - The compact projection is the **agent-facing contract** — it shows up in list output and default (compact) JSON. Pick the smallest set of fields that uniquely identifies + describes the resource for an LLM caller. -- `tableColumns` keys must be valid keys of the **compact** type (the projection drives both JSON and text output). - Type aliases via `z.infer`. Never hand-write a parallel `interface` — it will drift silently. - **Optional vs. nullable.** Metabase returns `null` for absent values; it rarely omits the key. Default to `z.().nullable()` and reach for `.optional()` only when you have observed the key actually missing in a real response. Wrong here causes silent parse failures on real payloads. - **Schema scope is principal-engineer judgment, not "mirror the frontend type."** Pick the fields the agent needs to do its job (write queries, choose content). Drop sync flags, fingerprints, JSON-unfolding metadata, audit timestamps, and other internal plumbing. `.loose()` keeps the door open for fields the agent doesn't need declared. The schema's job is to declare what's required and what's typed — not to recapitulate the API. +- **Request-body schemas belong here too.** A create or update verb takes a `CreateInput` / `UpdateInput` declared alongside the resource, and both the CLI's `readBody` and the resource method's parameter type read it from this one place. +- **Re-export every value the domain file exports from `packages/client/src/index.ts`**, in the alphabetical `./domain/` block. A `domain/` value the barrel does not name is not public client surface — a domain file alone does not make one. - **Pin closed enums** when the backend has one. The frontend often types a field as `string | null` even when the backend enumerates the value via Clojure `(derive :namespace/X :namespace/parent)` hierarchies. Pin the schema to `z.enum([...])` over the closed set: agents get a typed surface, and a server-side addition becomes a hard parse failure (a signal we can act on) rather than a silent string. To find the closed set, check the backend Clojure source — typically `src/metabase/types/core.cljc` or the resource's `api.clj` — for `derive` declarations under the resource's keyword namespace. Forbidden: - Typing an API response as `Array>`, `any`, `unknown`, or an inline `{ ... }` shape cast. The Zod schema is the single source of truth; downstream code consumes `z.infer`. -- Editing an existing command to wire the schema in. The domain file is purely additive; the command in Step 2 is the one that consumes it. - Declaring a separate `interface { ... }` next to `const = z.object(...)`. Use the inferred type alias. -- Putting the schema anywhere except `src/domain/`. No `src/schemas.ts`, no `src/api.ts`, no `commands//types.ts`. -- Adding a `tests/fixtures//sample.json` + colocated parse-test pair. `Schema.parse(fixture).toEqual(fixture)` is a tautology against Zod itself, with zero signal about whether the schema matches a real response. The schema is contract-tested by the e2e tier in Step 4. +- Putting the schema anywhere except `packages/client/src/domain/`. No `schemas.ts`, no `api.ts`, no `packages/cli/src/commands//types.ts` — a schema declared CLI-side is invisible to every other consumer of the client. +- Declaring the server's **wire envelope** here. `{ data: [...], total: N }` around a list, or any other shape the transport unwraps and never hands back, is module-private to `resources/` (Step 2). `domain/` holds only what a caller receives. +- Editing an existing command to wire the schema in. Every layer here is purely additive; the command in Step 3 is the one that consumes it. +- Adding a `packages/client/tests/fixtures//sample.json` + colocated parse-test pair. `Schema.parse(fixture).toEqual(fixture)` is a tautology against Zod itself, with zero signal about whether the schema matches a real response. The schema is contract-tested by the e2e tier in Step 5. + +## Step 2 — Resource methods (`packages/client/src/resources/.ts`) + +One file per Metabase resource, exporting `Resource(transport)` — a factory returning the object of methods reached as `mb..(…)`. This is the only layer that names an `/api/` path or calls the transport. + +```ts +import { z } from "zod"; + +import { + Card, + type CardCreateInput, + type CardListFilter, + type CardUpdateInput, +} from "../domain/card"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// `GET /api/card` answers a bare array rather than a `{ data, total }` envelope, so the count a +// caller reads off `ListResult` is the array's own length and the server reports none. +const CardApiList = z.array(Card); + +export interface CardListParams { + f?: CardListFilter | undefined; + model_id?: string | undefined; +} + +export function cardResource(transport: Transport) { + /** List cards. `f` picks a server-side preset; `model_id` scopes the presets that need an id. */ + async function list( + params: CardListParams = {}, + options: RequestOptions = {}, + ): Promise> { + const data = await transport.requestParsed(CardApiList, "/api/card", { + ...options, + query: { f: params.f, model_id: params.model_id }, + }); + return { data, total: null }; + } + + /** Get one card by id. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(Card, `/api/card/${id}`, { ...options }); + } + + /** Create a card — a question, a model, or a metric — from a full card body. */ + async function create(params: CardCreateInput, options: RequestOptions = {}): Promise { + return transport.requestParsed(Card, "/api/card", { ...options, method: "POST", body: params }); + } + + /** Update a card by id, patching only the fields the body carries. */ + async function update( + id: number, + params: CardUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Card, `/api/card/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** Archive (soft-delete) a card by id. Metabase models this as an update, not its own endpoint. */ + async function archive(id: number, options: RequestOptions = {}): Promise { + return update(id, { archived: true }, options); + } + + return { list, get, create, update, archive }; +} +``` + +The eight conventions every method follows: + +1. `client..(...)`, namespace named after the API resource (`mb.card`, singular). + The resource is what Metabase calls the thing, not what it calls the route: the file and the + namespace are `snippet` even though the endpoint is `/api/native-query-snippet`, because the + domain schema, the CLI noun and the Metabase concept all read `snippet`. +2. Path parameters positional, then params, then options: `update(id, params, options?)`. +3. Params use Metabase's own field names verbatim — `f`, `model_id`, `include_inactive`. **No mapping + layer, no renaming.** +4. Transport concerns (`signal`, `timeoutMs`, `retries`) live in the trailing `options`, never in + params. +5. Wire envelope schemas are module-private to `resources/`; they never appear in `domain/`. +6. Methods return domain values, never wire envelopes. Non-paginated lists return + `ListResult` = `{ data, total }`. +7. A string path parameter always goes through `encodeURIComponent`. +8. Every method carries the endpoint's description as a doc comment. + +Convention 7 has a companion habit: when every path parameter on a resource is a numeric id, say so in a one-line comment at the top of the factory, so a reader knows the omission was decided rather than forgotten. + +Further rules: + +- **Register the namespace.** `packages/client/src/client.ts` imports the factory and adds `: Resource(transport)` to the returned object, alphabetically. Until that line exists, `client.` does not resolve. +- **Only `client.ts` and `resources/` may import `resources/`.** The direction runs one way; a `domain/` or `http/` file reaching for a resource inverts it. +- **A paged endpoint returns pages.** Where Metabase itself applies `limit`/`offset`, the method returns `AsyncIterable>` from `paginatePages(transport, path, ItemSchema, { query, offset, max, pageSize, signal })` and is named `Pages`. The caller decides how far to pull. +- **A 204-for-absent endpoint** goes through `fetchOptionalParsed` in `packages/client/src/resources/optional-parsed.ts` rather than a bespoke status check. +- The client never formats, prompts, or exits. A method returns a value or throws from the taxonomy in `packages/client/src/errors.ts`. + +**`packages/client/src/resources/.test.ts`** — the wire test, one `it` per method. It builds a real client over `captureFetch` from `@metabase/client/testing/fetch-capture` and asserts the exact request in a single `toEqual`: + +```ts +it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(SEGMENT)]); + + await mb.segment.get(4); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/segment/4", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); +}); +``` + +This is the test that pins the path, the verb, the query vocabulary and the body serialization — the things a rename would otherwise silently change. `vi.mock` is not how you write it: `captureFetch` scripts real responses through the real transport. -## Step 2 — Commands +## Step 3 — Presentation and commands -Each leaf command uses `defineMetabaseCommand`. Three artifacts per resource group: +Commands are CLI surface: they live in `packages/cli/src/commands//` and reach the schema across the package boundary as `@metabase/client/domain/`. Everything CLI-internal (`../../output/render`, `../../output/views/`, `../flags`) stays relative. Each leaf command uses `defineMetabaseCommand`. -**`src/commands//index.ts`** — a tiny citty group: +**`packages/cli/src/output/views/.ts`** — the presentation binding, one file per domain resource-file, imported by the commands. Presentation is CLI-owned; it never sits on the client surface. ```ts -import { defineCommand } from "citty"; +import { type Card, CardCompact } from "@metabase/client/domain/card"; -export default defineCommand({ - meta: { name: "", description: "Inspect Metabase " }, +import type { ResourceView } from "../view"; + +export const cardView: ResourceView = { + compactPick: CardCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "archived", label: "Archived" }, + ], +}; +``` + +- `tableColumns` keys must be valid keys of the resource type; the columns render text/table output while `compactPick` drives compact JSON. +- Any presentation-only cell formatter (a `format:` function that turns a nested value into a table string) lives in this file beside the view — never in the domain schema. + +Then the three command artifacts per resource group: + +**`packages/cli/src/commands//index.ts`** — a tiny citty group via `defineCommandGroup`: + +```ts +import { defineCommandGroup } from "../group"; + +export default defineCommandGroup({ + name: "", + description: "Inspect Metabase ", subCommands: { - list: () => import("./list").then((m) => m.default), - get: () => import("./get").then((m) => m.default), + list: () => import("./list").then((mod) => mod.default), + get: () => import("./get").then((mod) => mod.default), }, }); ``` -Add `alias` to the `meta` if a short or alternate name is helpful (e.g. `db` aliasing `database`). +Add `alias` if a short or alternate name is helpful (e.g. `db` aliasing `database`), and `skills` to point agents at the skill files that explain the resource's payloads. -**`src/commands//list.ts`** — exports the envelope schema, uses it as `outputSchema`: +**`packages/cli/src/commands//list.ts`** — exports the envelope schema, uses it as `outputSchema`: ```ts -import { z } from "zod"; +import { Compact } from "@metabase/client/domain/"; -import { , Compact, View } from "../../domain/"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, type ListEnvelope } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { View } from "../../output/views/"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -const ApiList = /* schema for the actual API response shape */; - export const ListEnvelope = listEnvelopeSchema(Compact); export default defineMetabaseCommand({ meta: { name: "list", description: "List " }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags /* + filter flags */ }, + capabilities: { minVersion: 58 }, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags /* + filter flags */ }, outputSchema: ListEnvelope, examples: ["mb list", "mb list --json"], async run({ ctx, getClient }) { const client = await getClient(); - const response = await client.requestParsed(ApiList, "/api/"); - const envelope: ListEnvelope<> = { - data: /* extract items */, - returned: /* count */, - total: /* count or undefined */, - }; - renderList(envelope, View, ctx); + const { data, total } = await client..list(); + renderList(windowList(data, ctx.range, total), View, ctx); }, }); ``` -The `ListEnvelope` export is **mandatory**. It is consumed by JSON help (`--help --json`, via `outputSchema`) and by the matching e2e test (which imports it back to parse `--json` output). Do **not** redeclare a `z.object({ data, returned, total })` shape inline anywhere. +`...listFlags` is mandatory on every list verb: it contributes `--limit` / `--offset`, which the command shell resolves into the `ctx.range` that the window helper consumes. Omit it and `ctx.range` is the full range, so the flags an agent needs to page silently do nothing. -The API response schema (`ApiList` above) is the _server's_ envelope shape (often `{ data: [...], total: N }` or a bare array) and is distinct from the _CLI's_ envelope. Keep it private to the file. +**The command names no endpoint.** Two absolute rules govern `packages/cli/src/commands/**` — a command must not name an API path (any `"/api/` or `` `/api/ `` literal) and must not drive the HTTP transport directly (`requestParsed` / `requestRaw` / `requestStream` / `paginatePages`). Neither admits an exception. A new command that builds its own request breaks both, and the fix is always the same — the request belongs in Step 2. -**`src/commands//get.ts`** — positional id parsed via `parseId`: +**Pick the window helper by who applied the window** — one of the three in `packages/cli/src/output/window.ts`. Never hand-roll the envelope object; picking wrong produces a plausible envelope that no test catches. + +| Helper | Use when | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `windowList(data, ctx.range, total)` | The method returned the whole result set as a `ListResult` and the slice is client-side. The common case — forward the `total` it reports as it stands. | +| `windowServerPage(data, total, ctx.range)` | The endpoint applied `limit`/`offset` itself, so `data` is already the slice and `total` is the server's count (see `search.ts`). | +| `collectForOutput(source, view, ctx)` | A genuinely paged endpoint, where `source` is a `PageSource` forwarding the helper's `PageRequest` into the resource's `Pages` method (see below). | ```ts -import { , View } from "../../domain/"; +const envelope = await collectForOutput( + (request) => + client.collection.itemPages(ref, params, { + offset: ctx.range.offset, + ...(request.max !== undefined && { max: request.max }), + ...(request.pageSize !== undefined && { pageSize: request.pageSize }), + }), + collectionItemView, + ctx, +); +renderList(envelope, collectionItemView, ctx); +``` + +`collectForOutput` pulls only as far as the output byte budget can display, so an unbounded listing over a large collection costs a page or two rather than a full drain the cap then discards. It sizes each request through the `PageRequest` it hands the source — forward `max` and `pageSize` verbatim, or the budget cannot bound the walk. + +Every command declares `capabilities`: `{ minVersion: 58 }` is the supported baseline (no probe, no enforcement), a higher `minVersion` or a `tokenFeature` gates the command behind the preflight check, and `null` marks a command that never touches a Metabase server. Validate the right `minVersion` against the Metabase route files before picking one. + +The `ListEnvelope` export is **mandatory**. It is consumed by JSON help (`--help --json`, via `outputSchema`) and by the matching e2e test (which imports it back to parse `--json` output). Do **not** redeclare the envelope shape inline anywhere. It is the _CLI's_ envelope — `{ data, returned, offset, limit?, total?, has_more, next_offset?, truncated? }`, declared in `packages/cli/src/output/types.ts` — and has nothing to do with the server's wire envelope, which stayed module-private in Step 2. + +**`packages/cli/src/commands//get.ts`** — positional id parsed via `parseId`: + +```ts +import { } from "@metabase/client/domain/"; + import { renderItem } from "../../output/render"; +import { View } from "../../output/views/"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a by id" }, + capabilities: { minVersion: 58 }, args: { ...outputFlags, ...profileFlag, @@ -163,76 +335,91 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const item = await client.requestParsed(, `/api//${id}`); + const item = await client..get(id); renderItem(item, View, ctx); }, }); ``` -**`src/main.ts`** — register the new top-level subcommand alongside the existing entries. +**`packages/cli/src/main.ts`** — register the new top-level subcommand alongside the existing entries. When smoke-testing commands by hand, **never pass an API key on argv** — Metabase keys must come through env (`MB_URL`, `MB_API_KEY`) or stdin. The runtime hook will block argv-embedded keys. -## Step 3 — Unit tests +## Step 4 — Unit tests + +The resource wire test from Step 2 is not optional; everything in this step is on top of it. Add a `.test.ts` next to a command **only where there is non-orchestration logic to test**. Examples that warrant a unit test: -- A `--`-style filter applied client-side after the API returns (test the filter independently of the network round-trip). +- A `--`-style filter the CLI applies locally after the client call returns (test the filter independently of the network round-trip). - Argument-parsing branches with multiple failure modes (a new parser; `parseId` itself is centralized and already covered). -- A response-shape transformation that maps multiple fields or merges two endpoints' output. +- A response-shape transformation that maps multiple fields or merges two methods' output. - An error-mapping branch that converts a specific HTTP status to a specific user message. **Forbidden unit tests** (silent drift, fail-by-tautology): - `Schema.parse(fixture).toEqual(fixture)` — only proves Zod works. -- `vi.mock('citty')` or `vi.mock('../../core/http/client')` to "test" a command — collapses to "the mock returned what I configured." If the only way to unit-test a branch is to mock a project helper, push the coverage to e2e instead. -- Tests that re-encode the implementation (e.g. asserting the exact arg list passed to `client.requestParsed`). +- `vi.mock('citty')` or `vi.mock('@metabase/client/client')` to "test" a command — collapses to "the mock returned what I configured." If the only way to unit-test a branch is to mock a project helper, push the coverage to e2e instead. The client ships `testing/fake-client.ts` and `testing/fetch-capture.ts` as the sanctioned test doubles; reach for those before a module mock. +- Tests that re-encode the implementation (e.g. asserting the exact arg list a command passed to a resource method). -When in doubt, push coverage to the e2e tier — it runs the real path. A command whose run body is purely "call client, render" has nothing meaningful to assert at the unit tier; that's fine, and you should say so explicitly when filling in the sanity-check list at the bottom. +When in doubt, push coverage to the e2e tier — it runs the real path. A command whose run body is purely "resolve flags, call the client, render" has nothing meaningful to assert at the unit tier; that's fine, and you should say so explicitly when filling in the sanity-check list at the bottom. -## Step 4 — E2E tests (comprehensive) +## Step 5 — E2E tests (comprehensive) -Live under `tests/e2e/.e2e.test.ts`. Drive the **built** `dist/cli.mjs` against the seeded warehouse via `runCli`. Re-read the `add-e2e-test` skill before writing the file — its runtime contract (no `vi.mock`, no `execa`/`spawn`, env hygiene, per-test config home, opaque license tokens, no `/api/testing/*` calls) is binding here. +Live under `tests/e2e/.e2e.test.ts` at the repo root — the e2e tier is workspace-wide, not per-package. Drive the **built** `packages/cli/dist/cli.mjs` against the seeded warehouse via `runCli`. Re-read the `add-e2e-test` skill before writing the file — its runtime contract (no `vi.mock`, no `execa`/`spawn`, env hygiene, per-test config home, opaque license tokens, no `/api/testing/*` calls) is binding here. A comprehensive suite for a typical list/get pair covers, at minimum: -1. **List, default flags** — `exitCode === 0`, parsed via `ListEnvelope` (imported from `src/commands//list.ts`), asserts the seeded items appear with the expected compact fields via a single `toEqual({ ...full envelope... })` when feasible. If the list is unbounded (paginated or non-deterministic order), assert the _shape_ and the _presence_ of stable items rather than the full array, and still spell out the expected items as full objects. +1. **List, default flags** — `exitCode === 0`, parsed via `ListEnvelope` (imported from `packages/cli/src/commands//list.ts`), asserts the seeded items appear with the expected compact fields via a single `toEqual({ ...full envelope... })` when feasible. If the list is unbounded (paginated or non-deterministic order), assert the _shape_ and the _presence_ of stable items rather than the full array, and still spell out the expected items as full objects. 2. **List, a meaningful filter flag** (if the command has one) — `exitCode === 0`, asserts the filter narrowed the result and that every returned item satisfies the filter. -3. **Get, success** — `exitCode === 0`, parsed via `` (with `--full`) or `Compact` (default). Assert the parsed object with one `toEqual({ ... })` over the full expected payload, **never** a sequence of `expect(parsed.id).toBe(...)`/`expect(parsed.name).toBe(...)` field pokes. Use a stable identifier from `tests/e2e/seed/ids.ts` if pinned, otherwise look up the id dynamically by listing first and filtering by a known name. -4. **Get, invalid positional** (`abc`, empty, negative, zero) — `exitCode === 2` (`ConfigError`), `stderr.toContain('invalid id: "" (expected integer)')` (the literal message from `src/commands/parse-id.ts`), stdout empty. -5. **Get, valid format but missing on server** (e.g. `9999999`) — `exitCode === 1` (`HttpError`), `stderr.toContain("Not found.")` (the literal `userMessage` from Metabase's 404 envelope; see `src/core/http/errors.ts` for the taxonomy). +3. **Get, success** — `exitCode === 0`, parsed via `` (with `--full`) or `Compact` (default). Assert the parsed object with one `toEqual({ ... })` over the full expected payload, **never** a sequence of `expect(parsed.id).toBe(...)`/`expect(parsed.name).toBe(...)` field pokes. Use a seeded id from `tests/e2e/seed/seeded.ts` (`SEEDED`) or a pinned constant from `tests/e2e/seed/ids.ts`, otherwise look up the id dynamically by listing first and filtering by a known name. +4. **Get, invalid positional** (`abc`, empty, negative, zero) — `exitCode === 2` (`ConfigError`), `stderr.toContain('invalid id: "" (expected integer)')` (the literal message from `packages/cli/src/commands/parse-integer.ts`, which `parseId` delegates to), stdout empty. +5. **Get, valid format but missing on server** (e.g. `9999999`) — `exitCode === 1` (`HttpError`), `stderr.toContain("Not found.")` (the literal `userMessage` from Metabase's 404 envelope; see `packages/client/src/http/errors.ts` for the taxonomy). Assertions are exact at every level — these are not stylistic preferences, they are hard rules from CLAUDE.md and the `add-e2e-test` skill: -- **Exit codes** — always the exact integer (`toBe(0)`, `toBe(1)`, `toBe(2)`, `toBe(130)`). Never `.not.toBe(0)`. The `src/core/errors.ts` taxonomy is fixed: `ConfigError`=2, `AbortError`=130, all others=1. -- **Error strings** — always `toContain("")` or `toBe("")`. Never `toMatch(/.../i)`. Look the literal up in `src/` and pin it. A regex with `\d+` or `.*` for a dynamic part is FAIL — build the expected string from the same source the production code consumed and assert with `toBe`. +- **Exit codes** — always the exact integer (`toBe(0)`, `toBe(1)`, `toBe(2)`, `toBe(130)`). Never `.not.toBe(0)`. The `packages/client/src/errors.ts` taxonomy is fixed: `ConfigError`=2, `CapabilityError` (`packages/client/src/version/preflight-error.ts`)=2, `AbortError`=130, all others=1. +- **Error strings** — always `toContain("")` or `toBe("")`. Never `toMatch(/.../i)`. Look the literal up in `packages/` and pin it. A regex with `\d+` or `.*` for a dynamic part is FAIL — build the expected string from the same source the production code consumed and assert with `toBe`. - **Parsed payloads** — always one full `toEqual({ ... })`. Field-by-field `toBe` after `parseJson` is FAIL. If the command has additional verbs or flags with branching behavior, add a test per branch. -Schemas are imported, never redeclared: +Schemas are imported, never redeclared. E2E tests reach the client by package specifier and the CLI by relative path from the repo root: -- Single item: `` / `Compact` from `src/domain/.ts`. -- List envelope: `ListEnvelope` from `src/commands//list.ts`. +- Single item: `` / `Compact` from `@metabase/client/domain/`. +- List envelope: `ListEnvelope` from `../../packages/cli/src/commands//list`. +- `parseJson` from `@metabase/client/json`. If the command needs auth (the common case), pass `bootstrap.adminApiKey` and `bootstrap.baseUrl` via `runCli({ env: { MB_URL, MB_API_KEY } })` — never via argv. -## Step 5 — Command-list parity +## Step 6 — Command-list parity -`src/runtime/command-help.test.ts` has a literal `ALL_COMMANDS` list. Add the new entries (` list`, ` get`, …) in the same place new commands appear. Without this update the command-tree contract test fails on a clean run. +`packages/cli/src/runtime/command-help.test.ts` has a literal `ALL_COMMANDS` list. Add the new entries (` list`, ` get`, …) in the same place new commands appear. Without this update the command-tree contract test fails on a clean run. -## Step 6 — Self-grep before close-out (mandatory) +## Step 7 — Self-grep before close-out (mandatory) Run each of these. Any hit must be fixed; then re-run. ```sh -# Domain file: -rg -n "Record<\s*string\s*,\s*unknown\s*>" src/domain/.ts && echo FAIL || echo OK -rg -n "\bas \b[A-Z]" src/domain/.ts && echo FAIL || echo OK -rg -n ":\s*any\b|" src/domain/.ts && echo FAIL || echo OK -rg -n "[\w\)\]]!\." src/domain/.ts && echo FAIL || echo OK -rg -n "@ts-(ignore|nocheck|expect-error)" src/domain/.ts && echo FAIL || echo OK -rg -n "\.pick\(\{[^}]*\}\)\s*;" src/domain/.ts && echo FAIL || echo OK # `.pick(...)` not followed by `.strip()` +# Domain file (client surface — lives in packages/client): +rg -n "Record<\s*string\s*,\s*unknown\s*>" packages/client/src/domain/.ts && echo FAIL || echo OK +rg -n "\bas \b[A-Z]" packages/client/src/domain/.ts && echo FAIL || echo OK +rg -n ":\s*any\b|" packages/client/src/domain/.ts && echo FAIL || echo OK +rg -n "[\w\)\]]!\." packages/client/src/domain/.ts && echo FAIL || echo OK +rg -n "@ts-(ignore|nocheck|expect-error)" packages/client/src/domain/.ts && echo FAIL || echo OK +rg -n "\.pick\(\{[^}]*\}\)\s*;" packages/client/src/domain/.ts && echo FAIL || echo OK # `.pick(...)` not followed by `.strip()` +rg -n "@metabase/cli|\.\./\.\./cli/" packages/client/src/domain/.ts && echo FAIL || echo OK # the client must not reach into the CLI +rg -n "/api/" packages/client/src/domain/.ts && echo FAIL || echo OK # endpoints belong to resources/ + +# Resource file (the endpoint layer): +rg -n "\.request(Parsed|Raw|Stream)\(" packages/client/src/resources/.ts || echo "FAIL: no transport call — is this file doing anything?" +rg -n "\$\{[a-zA-Z_$][\w$]*\}" packages/client/src/resources/.ts # every string interpolation: numeric id, or encodeURIComponent? +rg -n "Resource" packages/client/src/client.ts || echo "FAIL: namespace not composed onto the client" + +# Command files (CLI surface — flags in, client call, render out): +rg -n '["`]/api/' packages/cli/src/commands// && echo FAIL || echo OK # the API-path rule +rg -n "\.request(Parsed|Raw|Stream)\(|paginatePages\(" packages/cli/src/commands// && echo FAIL || echo OK # the transport rule +rg -n "from \"\.\./\.\./domain/" packages/cli/src/commands// && echo FAIL || echo OK # use @metabase/client/domain/ # E2E test: rg -n 'from\s+"execa"|from\s+"node:child_process"|from\s+"child_process"' tests/e2e/.e2e.test.ts && echo FAIL || echo OK @@ -246,43 +433,53 @@ rg -n "\.not\.toBe\(0\)" tests/e2e/.e2e.test.ts && echo FAIL || echo OK # e rg -n "\.toMatch\(/" tests/e2e/.e2e.test.ts && echo FAIL || echo OK # exact substring required ``` -## Step 7 — Runnable verification (mandatory, all green before close-out) +## Step 8 — Runnable verification (mandatory, all green before close-out) ```sh -npx tsc --noEmit -bun run test +bun run check bun run build bun run test:e2e tests/e2e/.e2e.test.ts ``` +`bun run check` is the whole gate — typecheck, lint, format, then the unit tier: the resource wire tests above, plus every colocated `*.test.ts` in both packages. + If any step fails: fix and re-run, do not paper over. If you cannot run them in your environment, say so explicitly — do not claim "done." -## Step 8 — Close-out: `/review` then `/simplify` (mandatory, in this order) +## Step 9 — Close-out: `/review` then `/simplify` (mandatory, in this order) -Both are mandatory and must run **after** Step 7 is green. Do not skip either. +Both are mandatory and must run **after** Step 8 is green. Do not skip either. 1. **`/review`** — runs the strict end-of-task review skill against the diff. Any FAIL must be addressed before continuing. Do not argue with findings; fix them, or skip with an explicit one-line rationale. Re-run `/review` until it returns `RESULT: PASS`. 2. **`/simplify`** — runs the reuse / quality / efficiency review. Apply each finding that survives the rubric; skip false positives with a one-line note. Resolve any structural issues `/simplify` surfaces (redeclared schemas, missing command-list entries, magic-literal duplication, etc.) by going back to the relevant earlier step. -If either skill surfaces a structural issue (missing `.strip()`, redeclared list envelope, missing command-list entry, e2e test missing a coverage axis), the work is unfinished — fix and re-run the close-out from `/review`. +If either skill surfaces a structural issue (missing `.strip()`, a request built inside a command, a redeclared list envelope, a missing command-list entry, an e2e test missing a coverage axis), the work is unfinished — fix and re-run the close-out from `/review`. ## Sanity checks before declaring done -- [ ] Step 0 actually performed (read existing domain file, list command, get command, e2e test, output/types). -- [ ] **Domain trio**: `` with `.loose()`, `Compact` with `.pick({...}).strip()`, `View`. No hand-written parallel interface. +- [ ] Step 0 actually performed (read existing domain file, resource file + wire test, `client.ts`, list command, get command, e2e test, `packages/cli/src/output/types.ts`, `packages/cli/src/output/window.ts`). +- [ ] Domain file landed in `packages/client/src/domain/`, importing only `zod` and sibling `domain/*` files. +- [ ] **Domain pair**: `` with `.loose()`, `Compact` with `.pick({...}).strip()`. No hand-written parallel interface. +- [ ] Every value the domain file exports is re-exported from `packages/client/src/index.ts`. - [ ] Closed enums pinned via `z.enum([...])` where the backend defines a closed set. - [ ] Schema scope is query/agent-relevant fields only — no sync flags, fingerprints, audit timestamps, or other internal plumbing unless they drive an actual decision. - [ ] No fixture or schema-parse unit test added. -- [ ] **List command** exports `ListEnvelope = listEnvelopeSchema(Compact)` and uses it as `outputSchema`. The API-response shape is a separate private schema. +- [ ] **Resource file** `packages/client/src/resources/.ts` exports `Resource(transport)`, holds every `/api/` path and every transport call, and follows all eight conventions — positional path params then params then options, Metabase's own field names, transport concerns in `options`, wire envelopes module-private, domain values returned, `ListResult` for a non-paginated list, `encodeURIComponent` on every string path param, a doc comment per method. +- [ ] **Namespace composed** onto `packages/client/src/client.ts` as `: Resource(transport)`. +- [ ] **Wire test** `packages/client/src/resources/.test.ts` asserts URL, method, headers and body for every method through `captureFetch`. +- [ ] **View binding** `View` landed in `packages/cli/src/output/views/.ts` (not the domain file, not the resource file), with any `format:` cell helpers colocated there. +- [ ] **Commands** landed in `packages/cli/src/commands//`, call `client..(…)`, and name no `/api/` path and no transport method — the two rules admit no exception. +- [ ] **List command** exports `ListEnvelope = listEnvelopeSchema(Compact)` and uses it as `outputSchema`. +- [ ] **List command** spreads `...listFlags` and builds its envelope with the window helper that matches who applied the window (`windowList` / `windowServerPage` / `collectForOutput`) — never a hand-rolled object. - [ ] **Get command** uses `parseId` for the positional integer id. -- [ ] `src/main.ts` registers the new subcommand. -- [ ] Unit test added only where non-orchestration logic exists (or none added, with explicit rationale — "command body is pure orchestration" is acceptable). +- [ ] Every leaf command declares `capabilities` explicitly (`{ minVersion: … }` or `null`). +- [ ] `packages/cli/src/main.ts` registers the new subcommand. +- [ ] Command unit test added only where non-orchestration logic exists (or none added, with explicit rationale — "command body is pure orchestration" is acceptable). - [ ] **E2E test** at `tests/e2e/.e2e.test.ts` covering at minimum: list default, list filtered (if applicable), get success, get invalid id, get missing id. -- [ ] E2E test imports schemas from `src/`; no `z.object({...})` redeclaration of any output shape. -- [ ] `src/runtime/command-help.test.ts` `ALL_COMMANDS` list updated with the new leaves. -- [ ] Self-grep step (Step 6) ran clean. -- [ ] `npx tsc --noEmit`, `bun run test`, `bun run build`, `bun run test:e2e ` all exited 0. +- [ ] E2E test imports schemas from `@metabase/client/domain/` and `../../packages/cli/src/commands//list`; no `z.object({...})` redeclaration of any output shape. +- [ ] `packages/cli/src/runtime/command-help.test.ts` `ALL_COMMANDS` list updated with the new leaves. +- [ ] Self-grep step (Step 7) ran clean. +- [ ] `bun run check`, `bun run build`, `bun run test:e2e ` all exited 0. - [ ] `/review` returned `PASS`. - [ ] `/simplify` findings either applied or explicitly skipped with a reason. diff --git a/.claude/skills/audit-architecture/SKILL.md b/.claude/skills/audit-architecture/SKILL.md index 918f322..0f85c41 100644 --- a/.claude/skills/audit-architecture/SKILL.md +++ b/.claude/skills/audit-architecture/SKILL.md @@ -22,19 +22,32 @@ Whole-tree non-deterministic audit. Where `/review` checks the diff, this checks --- -## Subagent 1 — Layering & import direction +## Subagent 1 — Layering, import direction & package boundary -> Audit `src/` in the current repository for layering violations. Use ripgrep / file reads — read-only, no edits. +> Audit `packages/client/src/` and `packages/cli/src/` in the current repository for layering violations. Use ripgrep / file reads — read-only, no edits. > -> Skip `*.test.ts` files entirely — per CLAUDE.md, "Layering & boundary rules in `tests/structure.test.ts` apply to production source only; `*.test.ts` files are exempt and may import across layers." Audit only production `.ts` files. +> Skip `*.test.ts` files entirely — per CLAUDE.md, "Layering & boundary rules are declared per package and apply to production source only; `*.test.ts` files are exempt and may import across layers." Audit only production `.ts` files. > -> Direction rule: `commands → core → domain`. Specifically: +> This is a bun workspace: `packages/client` (`@metabase/client`) is the private Metabase API client, `packages/cli` (`@metabase/cli`) is the publishable CLI built on it. > -> - `src/domain/**` may import only from `zod` and other `src/domain/**` files. Nothing from `src/commands/`, `src/core/`, `src/runtime/`, `src/output/`. -> - `src/core/**` may import from `src/domain/**`, `src/runtime/**`, and other `src/core/**`. Never from `src/commands/`. -> - `src/output/**` may import from `src/domain/**`, `src/runtime/**`, and `src/core/**` except `src/core/auth/`. Never from `src/commands/` or `src/core/auth/`. -> - `src/runtime/**` may import from `src/core/errors` (typed errors are the project's pattern: `runtime/json.ts` returns `ConfigError`/`ValidationError`, `runtime/poll.ts` throws `TimeoutError`, etc.) and from `src/core/http/client.ts` for the `Client` type only. It must not import from `src/commands/`, `src/domain/`, `src/output/`, or any other `src/core/` file. -> - `src/commands/**` may import from any other `src/` location. +> **Package boundary (hardest rule):** +> +> - Nothing under `packages/client/src/**` may import from `packages/cli` — not by an `@metabase/cli` specifier, and not by a relative path that resolves outside the client's own src tree (`../../cli/src/...` reads as ordinary navigation but lands in the other package). +> - The client's dependency budget is `zod`, `semver`, and `node:` builtins. Root devDependencies hoist, so any third-party import resolves at runtime — report every production client file importing a package outside that budget. +> - `packages/cli/src/**` consumes the client by specifier: `@metabase/client/` (e.g. `@metabase/client/domain/card`, `@metabase/client/json`, `@metabase/client/errors`). A relative path from the CLI into `packages/client/src/` is a violation. +> +> **Within `packages/client/src/`:** +> +> - `domain/**` may import only from `zod` and other `domain/**` files. +> - `resources/**` — one file per Metabase resource, holding every `/api/` path the client sends — may be imported only by `client.ts` and by sibling `resources/**` files. A `domain/**`, `http/**`, `auth/**`, `version/**` or root-level client file importing a resource is a violation; the direction runs one way, and the permitted importers are an allowlist, so a directory added later is not exempt for having arrived after the rule. +> - Everything else may import freely within the client, subject to the boundary rules above. +> +> **Within `packages/cli/src/`:** +> +> - `commands/**` may import from any other CLI location and any `@metabase/client/*` path. It reaches Metabase only as `client..(…)` — a command naming an `/api/…` path literal or calling `requestParsed` / `requestRaw` / `requestStream` / `paginatePages` is a violation. Both rules are absolute and admit no exception, so report every offending file. Report separately any command that reaches the network by another name: `commands/auth/login.ts`'s `tryDiscoverMetadata` is the one sanctioned case, and no pattern rule can see a second one. +> - `core/**` may import from `runtime/**`, other `core/**`, and `@metabase/client/*`. Never from `commands/` or `output/`. +> - `output/**` may import from `runtime/**`, `core/**` except `core/auth/`, and any `@metabase/client` path **except `@metabase/client/http`** and anything beneath it (presentation consumes the error taxonomy from `@metabase/client/errors`, never the HTTP boundary). Never from `commands/`. +> - `runtime/**` may import from `@metabase/client/*` (typed errors are the project's pattern: `runtime/body.ts` uses `parseJson` and `ConfigError`, `runtime/yaml.ts` uses `ValidationError`). It must not import from `commands/`, `core/`, or `output/`. > > For every violation, report `path:line: — violates `. If zero violations, output the single line `(clean)`. @@ -42,13 +55,13 @@ Whole-tree non-deterministic audit. Where `/review` checks the diff, this checks ## Subagent 2 — Type strictness -> Audit `src/` and `tests/` in the current repository for type-strictness violations. Read-only. +> Audit `packages/client/src/`, `packages/cli/src/`, and `tests/` in the current repository for type-strictness violations. Read-only. > > Patterns to find: > > - `as `, `as unknown as`, `as never`, `as any` (any cast form). > - `: any` or `` annotations. -> - `Record` used as a typed value (allowed only in structurally-untyped envelopes — flag every instance and let aggregator decide). +> - `Record` used as a typed value (the sole sanctioned home is `packages/cli/src/output/projection.ts` — flag every other instance and let the aggregator decide). > - `!` non-null assertions: `\.\.\.?!\.`, `\.\.\.?!\[`, `! ;`. > - `// @ts-ignore`, `// @ts-nocheck`, `// @ts-expect-error` (allowed only when paired with `// reason:` and `// FIXME(date)`). > @@ -58,20 +71,49 @@ Whole-tree non-deterministic audit. Where `/review` checks the diff, this checks ## Subagent 3 — Boundary & process discipline -> Audit `src/` in the current repository for boundary violations. Read-only. Skip `*.test.ts` files entirely (production source only — tests are exempt). -> -> Forbidden outside their permitted homes: -> -> | Pattern | Allowed only in | -> | ---------------------------------------------------------------------------------------------- | ----------------------------------------------- | -> | `process.exit(` | `src/cli.ts` | -> | `process.stdout.write`, `process.stderr.write` | `src/cli.ts`, `src/output/**` | -> | `console.{log,warn,error,info,debug}` | `src/cli.ts`, `src/output/**` | -> | `JSON.parse(` | `src/runtime/json.ts` | -> | bare `fetch(`, `globalThis.fetch`, third-party HTTP libs (`got`/`axios`/`node-fetch`/`undici`) | `src/core/http/**` | -> | `new URL(` | `src/core/http/**`, `src/core/url.ts` | -> | `child_process` (`spawn`, `exec`, etc.) | `src/runtime/process.ts` | -> | `setTimeout` inside an `await` polling loop | `src/runtime/poll.ts`, `src/core/http/retry.ts` | +> Audit `packages/client/src/` and `packages/cli/src/` in the current repository for boundary violations. Read-only. Skip `*.test.ts` files entirely (production source only — tests are exempt). +> +> The permitted homes are declared **per package** in CLAUDE.md — a pattern's home in one package says nothing about the other. Judge each file against the table for the package it lives in. All paths below are relative to that package's `src/`. +> +> The client — every file under `packages/client/src/`: +> +> | Pattern | Allowed only in | +> | ---------------------------------------------------------------------------------------------- | --------------------------------------- | +> | bare `fetch(`, `globalThis.fetch`, third-party HTTP libs (`got`/`axios`/`node-fetch`/`undici`) | `http/**` | +> | `new URL(` | `http/**`, `url.ts` | +> | `JSON.parse(` | `json.ts` | +> | `setTimeout` inside an `await` wait loop | `poll.ts`, `http/retry.ts` | +> | `process.stdout.write`, `process.stderr.write` | nowhere — the client owns no output | +> | `process.exit(` | nowhere — the client never ends the run | +> | `@clack/prompts`, `@clack/core` | nowhere — prompting is the CLI's job | +> | `fs`, `fs/promises`, `node:fs`, `node:fs/promises` | nowhere — the client touches no files | +> | `child_process`, `node:child_process` | nowhere — the client spawns nothing | +> | an import specifier ending in `.ts` | nowhere — imports are extensionless | +> +> The CLI — every file under `packages/cli/src/`: +> +> | Pattern | Allowed only in | +> | ---------------------------------------------------------------------- | --------------------------------------------------------------------------- | +> | `process.exit(` | `cli.ts` | +> | `process.stderr.write` | `cli.ts`, `output/**` | +> | `process.stdout.write` | `output/**` | +> | `JSON.parse(` | nowhere — use `parseJson` from `@metabase/client/json` | +> | `new URL(` | nowhere — use `@metabase/client/url` | +> | bare `fetch(`, `globalThis.fetch`, third-party HTTP libs | `core/npm-registry.ts` | +> | `setTimeout` inside an `await` wait loop | nowhere — use `pollUntil` from `@metabase/client/poll` | +> | `child_process` (`spawn`, `exec`, etc.) | `runtime/process.ts` | +> | `@clack/prompts`, `@clack/core` | `output/prompt.ts` | +> | `process.env["MB_*"]`, `process.env["METABASE_*"]` | `core/env.ts` — a CLAUDE.md rule, not walker-enforced; judge it by reading | +> | an import specifier ending in `.ts` | nowhere — imports are extensionless | +> | an `/api/…` path literal (`"/api/` or `` `/api/ ``) | nowhere under `commands/` — endpoints are the client's `resources/` layer | +> | `.requestParsed(`, `.requestRaw(`, `.requestStream(`, `paginatePages(` | nowhere under `commands/` — a command calls `client..(…)` | +> +> The last two are absolute: neither has a sanctioned home anywhere in the CLI. Report every offending file. +> +> Two rules bind both packages but come from elsewhere, so apply them repo-wide rather than per-package: +> +> - `console.{log,warn,error,info,debug}` — forbidden everywhere, including `output/**` and `tests/`. `.oxlintrc.json` sets `no-console: error` with no overrides. Report every occurrence. +> - Platform env vars (`XDG_CONFIG_HOME`, `APPDATA`, `npm_config_prefix`) are read where they are used — `core/paths.ts`, `core/install-method.ts`, `core/auth/temp-config-home.ts`. `readEnv` owns the `MB_`/`METABASE_` family only; do not report the others. > > For each violation, report `path:line: : `. If zero violations, output `(clean)`. @@ -79,35 +121,52 @@ Whole-tree non-deterministic audit. Where `/review` checks the diff, this checks ## Subagent 4 — Test parity -> Audit pairing between `src/` and `tests/` in the current repository. Read-only. +> Audit pairing between the two packages' source and their tests in the current repository. Read-only. > -> Test layout: this project uses two tiers — colocated unit tests at `src/**/*.test.ts` and end-to-end at `tests/e2e/.e2e.test.ts`. There are no `tests/integration/` or `tests/smoke/` directories. +> Test layout: this project uses two tiers — colocated unit tests at `packages/

/src/**/*.test.ts` (beside the code, inside the package that owns it) and end-to-end at the repo root under `tests/e2e/.e2e.test.ts` (workspace-wide, driving the built `packages/cli/dist/cli.mjs`). There are no `tests/integration/` or `tests/smoke/` directories, and neither package has its own e2e tier. > > Required pairings: > -> - For every leaf command at `src/commands//.ts`: a `tests/e2e/.e2e.test.ts` exists and exercises that subcommand via `runCli`. Adding a new command without an e2e test is a missing pairing. -> - For every leaf command at `src/commands//.ts`: the literal `ALL_COMMANDS` list in `src/runtime/command-help.test.ts` must include `" "`. -> - For every `src/domain/.ts`: **no** fixture-and-parse-test pair is required (a unit test of the form `Schema.parse(fixture).toEqual(fixture)` is a tautology). The schema's contract is tested by the e2e tier when a command consuming it runs against the live API. Report a missing pairing only if no command consumes the schema AND no e2e test parses through it. -> - For every pure helper file in `src/core/**`, `src/output/**`, `src/runtime/**` (excluding `index.ts` re-exports and types-only files): a colocated unit test (`.test.ts` next to `.ts`) exercises it. +> - For every leaf command at `packages/cli/src/commands//.ts`: a `tests/e2e/.e2e.test.ts` exists and exercises that subcommand via `runCli`. Adding a new command without an e2e test is a missing pairing. +> - For every leaf command at `packages/cli/src/commands//.ts`: the literal `ALL_COMMANDS` list in `packages/cli/src/runtime/command-help.test.ts` must include `" "`. +> - For every `packages/client/src/domain/.ts`: **no** fixture-and-parse-test pair is required (a unit test of the form `Schema.parse(fixture).toEqual(fixture)` is a tautology). The schema's contract is tested by the e2e tier when a command consuming it runs against the live API. Report a missing pairing only if no command consumes the schema AND no e2e test parses through it. +> - For every `packages/client/src/resources/.ts`: a `packages/client/src/resources/.test.ts` beside it, covering every exported method with a wire assertion over `capture.calls` (URL, verb, headers, body) built on `captureFetch` from `packages/client/src/testing/fetch-capture.ts`. Report a method the wire test never sends — nothing else pins its path or query vocabulary at the unit tier. +> - For every pure helper file in `packages/client/src/**` and in `packages/cli/src/{core,output,runtime}/**` (excluding `index.ts` re-exports, the test doubles `packages/client/src/testing/fake-client.ts`, `packages/client/src/testing/fetch-capture.ts` and `packages/cli/src/core/auth/keyring-mock.ts`, and types-only files): a colocated unit test (`.test.ts` next to `.ts`, in the same package) exercises it. > - For every helper with infinite input space (URL parsing, JSON parsing, projection, byte capping, polling): a property test using `fast-check` should exist. > > For each missing pairing, report ` → expected `. If all pairings are present, output `(clean)`. --- -## Subagent 5 — Domain schema contract & dead exports +## Subagent 5 — Schema & resource contract, dead exports -> Audit `src/domain/` and the export graph in the current repository. Read-only. +> Audit `packages/client/src/domain/`, `packages/client/src/resources/`, and the export graph across both packages in the current repository. Read-only. > -> **Domain contract** — for every `src/domain/.ts`, verify it exports all of: +> **Domain contract** — for every `packages/client/src/domain/.ts`, verify it exports both of: > > 1. `` — a `z.object({...}).loose()` for the full API shape, with type via `z.infer`. (Zod 4 — `.passthrough()` is deprecated; `.loose()` is the project standard.) > 2. `Compact` — `.pick({...}).strip()` with inferred type. The trailing `.strip()` is **mandatory**: `.pick()` on a `.loose()` parent inherits the loose catchall and silently leaks every API field through the projection at parse time. Report a Compact missing `.strip()` as `path:line: compact-missing-strip: `. -> 3. `View` — an object with `compactPick: Compact` and `tableColumns: ColumnDef[]`. +> +> Presentation is CLI-owned: the `View` (`compactPick: Compact`, `tableColumns: ColumnDef[]`) belongs in `packages/cli/src/output/views/.ts`. A `ResourceView`, `ColumnDef`, `tableColumns`, or terminal-presentation `format:` helper found under `packages/client/src/domain/` is a layering violation — report as `path:line: presentation-in-client: `. > > Report missing exports as `path: missing-export: `. > -> **Dead exports** — find any `export` in `src/` that is not imported anywhere in `src/` or `tests/` (excluding `src/cli.ts` and `src/main.ts`, which are entry points). Report as `path:line: dead export: `. +> **Resource contract** — for every `packages/client/src/resources/.ts`, verify it exports `Resource(transport)`, that `packages/client/src/client.ts` composes the namespace onto the returned object, and that every method follows the eight conventions: +> +> 1. `client..(...)`, namespace named after the API resource (`mb.card`, singular). The resource is what Metabase calls the thing, not what it calls the route: the file and the namespace are `snippet` even though the endpoint is `/api/native-query-snippet`, because the domain schema, the CLI noun and the Metabase concept all read `snippet`. +> 2. Path parameters positional, then params, then options: `update(id, params, options?)`. +> 3. Params use Metabase's own field names verbatim — `f`, `model_id`, `include_inactive`. **No mapping layer, no renaming.** +> 4. Transport concerns (`signal`, `timeoutMs`, `retries`) live in the trailing `options`, never in params. +> 5. Wire envelope schemas are module-private to `resources/`; they never appear in `domain/`. +> 6. Methods return domain values, never wire envelopes. Non-paginated lists return `ListResult` = `{ data, total }`. +> 7. A string path parameter always goes through `encodeURIComponent`. +> 8. Every method carries the endpoint's description as a doc comment. +> +> Report as `path:line: : `. Convention 7 is the one with a silent failure mode, so check every `${…}` in a path template and confirm the interpolated value is either a number or escaped. Report a resource file no line in `client.ts` names as `path: unreachable-namespace: `. +> +> **List windowing** — for every `packages/cli/src/commands//list.ts`, verify the envelope comes from one of the three helpers in `packages/cli/src/output/window.ts`, picked by who applied the window: `windowList(data, ctx.range, total)` when the method returned the whole result set as a `ListResult` and the slice is client-side (the common case); `windowServerPage(data, total, ctx.range)` when the endpoint applied `limit`/`offset` itself and reports a count; `collectForOutput(source, view, ctx)` for a genuinely paged endpoint, where `source` is a `PageSource` forwarding the helper's `PageRequest` (`max`, `pageSize`) into the resource's `Pages` method. An envelope object literal assembled by hand is a drift finding — it produces a plausible shape whose `has_more`/`next_offset` no test catches. Report as `path:line: hand-rolled-envelope: `. Report a source that drops `max` or `pageSize` as `path:line: unbounded-walk: ` — the byte budget cannot stop the pull. Also report a list verb that omits `...listFlags`, or that never reads `ctx.range`, as `path:line: unwired-range: ` — its `--limit`/`--offset` silently do nothing. +> +> **Dead exports** — find any `export` in either package's `src/` that is not imported anywhere in `packages/*/src/` or `tests/`. Exclude the entry points (`packages/cli/src/cli.ts`, `packages/cli/src/main.ts`) and the client's public barrel `packages/client/src/index.ts`, whose whole job is re-export. A client export consumed only by the CLI (or only by a test) is live, not dead — resolve `@metabase/client/` specifiers to `packages/client/src/.ts` before judging. Report as `path:line: dead export: `. > > If both checks are clean, output `(clean)`. @@ -115,14 +174,14 @@ Whole-tree non-deterministic audit. Where `/review` checks the diff, this checks ## Subagent 6 — Code smells (correctness & readability) -> Audit `src/` in the current repository for correctness and readability smells. Read-only. +> Audit `packages/client/src/` and `packages/cli/src/` in the current repository for correctness and readability smells. Read-only. > > Find: > > - **Silent error swallows.** Empty `catch {}` blocks. `try`/`catch` whose handler returns a default value (`{}`, `[]`, `""`, `null`) instead of rethrowing or returning a typed error. > - **Placeholder fallbacks for absent state.** `?? ""`, `?? 0`, `?? []`, `?? {}` used to satisfy a type when the real semantic is "missing" or "error" rather than a genuine default. Inspect the surrounding context to judge — a literal default that's user-visible is fine; a coercion that hides a missing API field is not. > - **Boundary parsing without validation.** `JSON.parse(...)` whose result is consumed without a Zod schema or type guard. Network responses, file contents, env vars treated as already-typed. -> - **Magic literals duplicated across files.** Numeric or string constants (timeouts, byte caps, exit codes, profile/account names, file modes like `0o600`, retry counts) appearing in 2+ files without a shared named constant. Run a ripgrep pass over likely culprits and report any literal with 2+ occurrences in distinct files. +> - **Magic literals duplicated across files.** Numeric or string constants (timeouts, byte caps, exit codes, profile/account names, file modes like `0o600`, retry counts) appearing in 2+ files without a shared named constant. Run a ripgrep pass over likely culprits and report any literal with 2+ occurrences in distinct files, including pairs that straddle the two packages — a constant duplicated across the boundary belongs to whichever package owns the concept, exported for the other. > - **Boolean trap signatures.** Functions with 2+ boolean parameters; call sites passing 2+ positional booleans (`foo(true, false)`) where the call is not self-documenting. > - **Type guards that don't validate the narrowing.** `function isFoo(value): value is Foo` whose body checks a property weaker than what distinguishes `Foo` (e.g., narrowing to `NodeJS.ErrnoException` on the basis of `instanceof Error` alone). Quote the guard body. > - **Unreachable branches.** Code paths that cannot fire because an earlier branch returns first, or guards on conditions ruled out at the boundary. @@ -133,7 +192,7 @@ Whole-tree non-deterministic audit. Where `/review` checks the diff, this checks ## Subagent 7 — Comment & file-size hygiene -> Audit `src/` in the current repository for comment and size hygiene. Read-only. +> Audit `packages/client/src/` and `packages/cli/src/` in the current repository for comment and size hygiene. Read-only. > > Find: > @@ -171,10 +230,10 @@ Whole-tree non-deterministic audit. Where `/review` checks the diff, this checks > rg -n '\b(custom|my|patched|replacement|override|wrapped|reinvented)[A-Z]\w*\s*[=:(]' > rg -n '^(export\s+)?(async\s+)?function\s+(resolve|walk|find|parse|format|render|dispatch|route|debounce|throttle|retry|backoff|sleep|deepEqual|deepClone|merge|deepMerge|pick|omit|chunk|groupBy|escape)[A-Z]\w*' > rg -n 'Buffer\.byteLength|Buffer\.from\([^)]*\)\.length|\.split\(""\)\.length' # manual byte counting -> wc -l $(rg -l 'runMain|createServer|setupRoutes|defineCommand|fetch\(' src/ 2>/dev/null) 2>/dev/null | sort -nr | head -15 +> wc -l $(rg -l 'runMain|createServer|setupRoutes|defineCommand|fetch\(' packages/*/src/ 2>/dev/null) 2>/dev/null | sort -nr | head -15 > ``` > -> Then enumerate the project's own boundary helpers (parsers, HTTP clients, polling, command runtimes) and grep for direct calls to what each helper wraps. +> Then enumerate the project's own boundary helpers and grep for direct calls to what each helper wraps. Most of them are client surface the CLI is expected to reach for by specifier — `parseJson` (`@metabase/client/json`), `pollUntil` (`@metabase/client/poll`), `paginatePages` (`@metabase/client/paginate`), `normalizeUrl` (`@metabase/client/url`), `errorMessage` / `isFileNotFoundError` (`@metabase/client/errors`) — so a CLI file hand-rolling one of those is the highest-yield finding in this axis. The endpoint seam is the client's own: a command that assembles a request from `requestParsed` (`@metabase/client/http/transport`) or `paginatePages` instead of calling `client..(…)` is the same finding one layer up, and the simpler form is a method in `packages/client/src/resources/.ts`. The CLI's own seams are `defineMetabaseCommand` (`commands/runtime.ts`), `defineCommandGroup` (`commands/group.ts`), `readEnv` (`core/env.ts`), `readInput` / `readBody` (`runtime/`), `windowList` / `windowServerPage` / `collectForOutput` (`output/window.ts`), and `renderItem` / `renderList` (`output/render.ts`). > > **Phase 2 — investigate** (a candidate must survive all five questions to be promoted): > @@ -256,7 +315,7 @@ Once all eight subagents return, produce ONE report: ## Rules for the audit itself - Read-only. Do not modify any files. -- Do not run `npm run check` here. +- Do not run `tsc`, `oxlint`, `oxfmt`, or the test suites here. - An axis with zero findings is listed in the Summary's `Clean axes` line — never silently omitted. - If a subagent cannot determine something, surface the uncertainty in its findings — never PASS by default. - No editorializing. Quote snippets; let severity speak. diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index 42c5234..7039790 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -5,7 +5,7 @@ description: Strict end-of-task review. Use before marking any non-trivial task # review -Non-deterministic architectural check that complements `npm run check`. Determinism catches typos; this catches drift. +Non-deterministic architectural check that complements the deterministic gate (`bun run check` — typecheck, lint, format, unit tests, skill lint). Determinism catches typos; this catches drift. ## When to invoke @@ -24,61 +24,146 @@ Read both before forming any judgment. All other rules required for this review ## Project layering (memorize) +A bun workspace with two packages. `packages/client` (`@metabase/client`) is the private Metabase API client, built by `tsdown` to a `dist/` its `exports` map names; `packages/cli` (`@metabase/cli`) is the publishable CLI built on it, and inlines the client at build time. The e2e tier lives at the repo root under `tests/e2e/` and drives the built binary; unit tests sit beside their source under `packages/

/src/**/*.test.ts`. + ``` -src/cli.ts entrypoint; only file allowed to call process.exit -src/main.ts root citty command, lazy subCommands -src/commands/ CLI shell — citty defineCommand, flags, prompts, orchestration -src/core/ pure logic, no CLI deps - src/core/auth/ credential storage + verification - src/core/config.ts flag → env → file resolver - src/core/errors.ts type guards: isNodeError, isNotFoundError, errorMessage - src/core/url.ts normalizeUrl - src/core/http/ native-fetch-backed client + error taxonomy -src/domain/ Metabase API resource Zod schemas + inferred types -src/output/ presentation; takes typed values; renders text or JSON -src/runtime/ platform glue: stdin, poll, json, input, process +packages/client/src/ + index.ts public barrel: client factory, error taxonomy, Credential, parseJson + client.ts createClient — composes the resource namespaces onto one transport + resources/ one file per Metabase resource; every /api/ path and transport call + http/ the HTTP boundary — transport.ts, errors.ts, network-error.ts, + response-shape.ts, sanitize.ts, retry.ts, oauth.ts + testing/ the shipped test doubles — fake-client.ts, fetch-capture.ts + auth/ OAuth login flow: credential.ts, pkce.ts, callback-server.ts, oauth-*.ts + version/ tag.ts, probe.ts, capabilities.ts, preflight-error.ts + domain/ Metabase API resource Zod schemas + inferred types + errors.ts MetabaseError taxonomy, toMetabaseError, isFileNotFoundError, errorMessage + list.ts ListResult = { data, total } — what a one-response list method returns + url.ts normalizeUrl, displayUrl, assertEndpointOrigin — the only home for new URL() + json.ts parseJson — the only home for JSON.parse + poll.ts pollUntil — the wait-loop home (http/retry.ts drives its own backoff wait) + paginate.ts, signal.ts, predicates.ts, json-pointer.ts + +packages/cli/src/ + cli.ts entrypoint; only file allowed to call process.exit + main.ts root citty command, lazy subCommands + commands/ CLI shell — defineMetabaseCommand, flags, prompts, orchestration + core/ pure logic, no CLI deps + core/auth/ credential storage + verification + core/config.ts flag → env → stored resolver + core/env.ts readEnv — every MB_* env name, no raw process.env reads elsewhere + core/user-agent.ts USER_AGENT, the CLI's wire identity + core/npm-registry.ts the upgrade check's dist-tags lookup — the CLI's only fetch() + core/system-ca.ts trustSystemCa — the only node:tls in the repo, a CLI prerogative + output/ presentation; takes typed values; renders text or JSON + runtime/ platform glue: stdin, csv, yaml, citty helpers ``` -Allowed import direction: +Allowed import direction — the package boundary first, then within each package: -- `commands/*` may import `core/*`, `domain/*`, `output/*`, `runtime/*`. -- `core/*` may import `domain/*`, `runtime/*`. Never `commands/`. -- `output/*` may import `domain/*`, `runtime/*`, and `core/*` except `core/auth/`. Never `commands/`, `core/auth/`. -- `runtime/*` imports nothing from `commands/`, `core/`, `domain/`, `output/`. -- `domain/*` imports only `zod` and other `domain/*` files. Nothing from elsewhere. +- **Nothing in the client may import from the CLI** — not by an `@metabase/cli` specifier, not by a relative path that escapes the client's own src tree. The reverse is expected: the CLI consumes the client as `@metabase/client/`. +- The client's dependency budget is `zod` + `semver` + `node:` builtins. Root devDependencies hoist, so the resolver will not reject a third dependency — a production client file importing anything else is FAIL even though it type-checks. +- `domain/*` imports only `zod` and sibling `domain/*` files. Nothing from elsewhere in either package. +- `resources/*` is imported by `client.ts` and by sibling `resources/*` files, and by nothing else. A `domain/`, `http/`, `auth/` or `version/` file importing a resource is FAIL — the direction runs one way, and the permitted importers are an allowlist, so a directory added later is not exempt for having arrived after the rule. +- `commands/*` may import `core/*`, `output/*`, `runtime/*`, and any `@metabase/client/*` path. +- `core/*` may import `runtime/*` and `@metabase/client/*`. Never `commands/`, never `output/`. +- `output/*` may import `runtime/*`, `core/*` except `core/auth/`, and `@metabase/client/*` **except `@metabase/client/http/`** — presentation consumes the error taxonomy from `@metabase/client/errors`, not the HTTP boundary. Never `commands/`. +- `runtime/*` may import `@metabase/client/*` (typed errors are the project's pattern: `runtime/body.ts` uses `parseJson` and `ConfigError`, `runtime/yaml.ts` uses `ValidationError`). It must not import from `commands/`, `core/`, or `output/`. ## Domain-file contract -Every Metabase API resource in `src/domain/.ts` exports exactly three things per resource: +Every Metabase API resource in `packages/client/src/domain/.ts` exports exactly two things per resource — the client's agent-facing contract: 1. **``** — `z.object({ ... }).loose()` for the full API shape, with the type inferred via `z.infer`. `.loose()` is the default so Metabase API additions don't break the parse — tighten over time, don't preempt. (Zod 4 — `.passthrough()` is deprecated; use `.loose()`.) 2. **`Compact`** — `.pick({ ... }).strip()` projection with its inferred type. The trailing `.strip()` is **mandatory**: `.pick()` on a `.loose()` parent inherits the loose catchall and silently passes every API field through at parse time, which means the "compact" projection isn't actually compact. A diff that lands a Compact missing `.strip()` is FAIL — quote `.pick({...})` without trailing `.strip()`. -3. **`View`** — `ResourceView` with `compactPick: Compact` and `tableColumns: ColumnDef[]`. Consumed by `src/output/render`; inlining a column list inside a command is FAIL. -Naming: `` is PascalCase (`Card`); `View` is camelCase (`cardView`). `` is the _resource name_, not the file name — a file may host multiple resources (e.g. `domain/user.ts` → `CurrentUser` + `CurrentUserCompact` + `userView`); the three-export contract holds per-resource. +Presentation is CLI-owned, never the client's: the `View` (`ResourceView` with `compactPick: Compact` and `tableColumns: ColumnDef[]`) lives in `packages/cli/src/output/views/.ts`, with any `format:` cell helper colocated there. A `ResourceView`, a `ColumnDef`, a `tableColumns` list, or a terminal-presentation `format:` helper appearing in `packages/client/src/domain/` is FAIL. Inlining a column list inside a command is FAIL. + +Naming: `` is PascalCase (`Card`); the CLI-side `View` is camelCase (`cardView`). `` is the _resource name_, not the file name — a file may host multiple resources (e.g. `domain/user.ts` → `CurrentUser` + `CurrentUserCompact`); the pair contract holds per-resource. + +Adding a resource is additive: a new `packages/client/src/domain/.ts` for the pair, `packages/client/src/resources/.ts` for the methods, and `packages/cli/src/output/views/.ts` for the view — consumed by commands as `client..(…)`, `@metabase/client/domain/` and `../../output/views/`. A schema declared in `packages/cli` instead — or a command importing one by a relative path into the client's src tree — is FAIL. Editing existing commands to wire the schema is FAIL — commands import what they need. Do **not** require a fixture + parse-test pair — that is a tautology against Zod itself. The schema is contract-tested by the e2e tier when a command consuming it runs against the live API. + +A new or modified API resource without both exports, or that omits `.loose()` on the full schema, or that omits `.strip()` after `.pick()` on the Compact, or that hand-rolls `Array>` / inline `as { ... }` casts in commands instead of consuming `z.infer`, is a structural failure. + +`Record` is forbidden in command files. The single exemption is `packages/cli/src/output/projection.ts` — the canonical structurally-untyped envelope for dot-path `--fields` projection over already-validated payloads. + +## Resource-method contract + +`packages/client/src/resources/.ts` exports `Resource(transport)` and holds every `/api/` path the client sends. `packages/client/src/client.ts` composes each namespace onto the client, so a resource file with no line in `client.ts` is unreachable and FAIL. Eight conventions bind every method: -Adding a resource is additive: a new `src/domain/.ts`. Editing existing commands to wire the schema is FAIL — commands import what they need. Do **not** require a `tests/fixtures//sample.json` + parse-test pair — that is a tautology against Zod itself. The schema is contract-tested by the e2e tier when a command consuming it runs against the live API. +1. `client..(...)`, namespace named after the API resource (`mb.card`, singular). The resource is what Metabase calls the thing, not what it calls the route: the file and the namespace are `snippet` even though the endpoint is `/api/native-query-snippet`, because the domain schema, the CLI noun and the Metabase concept all read `snippet`. +2. Path parameters positional, then params, then options: `update(id, params, options?)`. +3. Params use Metabase's own field names verbatim — `f`, `model_id`, `include_inactive`. **No mapping layer, no renaming.** +4. Transport concerns (`signal`, `timeoutMs`, `retries`) live in the trailing `options`, never in params. +5. Wire envelope schemas are module-private to `resources/`; they never appear in `domain/`. +6. Methods return domain values, never wire envelopes. Non-paginated lists return `ListResult` = `{ data, total }`. +7. A string path parameter always goes through `encodeURIComponent`. +8. Every method carries the endpoint's description as a doc comment. -A new or modified API resource without all three exports, or that omits `.loose()` on the full schema, or that omits `.strip()` after `.pick()` on the Compact, or that hand-rolls `Array>` / inline `as { ... }` casts in commands instead of consuming `z.infer`, is a structural failure. +A method violating any of the eight is FAIL — quote the signature or the path template. Convention 7 is the one with a silent failure mode: an un-escaped string interpolated into a path template lets a `/` or `?` in a setting key or collection ref rewrite the request. -`Record` is forbidden in command files. The single exemption is `src/output/projection.ts` — the canonical structurally-untyped envelope for dot-path `--fields` projection over already-validated payloads. +A new or changed method comes with its wire test in `packages/client/src/resources/.test.ts` — one `it` per method, asserting URL, verb, headers and body in a single `toEqual` over `capture.calls` from `@metabase/client/testing/fetch-capture`. That test is what pins the path and query vocabulary; a method landed without one is FAIL. ## Forbidden patterns and their permitted homes -The src-side rules are enforced by `tests/structure.test.ts` for files under `src/`. Test files are out of that walker's scope, but the e2e-side restrictions below are still part of this review. - -| Pattern | Permitted only in | -| ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `process.exit(` | `src/cli.ts`, `tests/e2e/setup/bootstrap.ts` | -| `process.stdout.write`, `process.stderr.write` | `src/cli.ts`, `src/output/**`, `tests/e2e/setup/bootstrap.ts` | -| `console.{log,warn,error,info,debug}` | `src/cli.ts`, `src/output/**` | -| `JSON.parse(` | `src/runtime/json.ts` | -| bare `fetch(`, `globalThis.fetch`, third-party HTTP libs (`got`/`axios`/`node-fetch`/`undici`) | `src/core/http/**`, `tests/e2e/setup/**` | -| `new URL(` | `src/core/http/**`, `src/core/url.ts` | -| `child_process` (`spawn`, `exec`, etc.), `execa` | `src/runtime/process.ts`, `tests/e2e/run-cli.ts`, `tests/e2e/setup/global-setup.ts` | -| `setTimeout` inside an `await` polling loop | `src/runtime/poll.ts` | -| Hand-written ENOENT shape check (`error.code === "ENOENT"`) | nowhere — use `isNotFoundError` from `src/core/errors` | -| Inline `JSON.parse(...) ; Schema.parse(...)` | nowhere — use `parseJson(raw, Schema, { source })` from `src/runtime/json.ts` | +CLAUDE.md declares these as a **separate policy per package**, applied to production source only (`*.test.ts` files are exempt). A pattern's permitted home in one package says nothing about the other: `JSON.parse` has a home in the client and none in the CLI, `fetch` has a home in each but at different paths. The e2e-side restrictions below are part of this review too. + +### The client — every file under `packages/client/src/` + +| Pattern | Permitted only in | +| ---------------------------------------------------------------------------------------------- | --------------------------------------- | +| bare `fetch(`, `globalThis.fetch`, third-party HTTP libs (`got`/`axios`/`node-fetch`/`undici`) | `http/**` | +| `new URL(` | `http/**`, `url.ts` | +| `JSON.parse(` | `json.ts` | +| `setTimeout` inside an `await` wait loop | `poll.ts`, `http/retry.ts` | +| `process.stdout.write`, `process.stderr.write` | nowhere — the client owns no output | +| `process.exit(` | nowhere — the client never ends the run | +| `@clack/prompts`, `@clack/core` | nowhere — prompting is the CLI's job | +| `fs`, `fs/promises`, `node:fs`, `node:fs/promises` | nowhere — the client touches no files | +| `child_process`, `node:child_process` | nowhere — the client spawns nothing | +| a `.ts` extension in an import specifier | nowhere | +| any dependency beyond `zod`, `semver`, `node:` builtins | nowhere | +| an import resolving into `packages/cli` | nowhere | + +### The CLI — every file under `packages/cli/src/` + +| Pattern | Permitted only in | +| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `process.exit(` | `cli.ts` | +| `process.stderr.write` | `cli.ts`, `output/**` | +| `process.stdout.write` | `output/**` | +| `JSON.parse(` | nowhere — use `parseJson` from `@metabase/client/json` | +| `new URL(` | nowhere — use the helpers in `@metabase/client/url` | +| bare `fetch(`, `globalThis.fetch`, third-party HTTP libs | `core/npm-registry.ts` (not a Metabase endpoint) | +| `setTimeout` inside an `await` wait loop | nowhere — use `pollUntil` from `@metabase/client/poll` | +| `child_process` (`spawn`, `exec`, etc.) | `runtime/process.ts` | +| `@clack/prompts`, `@clack/core` | `output/prompt.ts` | +| `Record` | nowhere under `commands/` — the walker scopes this rule to command files; `output/projection.ts` is the canonical untyped envelope and sits outside that scope | +| an import of `@metabase/client/http/**` from `output/**` | nowhere — presentation uses `@metabase/client/errors` | +| a `.ts` extension in an import specifier | nowhere | +| `process.env["MB_*"]`, `process.env["METABASE_*"]` | `core/env.ts` — CLI env vars go through `readEnv`. A CLAUDE.md rule, not walker-enforced; judge it by reading, not by trusting the gate | +| an `/api/…` path literal (`"/api/` or `` `/api/ ``) | nowhere under `commands/` — endpoints are the client's `resources/` layer | +| `.requestParsed(`, `.requestRaw(`, `.requestStream(`, `paginatePages(` | nowhere under `commands/` — a command calls `client..(…)` | + +The last two are **absolute**: neither has a sanctioned home anywhere in the CLI. A diff that lands a command naming a path or driving the transport is FAIL. + +What those two patterns bound is a path literal and a transport call, not every client function that can open a socket. `commands/auth/login.ts` calls `tryDiscoverMetadata` from `@metabase/client/http/oauth`, which fetches the OAuth discovery document before any credential exists and therefore before a client can be constructed; that one is sanctioned. A **second** such call from `commands/` is FAIL — the pattern rules cannot see it, so review is the only thing that can. + +Two rules bind both packages but come from elsewhere, so judge them repo-wide rather than per-package: + +- `console.{log,warn,error,info,debug}` — forbidden everywhere, including `output/**` and `tests/`. `.oxlintrc.json` sets `no-console: error` with no overrides; write through `output/render.ts` instead. +- Platform env vars (`XDG_CONFIG_HOME`, `APPDATA`, `npm_config_prefix`) are read where they are used — `core/paths.ts`, `core/install-method.ts`, `core/auth/temp-config-home.ts`. `readEnv` owns the `MB_`/`METABASE_` family only; do not flag the others. + +### The test tree — every file under the repo-root `tests/`, outside the walker but still reviewed here + +| Pattern | Permitted only in | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `process.exit(`, `process.std*.write` | `tests/e2e/setup/**` | +| `/api/testing/snapshot`, `/api/testing/restore` | `tests/e2e/setup/**` — a `beforeEach` there restores before every test | +| `fetch(` against Metabase | `tests/e2e/setup/**` (bootstrap, oauth harness) | +| `child_process` / `execa` | `tests/e2e/run-cli.ts`, `tests/e2e/setup/**`, `scripts/**` | +| Hand-written ENOENT shape check (`error.code === "ENOENT"`) | nowhere — use `isFileNotFoundError` from `@metabase/client/errors` | +| Inline `JSON.parse(...) ; Schema.parse(...)` | nowhere — use `parseJson(raw, Schema, { source })` from `@metabase/client/json` | ## Type strictness rules (always) @@ -92,52 +177,58 @@ The src-side rules are enforced by `tests/structure.test.ts` for files under `sr ## Test-parity rules -- New `src/commands//.ts` ⇒ a `tests/e2e/.e2e.test.ts` exists and exercises that subcommand end-to-end via `runCli`. Adding a new command without an e2e test is FAIL. -- New `src/commands//.ts` (any leaf) ⇒ `src/runtime/command-help.test.ts`'s literal `ALL_COMMANDS` list includes the new path. Renaming or deleting a command without updating the list is FAIL. -- New `src/domain/.ts` ⇒ no fixture-and-parse-test pair required. A unit test of the form `Schema.parse(fixture).toEqual(fixture)` is a tautology against Zod and is FAIL if introduced. The schema's contract is tested by the e2e tier hitting the live API. -- New pure helper in `core/`, `output/`, `runtime/` ⇒ a unit test imports from it. +- New `packages/cli/src/commands//.ts` ⇒ a `tests/e2e/.e2e.test.ts` exists and exercises that subcommand end-to-end via `runCli`. Adding a new command without an e2e test is FAIL. +- New `packages/cli/src/commands//.ts` (any leaf) ⇒ `packages/cli/src/runtime/command-help.test.ts`'s literal `ALL_COMMANDS` list includes the new path. Renaming or deleting a command without updating the list is FAIL. +- New or changed method in `packages/client/src/resources/.ts` ⇒ `packages/client/src/resources/.test.ts` covers it with a wire assertion over `capture.calls` (URL, verb, headers, body) built on `captureFetch` from `@metabase/client/testing/fetch-capture`. A method with no wire test is FAIL — nothing else pins the path or the query vocabulary at the unit tier. +- New `packages/client/src/domain/.ts` ⇒ no fixture-and-parse-test pair required. A unit test of the form `Schema.parse(fixture).toEqual(fixture)` is a tautology against Zod and is FAIL if introduced. The schema's contract is tested by the e2e tier hitting the live API. +- New pure helper anywhere in `packages/client/src/` or in the CLI's `core/`, `output/`, `runtime/` ⇒ a colocated unit test (`.test.ts` beside `.ts`, inside the same package) imports from it. - New helper with infinite input space (URL parsing, JSON parsing, projection, byte cap, polling) ⇒ a property test (`fast-check`). ## E2E test contract -- E2E tests drive the CLI through `runCli` from `tests/e2e/run-cli.ts`. Direct `execa`, `spawn`, or `child_process` calls inside `tests/e2e/**/*.e2e.test.ts` are FAIL. -- E2E tests read admin credentials via `readBootstrap()` from `tests/e2e/bootstrap-data.ts`. Inline `fetch` to Metabase, hard-coded API keys, or calls to `/api/setup` from a test are FAIL. +- E2E tests drive the built `packages/cli/dist/cli.mjs` through `runCli` from `tests/e2e/run-cli.ts`. Direct `execa`, `spawn`, or `child_process` calls inside `tests/e2e/**/*.e2e.test.ts` are FAIL. +- E2E tests read admin credentials via `readBootstrap()` from `tests/e2e/bootstrap-data.ts` and seeded entity ids via `SEEDED` from `tests/e2e/seed/seeded.ts`. Inline `fetch` to Metabase, hard-coded API keys, hard-coded entity ids, or calls to `/api/setup` from a test are FAIL. - The `Bootstrap` Zod schema and `BOOTSTRAP_FILE_PATH` constant live only in `tests/e2e/bootstrap-data.ts`. Re-declaring either in `tests/e2e/setup/bootstrap.ts` (or anywhere else) is FAIL — the writer imports from the reader to prevent shape drift. -- `--json` output assertions parse through the schema imported from `src/commands//.ts` (e.g. `LoginResult`, `AuthStatus`) or `src/domain/.ts`. Re-declaring a `z.object({...})` in a test that mirrors a `src/` export is FAIL. +- `--json` output assertions parse through the schema imported from the package that owns it: `@metabase/client/domain/` for a resource, `../../packages/cli/src/commands//` for a command-shaped output (e.g. `LoginResult`, `AuthStatus`, `ListEnvelope`). Re-declaring a `z.object({...})` in a test that mirrors either export is FAIL. - Each test or test-group gets its own `XDG_CONFIG_HOME` via `mkTempConfigHome()`. Sharing config home across tests that mutate credentials is FAIL. +- A suite whose command declares non-baseline `capabilities` gates itself with `requireServer("", {...})` from `tests/e2e/server-gate.ts`, passing a lane label naming the describe or test the gate guards so an unmet gate is reported in the closing coverage block. A suite that fails instead of skipping on a server that cannot satisfy the command is FAIL. ## Axes (output one row per axis) For each axis below, output `PASS`, `FAIL`, or `UNKNOWN`. For every `FAIL` and `UNKNOWN`, quote the offending snippet with `path:line` and a one-sentence explanation. -1. **Layering** — direction rules above. +1. **Layering** — direction rules above, judged against the package the file is in. 2. **Type strictness** — rules above. -3. **Output discipline** — `console.*`, `process.exit`, `process.std*.write` outside permitted homes. -4. **Boundary discipline** — `JSON.parse`, raw `fetch`/`globalThis.fetch`/third-party HTTP libraries (`got`/`axios`/`node-fetch`/`undici`)/`new URL`, `child_process`, polling `setTimeout` outside permitted homes. -5. **Domain contract** — every new or changed resource in `src/domain/` exports `` (with `.loose()`), `Compact` (with `.pick({...}).strip()` — the trailing `.strip()` is mandatory), and `View`; naming follows the convention; commands consume `z.infer` rather than `Array>` or inline `as { ... }` casts. -6. **Test parity** — rules above. -7. **Comment hygiene** — no WHAT-comments; no task/PR/issue/external-path references; no comments-as-history. -8. **Imports / extensions** — no `from "...\.(ts|js)";`. Type-only imports use `import type`. -9. **TS escape hatches** — no `// @ts-ignore`, `// @ts-nocheck`, `// @ts-expect-error` without paired `reason:` + `FIXME(date)`. -10. **Reuse / supersession** — for each new helper, grep `src/` for existing helpers covering the same intent. New code that duplicates an existing utility is FAIL. A new helper that subsumes an older narrower one without deleting the old one is FAIL. -11. **Type drift** — a hand-written `interface`/`type` whose keys mirror an in-repo value's shape (could be `typeof X`, mapped over `keyof typeof X`, or a library generic like `ParsedArgs`) is FAIL. Hand-maintained parallel definitions drift silently. -12. **Catch-all directories** — new files under `_shared/`, `_helpers/`, `_utils/`, `common/`, `lib/`, `misc/`, or any other bucket-name directory is FAIL. Files must be named by purpose. -13. **Test quality** — assertions over a structure must be full (`toEqual()` / `toEqual()`) — single-property pokes when the whole object is observable is FAIL (e.g. a sequence of `expect(parsed.id).toBe(...)` / `expect(parsed.name).toBe(...)` after `parseJson` collapses to one `toEqual({...})`). Redundant assertions (e.g. asserting `.length` before `toEqual` of the array) is FAIL. Error assertions that check only the type or only the message — never both — is FAIL. Fixture fields that no test reads (and are not structurally required to satisfy a type) is FAIL. - - **Exit codes:** `expect(.exitCode).not.toBe(0)` is FAIL. Assert the exact integer — `toBe(2)` for `ConfigError`, `toBe(130)` for `AbortError`, `toBe(1)` for everything else (`HttpError`, `ValidationError`, `NetworkError`, `TimeoutError`, `UnknownError`). The taxonomy lives in `src/core/errors.ts`; if you can't tell which code is right, the test isn't asserting the contract you think it is. +3. **Output discipline** — `console.*`, `process.exit`, `process.std*.write` outside permitted homes. A file under `packages/client/src/` writing to a stream or ending the process is FAIL regardless of path — the client has no permitted home for either. +4. **Boundary discipline** — `JSON.parse`, raw `fetch`/`globalThis.fetch`/third-party HTTP libraries (`got`/`axios`/`node-fetch`/`undici`)/`new URL`, `child_process`, polling `setTimeout` outside permitted homes. Read the per-package table — the CLI has no home for `JSON.parse`, `new URL`, or a wait loop, and delegates all three to the client. + 4a. **Package boundary** — an import in `packages/client/**` that resolves into `packages/cli` (by `@metabase/cli` specifier or by a relative path escaping the client's src tree) is FAIL. A production client file importing anything beyond `zod`, `semver`, and `node:` builtins is FAIL. A CLI file reaching into the client by relative path instead of the `@metabase/client/` specifier is FAIL. `packages/cli/src/output/**` importing `@metabase/client/http/**` is FAIL. Anything outside `client.ts` and `resources/` importing `resources/` is FAIL. + 4b. **Request placement** — a file under `packages/cli/src/commands/` that names an `/api/…` path literal, or calls `requestParsed`/`requestRaw`/`requestStream`/`paginatePages`, is FAIL. Quote the path template and name the resource method it belongs on. +5. **Resource contract** — every new or changed resource in `packages/client/src/domain/` exports `` (with `.loose()`) and `Compact` (with `.pick({...}).strip()` — the trailing `.strip()` is mandatory), and its `View` lives CLI-side in `packages/cli/src/output/views/.ts` (a `ResourceView`/`ColumnDef`/`tableColumns` in the domain file is FAIL); naming follows the convention; commands consume `z.infer` rather than `Array>` or inline `as { ... }` casts. Every new or changed method in `packages/client/src/resources/` satisfies all eight conventions above, is composed onto `client.ts`, and returns a domain value rather than the server's envelope. +6. **List windowing** — a list command builds its envelope with one of the three helpers in `packages/cli/src/output/window.ts`, picked by who applied the window: `windowList(data, ctx.range, total)` when the method returned the whole result set as a `ListResult` and the slice is client-side (the common case); `windowServerPage(data, total, ctx.range)` when the endpoint applied `limit`/`offset` itself and reports a count; `collectForOutput(source, view, ctx)` for a genuinely paged endpoint, where `source` is a `PageSource` forwarding the helper's `PageRequest` (`max`, `pageSize`) into the resource's `Pages` method. A source that drops `max` or `pageSize` is FAIL — the byte budget cannot bound the walk. A hand-rolled envelope literal is FAIL — it produces a plausible shape whose `has_more`/`next_offset` no test catches. A list verb missing `...listFlags`, or one that never reads `ctx.range`, is FAIL: the `--limit`/`--offset` an agent needs to page silently do nothing. +7. **Test parity** — rules above. +8. **Comment hygiene** — no WHAT-comments; no task/PR/issue/external-path references; no comments-as-history. +9. **Imports / extensions** — no `from "...\.(ts|js)";`. Type-only imports use `import type`. +10. **TS escape hatches** — no `// @ts-ignore`, `// @ts-nocheck`, `// @ts-expect-error` without paired `reason:` + `FIXME(date)`. +11. **Reuse / supersession** — for each new helper, grep **both packages** for existing helpers covering the same intent; a CLI helper duplicating something the client already exports (`parseJson`, `pollUntil`, `paginatePages`, `errorMessage`, `normalizeUrl`) is the common case and is FAIL. A new helper that subsumes an older narrower one without deleting the old one is FAIL. +12. **Type drift** — a hand-written `interface`/`type` whose keys mirror an in-repo value's shape (could be `typeof X`, mapped over `keyof typeof X`, or a library generic like `ParsedArgs`) is FAIL. Hand-maintained parallel definitions drift silently. +13. **Catch-all directories** — new files under `_shared/`, `_helpers/`, `_utils/`, `common/`, `lib/`, `misc/`, or any other bucket-name directory is FAIL. Files must be named by purpose. +14. **Test quality** — assertions over a structure must be full (`toEqual()` / `toEqual()`) — single-property pokes when the whole object is observable is FAIL (e.g. a sequence of `expect(parsed.id).toBe(...)` / `expect(parsed.name).toBe(...)` after `parseJson` collapses to one `toEqual({...})`). Redundant assertions (e.g. asserting `.length` before `toEqual` of the array) is FAIL. Error assertions that check only the type or only the message — never both — is FAIL. Fixture fields that no test reads (and are not structurally required to satisfy a type) is FAIL. + - **Exit codes:** `expect(.exitCode).not.toBe(0)` is FAIL. Assert the exact integer — `toBe(2)` for `ConfigError`, `toBe(130)` for `AbortError`, `toBe(2)` for `CapabilityError`, `toBe(1)` for everything else (`HttpError`, `ValidationError`, `NetworkError`, `TimeoutError`, `UnknownError`). The taxonomy lives in `packages/client/src/errors.ts` (`CapabilityError` in `packages/client/src/version/preflight-error.ts`); if you can't tell which code is right, the test isn't asserting the contract you think it is. - **Error strings:** `expect().toMatch(/.../i)` for stderr / error messages is FAIL. Use `toContain("")` or `toBe("")`. The narrow exception is asserting _absence_ of a pattern (`expect(out).not.toMatch(/pattern/)`). Where the message contains a dynamic value (a byte count, a generated id, a path), build the expected string from the same data the production code consumed and assert with `toBe`. Replacing the dynamic part with `\d+` or `.*` in a regex is FAIL. -14. **README parity** — `README.md` must document every command in `src/commands/` (each `defineCommand` exported as a top-level subcommand). Adding a new command, renaming one, removing one, or changing a flag's name/description without a matching `README.md` update is FAIL. Adding/removing a flag in `commonFlags` without updating the common-flags section is FAIL. If `README.md` is absent, mark FAIL with reason "no README to keep in sync" so the project owner is on notice. -15. **Fail-fast boundaries** — boundary code (JSON parsing, file I/O, HTTP response handling, stdin reads) must throw or return a typed error on malformed input. Diff that introduces silent coercion to `{}`/`[]`/`""`, `?? ""`/`?? 0`/`?? []`/`?? {}` to satisfy a type when the real meaning is "absent" or "error", empty `catch {}` blocks, or `try`/`catch` that swallows the error and returns a default — is FAIL. Quote the offending site. -16. **Magic literals** — diff introduces a numeric or string literal that already appears elsewhere in the repo without a shared constant (timeouts, byte caps, exit codes, profile names, account identifiers, file modes). Grep `src/` for the literal before deciding; one occurrence is fine, two without a constant is FAIL. -17. **Boolean traps** — diff adds a function with 2+ boolean parameters, or a call site with 2+ positional boolean arguments where intent is non-obvious from the call. Use a named-options object or split functions. FAIL. -18. **Type-guard validity** — a function declared `(value): value is T` must validate the property that distinguishes `T`. `instanceof Error` narrowing to a more specific error subtype, or any guard whose body cannot rule out a non-`T` input, is a hidden cast and FAIL. -19. **Eloquence** — diff introduces ceremony that has no real-world counterpart. Stacked `override readonly` chains where a plain assignment would do, intermediate abstract classes that exist solely to deduplicate four lines, generic parameters that don't earn their complexity, option-bag wrappers around single-arg calls — FAIL. The test: would two engineers writing this from scratch both reach for this pattern? If no, it's overengineered. Quote the offending construct and a simpler alternative. -20. **Expression complexity** — diff introduces a big inline boolean or ternary chain in an `if`, `return`, or assignment. Multi-clause `&&`/`||` mixes, `?.` ladders combined with `??` fallbacks, or nested ternaries (`a ? x : b ? y : z`) inside a conditional are FAIL. Each meaningful clause should be a named local (`const hasBudget = …; const isFresh = …`). The condition in the `if` should read as one phrase, not a puzzle. Quote the offending site. -21. **Mocking discipline** — `vi.mock(...)` is a last resort. Diff introduces a module mock to test a one-line wrapper / one-line delegation, or to substitute logic owned by the system under test, is FAIL — that test asserts only what the mock was configured to return (a tautology). Acceptable: hermetic isolation of side-effecting external deps in integration tests where the real path pollutes the host (e.g. `@napi-rs/keyring`, real network, real subprocess), or fixture injection into a fully-exercised pipeline. Not acceptable: mocking an internal module so a unit test can fire a single branch — instead prefer a real value flowing through real imports (real `ZodError`, real `HttpError`, real Zod schema), or push the coverage to the smoke/integration tier. Prefer integration over isolation when in doubt. Quote any `vi.mock` call and the one-line wrapper it shadows. -22. **E2E CLI invocation discipline** — under `tests/e2e/**/*.e2e.test.ts`, any direct call to `execa`, `spawn`, `exec`, or `child_process` is FAIL — e2e tests drive the binary through `runCli` from `tests/e2e/run-cli.ts`. Inline `fetch(...)` from a test file is FAIL — credential setup belongs in `tests/e2e/setup/bootstrap.ts` and tests read state via `readBootstrap()`. -23. **Bootstrap-data unity** — the `Bootstrap` Zod schema (the `.bootstrap.json` shape) and the `BOOTSTRAP_FILE_PATH` constant must live only in `tests/e2e/bootstrap-data.ts`. Any redeclaration of the same shape elsewhere (typically in `tests/e2e/setup/bootstrap.ts`) is FAIL — writer and reader share one source. -24. **Test schema reuse** — when an `src/` module exports a Zod schema describing CLI output, tests must import it. Schemas that fall under this rule include: per-command output schemas (e.g. `LoginResult`, `AuthStatus`), every `` / `Compact` in `src/domain/`, and every `ListEnvelope` exported from `src/commands//list.ts` (built via `listEnvelopeSchema(Compact)` from `src/output/types.ts`). A test file that declares a `z.object({...})` mirroring the same fields as a known `src/` export — including hand-rolling a list-envelope shape (`{ data, returned, total }`) instead of importing the command's `ListEnvelope` — is FAIL. Quote both the test declaration and the canonical export. -25. **Helper reuse in tests** — test code reimplementing a helper that already lives in `src/runtime/` or `src/core/errors` (e.g. inline `JSON.parse` + `Schema.parse` instead of `parseJson`; `error.code === "ENOENT"` instead of `isNotFoundError`; deadline+sleep loops instead of `pollUntil`) is FAIL. Test files are allowed to import these helpers across the layering boundary because the layering rules don't apply to `tests/`. -26. **License-token discipline** — diff that introduces (a) a hardcoded real EE token (any string matching `^mb_(dev_[0-9a-f]{57}|[0-9a-f]{64})$` outside a clearly-fake placeholder like `mb_dev_0123…`), (b) `console.log` / `process.std*.write` / `Bash(echo $...)` / `Read(.env*)` of `MB_PREMIUM_EMBEDDING_TOKEN` or `METASTORE_DEV_SERVER_URL` or any binding holding their value, (c) writing the token to a file outside the test process's stdin pipe, (d) an assertion `toContain` / `toEqual` / `toBe` that compares against the token string is FAIL. Acceptable: `process.env["MB_PREMIUM_EMBEDDING_TOKEN"] === ""` for skip-gating; `runCli({ stdin: token })` to thread the token opaquely. Quote the offending site. + - **e2e stderr:** the harness is never a TTY, so the CLI writes a JSON error envelope to stderr. `expect(.stderr).toBe("")` can never match and is FAIL — stderr carries the envelope plus any leading `warn()` lines. Assert through `cliErrorMessage(.stderr)` from `tests/e2e/cli-error.ts`. A raw `expect(.stderr).toContain(...)` is acceptable for a plain substring, but is FAIL when the expected message contains a quote, backslash, or newline — the envelope JSON-escapes those, so the raw substring is absent and only `cliErrorMessage` sees the real text. +15. **README parity** — the root `README.md` is the user-facing command reference and must document every command in `packages/cli/src/commands/` (each group exported as a top-level subcommand). Adding a new command, renaming one, removing one, or changing a flag's name/description without a matching root `README.md` update is FAIL. Adding/removing a flag in `GLOBAL_FLAG_ARGS` (`packages/cli/src/commands/global-flags.ts`, composed from `outputFlags` / `profileFlag` / `connectionFlags` in `packages/cli/src/commands/flags.ts`) without updating the common-flags section is FAIL. A change to a root script (`bun run check`, the `e2e:*` family) without updating the "Working in the repo" section is FAIL. `packages/client/README.md` is the client's own reference, governed by the barrel exports in `packages/client/src/index.ts` and the `domain/` pair contract — adding, removing, or renaming a barrel export, or changing a documented ``/`Compact` sample so it diverges from the schema, without updating it is FAIL. `packages/cli/README.md` is a pointer to the root one for the npm page — a diff that grows it into a second command reference is FAIL. +16. **Fail-fast boundaries** — boundary code (JSON parsing, file I/O, HTTP response handling, stdin reads) must throw or return a typed error on malformed input. Diff that introduces silent coercion to `{}`/`[]`/`""`, `?? ""`/`?? 0`/`?? []`/`?? {}` to satisfy a type when the real meaning is "absent" or "error", empty `catch {}` blocks, or `try`/`catch` that swallows the error and returns a default — is FAIL. Quote the offending site. +17. **Magic literals** — diff introduces a numeric or string literal that already appears elsewhere in the repo without a shared constant (timeouts, byte caps, exit codes, profile names, account identifiers, file modes). Grep both packages for the literal before deciding; one occurrence is fine, two without a constant is FAIL. +18. **Boolean traps** — diff adds a function with 2+ boolean parameters, or a call site with 2+ positional boolean arguments where intent is non-obvious from the call. Use a named-options object or split functions. FAIL. +19. **Type-guard validity** — a function declared `(value): value is T` must validate the property that distinguishes `T`. `instanceof Error` narrowing to a more specific error subtype, or any guard whose body cannot rule out a non-`T` input, is a hidden cast and FAIL. +20. **Eloquence** — diff introduces ceremony that has no real-world counterpart. Stacked `override readonly` chains where a plain assignment would do, intermediate abstract classes that exist solely to deduplicate four lines, generic parameters that don't earn their complexity, option-bag wrappers around single-arg calls — FAIL. The test: would two engineers writing this from scratch both reach for this pattern? If no, it's overengineered. Quote the offending construct and a simpler alternative. +21. **Expression complexity** — diff introduces a big inline boolean or ternary chain in an `if`, `return`, or assignment. Multi-clause `&&`/`||` mixes, `?.` ladders combined with `??` fallbacks, or nested ternaries (`a ? x : b ? y : z`) inside a conditional are FAIL. Each meaningful clause should be a named local (`const hasBudget = …; const isFresh = …`). The condition in the `if` should read as one phrase, not a puzzle. Quote the offending site. +22. **Mocking discipline** — `vi.mock(...)` is a last resort. Diff introduces a module mock to test a one-line wrapper / one-line delegation, or to substitute logic owned by the system under test, is FAIL — that test asserts only what the mock was configured to return (a tautology). Acceptable: hermetic isolation of side-effecting external deps in integration tests where the real path pollutes the host (e.g. `@napi-rs/keyring`, real network, real subprocess), or fixture injection into a fully-exercised pipeline. Not acceptable: mocking an internal module so a unit test can fire a single branch — instead prefer a real value flowing through real imports (real `ZodError`, real `HttpError`, real Zod schema), or push the coverage to the smoke/integration tier. Prefer integration over isolation when in doubt. Quote any `vi.mock` call and the one-line wrapper it shadows. +23. **E2E CLI invocation discipline** — under `tests/e2e/**/*.e2e.test.ts`, any direct call to `execa`, `spawn`, `exec`, or `child_process` is FAIL — e2e tests drive the binary through `runCli` from `tests/e2e/run-cli.ts`. Inline `fetch(...)` from a test file is FAIL — credential setup belongs in `tests/e2e/setup/bootstrap.ts` and tests read state via `readBootstrap()`. +24. **Bootstrap-data unity** — the `Bootstrap` Zod schema (the `.bootstrap.json` shape) and the `BOOTSTRAP_FILE_PATH` constant must live only in `tests/e2e/bootstrap-data.ts`. Any redeclaration of the same shape elsewhere (typically in `tests/e2e/setup/bootstrap.ts`) is FAIL — writer and reader share one source. +25. **Test schema reuse** — when a production module exports a Zod schema describing CLI output, tests must import it from wherever it lives. Schemas that fall under this rule include: per-command output schemas (e.g. `LoginResult`, `AuthStatus`) and every `ListEnvelope` exported from `packages/cli/src/commands//list.ts` (built via `listEnvelopeSchema(Compact)` from `packages/cli/src/output/types.ts`), plus every `` / `Compact` in `packages/client/src/domain/`. A test file that declares a `z.object({...})` mirroring the same fields as a known export — including hand-rolling a list-envelope shape (`{ data, returned, offset, limit, total, has_more, next_offset, truncated }`) instead of importing the command's `ListEnvelope` — is FAIL. Quote both the test declaration and the canonical export. +26. **Helper reuse in tests** — test code reimplementing a helper the client already exports (e.g. inline `JSON.parse` + `Schema.parse` instead of `parseJson` from `@metabase/client/json`; `error.code === "ENOENT"` instead of `isFileNotFoundError` from `@metabase/client/errors`; deadline+sleep loops instead of `pollUntil` from `@metabase/client/poll`) is FAIL. Test files are allowed to import these across the layering and package boundaries because the layering rules apply to production source only. +27. **License-token discipline** — diff that introduces (a) a hardcoded real EE token (any string matching `^mb_(dev_[0-9a-f]{57}|[0-9a-f]{64})$` outside a clearly-fake placeholder like `mb_dev_0123…`), (b) `console.log` / `process.std*.write` / `Bash(echo $...)` / `Read(.env*)` of `MB_PREMIUM_EMBEDDING_TOKEN` or `METASTORE_DEV_SERVER_URL` or any binding holding their value, (c) writing the token to a file outside the test process's stdin pipe, (d) an assertion `toContain` / `toEqual` / `toBe` that compares against the token string is FAIL. Acceptable: `process.env["MB_PREMIUM_EMBEDDING_TOKEN"] === ""` for skip-gating; `runCli({ stdin: token })` to thread the token opaquely. Quote the offending site. ## Output format @@ -145,7 +236,7 @@ For each axis below, output `PASS`, `FAIL`, or `UNKNOWN`. For every `FAIL` and ` | # | Axis | Status | Detail | |---|------|--------|--------| | 1 | Layering | PASS | | -| 2 | Type strictness | FAIL | src/foo.ts:12 — `as unknown as Card` | +| 2 | Type strictness | FAIL | packages/cli/src/commands/card/get.ts:12 — `as unknown as Card` | | 3 | Output discipline | PASS | | ... ``` @@ -160,6 +251,6 @@ If FAIL, follow the table with a bulleted list of the next concrete actions to a ## Rules for the review itself - Read-only. Do not modify any files. -- Do not run `npm run check` or other deterministic gates here. Those are separate; this skill is the architectural / non-deterministic layer. +- Do not run `tsc`, `oxlint`, `oxfmt`, or the test suites here. Those are separate; this skill is the architectural / non-deterministic layer. - Do not green-light by default. If you cannot determine an axis from the diff, mark `UNKNOWN` with the reason — never PASS. - Be ruthless. The point of this skill is to be strict where the type-checker can't be. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1f7a8ca..7418b38 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,11 +5,18 @@ on: branches: [main] pull_request: +# Each gate is its own job so a red check names what broke, and each runs at the root manifest's +# declared floor. Whether the whole declared range holds is a different question, answered by +# test.yml's `toolchain` matrix. jobs: typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.19.5 + - run: node --version - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -20,6 +27,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.19.5 + - run: node --version - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -30,6 +41,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.19.5 + - run: node --version - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -40,7 +55,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: stbenjam/skillsaw@v0 + - uses: stbenjam/skillsaw@v0.17.0 with: - path: skill-data + path: packages/cli/skill-data strict: true diff --git a/.github/workflows/release-alpha.yml b/.github/workflows/release-alpha.yml index 30fa86c..82c0f48 100644 --- a/.github/workflows/release-alpha.yml +++ b/.github/workflows/release-alpha.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Node.js with npm registry uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "24" registry-url: "https://registry.npmjs.org" - name: Setup Bun @@ -35,7 +35,7 @@ jobs: - name: Compute alpha version and dist-tag id: version run: | - BASE=$(node -p "require('./package.json').version") + BASE=$(node -p "require('./packages/cli/package.json').version") BRANCH="${{ github.event.pull_request.head.ref }}" SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//') SHORT_SHA=$(git rev-parse --short HEAD) @@ -46,12 +46,14 @@ jobs: echo "Will publish @metabase/cli@$VERSION with dist-tag $TAG" - name: Set package version - run: npm version --no-git-tag-version --allow-same-version "${{ steps.version.outputs.version }}" + working-directory: packages/cli + run: npm pkg set version="${{ steps.version.outputs.version }}" - name: Build run: bun run build - name: Publish to NPM + working-directory: packages/cli run: npm publish --tag "${{ steps.version.outputs.tag }}" --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b4181f..91decfa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Node.js with npm registry uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "24" registry-url: "https://registry.npmjs.org" - name: Setup Bun @@ -33,7 +33,7 @@ jobs: - name: Check whether local version was already published id: check run: | - LOCAL=$(node -p "require('./package.json').version") + LOCAL=$(node -p "require('./packages/cli/package.json').version") LATEST=$(npm view @metabase/cli version 2>/dev/null || echo "none") ALREADY_PUBLISHED=$(npm view @metabase/cli versions --json 2>/dev/null | node -e " let raw = ''; @@ -55,6 +55,7 @@ jobs: - name: Publish to NPM if: steps.check.outputs.changed == 'true' + working-directory: packages/cli run: npm publish --tag latest --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0dc6b3d..7e29ace 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,12 +6,30 @@ on: pull_request: jobs: - test: + # The root manifest's `engines.node` is `^20.19.0 || >=22.12.0`, both boundaries pinned below + # alongside a current release. `bun run check`'s fifth step, `lint:skills`, is `uvx skillsaw` and + # cannot vary with Node, so it stays in lint.yml where the skillsaw action already runs it; the + # four steps here are the ones that load a Node binary. + toolchain: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: + - 20.19.5 + - 22.12.0 + - 24.12.0 steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - run: node --version - uses: oven-sh/setup-bun@v2 with: bun-version: latest - run: bun install + - run: bun run typecheck + - run: bun run lint + - run: bun run format:check - run: bun run test diff --git a/.gitignore b/.gitignore index 30992ee..daed204 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,8 @@ coverage/ plan .dev-state/ tests/e2e/.bootstrap*.json +tests/e2e/.gate-skips*.json tests/e2e/snapshots/*.sql -metabase-*.log \ No newline at end of file +metabase-*.log +*.tgz +.claude/worktrees/ \ No newline at end of file diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 55c15df..436e16d 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,4 +1,4 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": [] + "ignorePatterns": ["packages/cli/src/core/schema/data/**"] } diff --git a/.skillsaw.yaml b/.skillsaw.yaml index d87361d..ecda793 100644 --- a/.skillsaw.yaml +++ b/.skillsaw.yaml @@ -1,8 +1,11 @@ # skillsaw configuration — https://github.com/stbenjam/skillsaw -# Lints the skill collection under skill-data/. Pinned so a skillsaw -# release can't silently change token math and break CI. +# Lints the skill collection under packages/cli/skill-data/. The tool version is +# pinned at both call sites — `lint:skills` in package.json and the action tag in +# .github/workflows/lint.yml — so a skillsaw release can't silently change token +# math and break CI. This field records the version those two pins name; keep the +# three in step. -version: "0.11.4" +version: "0.17.0" rules: context-budget: diff --git a/CLAUDE.md b/CLAUDE.md index a3bd976..7ce8370 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,153 +1,98 @@ # CLAUDE.md -Metabase CLI. TypeScript ESM. citty + native `fetch` + Zod + @clack/prompts. oxlint + oxfmt. vitest. tsdown. +Metabase CLI and the `@metabase/client` package it is built on. TypeScript ESM. citty + native `fetch` + Zod + @clack/prompts. oxlint + oxfmt. vitest. tsdown. -## Hard rules +This file is rules only. Architecture and rationale live in `docs/architecture.md`; workflows live in the skills under `.claude/skills/`. -- No `as` type casts: `as X`, `as unknown as X`, `as never`, `as any`. Use type guards in `core/errors.ts`, or Zod `.parse` at boundaries. `as const` is allowed (it's a const assertion, not a cast — it narrows literal types without bypassing checking). -- No `any`. No `Record` for API responses — every cross-network value gets a named Zod schema in `src/domain/` and is parsed at the boundary. The sole exemption is `src/output/projection.ts`, the canonical structurally-untyped envelope for dot-path `--fields a,b.c.d` projection over already-validated payloads. -- No `!` non-null assertions. Restructure with helpers (see `core/config.ts` `pickField()` for the pattern). -- No inline object types in unions, returns, or function params. Always named via `interface` or `type`. -- No file extensions in imports (`./foo`, not `./foo.ts`). `import type` for type-only imports. -- No comments unless WHY is non-obvious. Never WHAT, never task/PR/path refs. -- Derive types from values when possible: `typeof X`, mapped types over `keyof typeof X`, or library generics (e.g. citty's `ParsedArgs`). A hand-written interface that mirrors a value's shape will drift silently — derive it. -- When a new helper subsumes an older narrower one, delete the older one in the same change. Don't leave both. -- Fail fast at boundaries. JSON parsing, file reads, HTTP responses must throw or return a typed error on malformed input — never coerce silently to `{}`/`[]`/`""` and continue. Empty `catch {}` is forbidden. Either handle the specific error or let it propagate; corruption that disappears is worse than corruption that crashes. -- No placeholder fallbacks for absent state. `?? ""` / `?? 0` / `?? []` / `?? {}` used to satisfy a type when the real semantic is "missing" or "error" hides bugs. Model absence with `null` or a discriminated union; if the value is required, require it. -- No magic literals. Numeric or string constants that recur (byte caps, timeouts, exit codes, profile names, file modes, account names) get named constants colocated with the canonical user. The same `process.exit(130)` in five files is five places to update. -- No boolean traps. A function with 2+ boolean parameters becomes a named-options object or splits into two functions. `foo(true, false)` at the call site is unreadable and easy to invert. -- Type guards must validate what they narrow. `function isFoo(value): value is Foo` must check the property that distinguishes `Foo`, not a weaker shared property. A guard that narrows on `instanceof Error` while claiming `is NodeJS.ErrnoException` is a hidden cast — callers will read `.code` off something that doesn't have it. -- Eloquence: prefer the simplest realistic expression. Don't stack ceremony — repeated `override readonly` modifiers, generic gymnastics, intermediate abstract classes, or option-bag wrappers — when a plain field, an early return, or a non-generic shape is shorter and clearer. If two real-world engineers wouldn't both reach for the pattern, don't write it. Idiomatic > technically-pristine. -- No big inline expressions. `if (a && b && (c.x?.y ?? 0) > Z || isFooBar(d))` is noise. Split into semantically-named locals (`const hasBudget = …; const isFresh = …; if (hasBudget && isFresh) …`). Same for ternary chains — flatten with early returns, guard clauses, or a small lookup. The conditional in an `if`/return should read as one phrase, not a puzzle. -- We do not duplicate auth resolution for SOURCE/TARGET. Use profiles. Multi-instance commands take `--from-profile` / `--to-profile`, each routed through the same `resolveConfig`. There is no `MB_SOURCE_*` env-var family, no parallel `getSourceClient`, no shadow flag set. Reads of `MB_URL` / `MB_API_KEY` belong in `core/config.ts` only, and go through `core/env.ts` `readEnv` (never a raw `process.env[...]`). -- Tests import production Zod schemas from `src/`; they never redeclare them. `LoginResult` (`src/commands/auth/login.ts`), `AuthStatus` (`src/commands/auth/status.ts`), every `` / `Compact` in `src/domain/`, and every `ListEnvelope` in `src/commands//list.ts` is THE contract — copying the shape into a test creates silent drift the type-checker can't catch. -- Compact projections MUST chain `.strip()` after `.pick()`: `.pick({...}).strip()`. Zod 4's `.pick()` on a `.loose()` parent inherits the loose catchall — without `.strip()` the projection silently passes every API field through. The bug is invisible until you look at the rendered `--json` output and see fields you never picked. This applies to every `Compact` in `src/domain/` and any other `pick()` derived from a `.loose()` schema. -- Tests reuse `src/runtime/` and `src/core/errors` helpers (`parseJson`, `pollUntil`, `isNotFoundError`, `errorMessage`) instead of reimplementing `JSON.parse` + Zod, sleep+deadline loops, or ENOENT shape checks. Tests are code; the layering rules don't bite, but the duplication and drift rules do. - -## Layout - -- `src/cli.ts` — `runMain` entry. -- `src/main.ts` — root citty command, lazy `subCommands`. -- `src/commands/` — CLI shell only. No HTTP, no parsing, no formatting. -- `src/core/` — pure logic, no CLI deps. - - `auth/` — credential storage + verify, plus the OAuth login flow: `credential.ts` (discriminated `Credential` union), `pkce.ts`, `callback-server.ts` (loopback redirect), `oauth-login.ts` (orchestration), `oauth-session.ts` (refresh/revoke). - - `env.ts` — `readEnv(canonical)` resolves a CLI env var by its canonical `MB_` name, falling back to the deprecated `METABASE_` alias and recording the legacy hit so the command shell warns once per run (`consumeLegacyEnvWarnings`, flushed in `commands/runtime.ts`). Every CLI env-var name (`MB_URL`, `MB_API_KEY`, `MB_PROFILE`, `MB_VERBOSE`, `MB_CLI_SKIP_PREFLIGHT`, `MB_CLI_DISABLE_KEYRING`) is a const here; reads go through `readEnv`, never raw `process.env[...]`. - - `config.ts` — flag → env → stored resolver. Profile-aware (`resolveProfileName`, `resolveConfig`). Credential/profile env reads (`MB_URL`/`MB_API_KEY`/`MB_PROFILE`) live here, via `core/env.ts`. - - `errors.ts` — `isNotFoundError`, `errorMessage` (Node error type guards used outside the HTTP boundary). - - `http/` — the HTTP boundary. `client.ts` wraps native `fetch` with `requestParsed(schema, path, opts)` (the ONLY typed-JSON path), `requestRaw`, `requestStream`. Retries are idempotency-aware: GET/HEAD/OPTIONS retry on retryable status codes by default; POST/PUT/PATCH/DELETE never retry on status (only on network/timeout). Callers may override via `RequestOptions.idempotent`. `errors.ts` owns the discriminated `MetabaseError` taxonomy and `toMetabaseError(unknown)`. `sanitize.ts` runs at `HttpError` construction — secret redaction is not optional. `retry.ts` is the backoff math; it is also the only `core/http/` site allowed to drive a `setTimeout`-based wait loop (via `node:timers/promises`) outside `src/runtime/poll.ts`. `oauth.ts` is the OAuth protocol boundary (RFC 8414 discovery with same-origin endpoint pinning, dynamic client registration, token exchange/refresh/revocation); its schemas are protocol envelopes, not `src/domain/` resources. Nothing outside this directory may import a third-party HTTP library or call `fetch` directly; this is enforced by `tests/structure.test.ts`. - - `url.ts` — `normalizeUrl`, `displayUrl`, `assertEndpointOrigin`. The single permitted home for `new URL(...)` outside `src/core/http/**`; the URL helpers belong here, not at call sites. Base URLs may carry a subpath (`https://my.org.com/metabase`) — never reduce a stored instance URL to its origin, and always join request paths by concatenation, not `new URL(path, base)`. -- `src/domain/` — one file per Metabase resource; Zod schema + inferred type co-located. See **Domain pattern**. -- `src/output/` — presentation; takes typed values. -- `src/runtime/` — platform glue (stdin, poll). - - `input.ts` — `readInput({ flag, file, stdin, positional, required })` resolves a body-shaped input. Precedence: `flag > file > stdin > positional`; first non-empty source wins. Stdin reads when `stdin: true` or when `process.stdin.isTTY` is false (auto-detect for pipes). `required: true` (default) throws `ConfigError` when every source is empty. Stdin reading lives only here. - - `body.ts` — `readBody(sources, schema)` chains `readInput` + `parseJson` + Zod validation for JSON bodies. Rejects multiple explicit body sources (`--body` + `--file` + `--stdin` + positional) with `ConfigError`; only one wins. - - `paginate.ts` — `paginate(client, path, itemSchema, opts)` is the canonical limit/offset iterator over Metabase list endpoints; returns `AsyncIterable`. Honors `commonFlags.limit` via `opts.max`; defaults pageSize to 50 (Metabase server default). `collectPaginated` drains it into an array. -- `tests/` — see **Tests** and **E2E test tier**. Unit tests sit beside source under `src/**/*.test.ts`. The e2e tier lives under `tests/e2e/` with its own runtime contract. -- `bin/mb-dev` — contributor wrapper running the CLI from source against an isolated `XDG_CONFIG_HOME=$ROOT/.dev-state` with `MB_CLI_DISABLE_KEYRING=1`. Use this — never the real `~/.config` — when poking at the running e2e Metabase by hand. - -## Commands runtime - -- `src/commands/runtime.ts` — `defineMetabaseCommand({ meta, args, run })` is the canonical command shell. It merges `commonFlags` into `args` (callers add only their extra flags), parses `args` through `resolveCommonFlags` to build `ctx`, and exposes a lazy `getClient()` that runs `resolveConfig` + `createClient` on first call (cached). Use it instead of `defineCommand` directly. Pass `args: {}` when a command adds no extra flags. -- **Capabilities + preflight.** The minimum supported server is **Metabase v0.58**. Every command declares `capabilities: { minVersion, tokenFeature? }` (`minVersion` is the bare Metabase major integer like `58`, not semver). Baseline is `{ minVersion: 58 }` and is treated as "no gating" (no probe, no enforcement). Commands that never touch a Metabase server (e.g. `uuid`, `upgrade`) declare `capabilities: null` so JSON help (`--help --json`) reports no version requirement rather than a misleading baseline — don't fake a baseline for a local command. Annotate every command explicitly (a `{...}` or `null`); uniformity keeps the reported surface honest. The server version and token-features are probed once on `auth login`/`auth list` and cached in the profile record; For non-baseline commands `getClient()` runs a preflight against that cache and throws `CapabilityError` (exit `2`) on a version/feature mismatch, or warns and proceeds when the version is unknown; baseline and `null` commands never preflight. `--skip-preflight` (per-invocation) or `MB_CLI_SKIP_PREFLIGHT=1` (process-wide) bypasses the check. To find the right `minVersion`/feature for a new endpoint, validate against `../metabase` at `origin/release-x.58.x` (route file `src/metabase/api_routes/routes.clj`, EE routes `enterprise/backend/src/metabase_enterprise/api_routes/routes.clj`); token-feature keys are the underscored map keys in `src/metabase/premium_features/settings.clj` (e.g. `remote_sync`, `transforms`). -- `src/output/prompt.ts` — `promptText` / `promptPassword` / `promptConfirm` / `promptSelect` wrap `@clack/prompts`. They throw `AbortError` on user cancel and `ConfigError` when stdin is not a TTY. Commands import these instead of `@clack/prompts` directly so the cancel-to-`AbortError` pathway is funneled in one place. +## Types -## Domain pattern +- No `as` casts (`as X`, `as unknown as X`, `as never`, `as any`). Use type guards or Zod `.parse` at boundaries. `as const` is fine. +- No `any`. No `Record` for API responses — every cross-network value gets a named Zod schema in the client's `domain/`, parsed at the boundary. Sole exemption: `packages/cli/src/output/projection.ts`. +- No `!` non-null assertions. Restructure with a helper returning a named result-or-`null` interface, as `packages/cli/src/core/config.ts` does with `resolveUrl` / `resolveCredential`. +- No inline object types in unions, returns, or params. Name them via `interface` or `type`. +- Derive types from values (`typeof X`, mapped types over `keyof typeof X`, citty's `ParsedArgs`). A hand-written interface mirroring a value's shape drifts silently. +- Type guards must check the property that distinguishes what they narrow, not a weaker shared one. -Every Metabase API resource lives in `src/domain/.ts` (`card.ts`, `dashboard.ts`, `database.ts`, …) and exports exactly three things per resource: +## Code -1. **``** — `z.object({ ... }).loose()` for the full API shape; type aliased via `export type = z.infer>`. `.loose()` is the default so Metabase API additions don't break us; tighten over time. (Zod 4 — `.passthrough()` is deprecated.) -2. **`Compact`** — `.pick({ ... }).strip()` projection for list output and agent-facing JSON; type aliased the same way. The trailing `.strip()` is mandatory — without it the picked schema inherits the loose catchall and stops projecting. The compact view is the agent-facing contract. -3. **`View`** — `ResourceView` with `compactPick: Compact` and `tableColumns: ColumnDef[]`. Consumed by `src/output/render`; never inline a column list in a command. - -**Schema scope: trim to what the agent needs.** `src/domain/` is not a mirror of the frontend type — it's the agent-facing contract. Pick fields that drive query writing or content selection (id, name, FK targets, base/semantic types, descriptions). Drop sync flags, fingerprints, JSON-unfolding metadata, `last_analyzed`/`created_at`/`updated_at`, and other internal plumbing. `.loose()` keeps the door open for fields the agent doesn't read; the schema's job is to declare what's required and what's typed. - -**Pin closed enums.** When the frontend types a field as `string | null` but the backend enumerates it (Clojure `(derive :namespace/X :namespace/*)` hierarchies define the closed set), pin the schema to `z.enum([...])` over those values. Closed enums give agents a typed surface and make new server values land as a hard parse failure (a signal we can act on) rather than a silent string. To find the closed set, grep the backend for `derive` declarations under the resource's keyword namespace. - -Naming: `` is PascalCase (`Card`, `CurrentUser`); `Compact` follows; `View` is camelCase (`cardView`, `userView`). The token `` is the resource name, not the file name — a single file may host multiple resources (e.g. `domain/user.ts` exports `CurrentUser`/`CurrentUserCompact`/`userView`), and the three-export contract holds per-resource. +- No file extensions in imports (`./foo`, not `./foo.ts`). `import type` for type-only imports. +- No comments unless the WHY is non-obvious. Never WHAT, never task/PR/path refs. This applies to prose docs too — describe the end state, never the change to it. Delete on sight: "now", "no longer", "previously", "used to", "the new …", "moved to", "renamed". +- Fail fast at boundaries. JSON parsing, file reads, HTTP responses throw or return a typed error on malformed input. Empty `catch {}` is forbidden. +- No placeholder fallbacks for absent state. `?? ""` / `?? 0` / `?? []` / `?? {}` to satisfy a type when the semantic is "missing" hides bugs — model absence with `null` or a discriminated union. +- No magic literals. Recurring constants (byte caps, timeouts, exit codes, file modes) get a named constant colocated with the canonical user. +- No boolean traps. 2+ boolean params becomes a named-options object or two functions. +- No big inline expressions. Split into semantically-named locals; flatten ternary chains with early returns or a lookup. +- Prefer the simplest realistic expression. No ceremony — repeated `override readonly`, generic gymnastics, intermediate abstract classes — where a plain field or early return is clearer. +- When a new helper subsumes an older narrower one, delete the older one in the same change. +- An `export` in `packages/cli` needs an importer outside its own file; a `*.test.ts` importer counts. The CLI ships as a bundled binary with no `exports`/`main`/`types`, so a symbol nothing imports is exported for nobody. `packages/client` is exempt — its `exports` map makes any named module public surface. +- Never fake green. No `oxlint-disable`, no `@ts-expect-error`, no `.skip`/`.todo` on a test that used to run, no weakening an assertion to match wrong output, no silently narrowing scope. If it can't pass honestly, stop and report the blocker. -Adding a resource is purely additive: drop in `src/domain/.ts`. Never edit an existing command to wire schemas — commands import what they need. +## Layout -**Don't write tautological schema-parse unit tests.** A test that does `expect(Schema.parse(fixture)).toEqual(fixture)` only proves Zod works — it has no signal about whether the schema matches a real Metabase response. The schema's correctness is a contract test, and the contract test lives in the e2e tier where `parseJson(stdout, Schema)` runs against output produced by the live API. If your domain file doesn't yet have an e2e command driving it, that's fine — land the schema additively and let the first command that consumes it pull it through e2e. Don't paper over the gap with a fixture round-trip. +| Path | What | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `packages/client` | Private Metabase API client: Zod `domain/` schemas, `resources/` methods, `http/` boundary, OAuth, version/capabilities | +| `packages/cli` | Publishable CLI: `commands/` (shell only), `core/` (pure logic), `output/` (presentation), `runtime/` (platform glue) | +| `tests/e2e` | Built-binary tier against a live Metabase | + +- Nothing in `packages/client` may import from `packages/cli`, touch `process`, or mutate process-global state. Its dependency budget is `zod` (peer) + `semver` + `node:` builtins. +- Within `packages/client`, only `client.ts` and `resources/` may import from `resources/`. The CLI may `import type` from the resource subpaths the `exports` map publishes. +- `JSON.parse` only in `json.ts`. `fetch` only in `http/` (plus `packages/cli/src/core/npm-registry.ts`, which is not a Metabase endpoint). `new URL(` only in `url.ts` and `http/`. Within `packages/client`, a `setTimeout` wait loop only in `poll.ts` and `http/retry.ts`; in `packages/cli`, a hand-rolled one nowhere — wait on `node:timers/promises`. +- `process.exit` only in the CLI entry; `process.stdout.write` only in `output/`; `child_process` only in `runtime/process.ts`. `src/output/` must not import the HTTP layer — reach for `@metabase/client/errors`. +- TLS trust is the host application's to configure, so `node:tls` appears nowhere in `packages/client`; the CLI opts in for itself via `core/system-ca.ts`. +- Every `MB_` env var name is a const in `packages/cli/src/core/env.ts` and is read through `readEnv`, never raw `process.env[...]`. `MB_URL` / `MB_API_KEY` are read in `core/config.ts` only. +- Multi-instance commands use profiles (`--from-profile` / `--to-profile`) routed through the same `resolveConfig`. No `MB_SOURCE_*` family, no parallel client getter. +- A directory earns subfolders at ~8–10 files **and** 2+ concern-clusters. Single-role directories (`domain/`, `commands//`) stay flat at any size. Never create a folder for one file. +- Never add: a catch-all module or bucket directory (`api.ts`, `schemas.ts`, `lib/`, `_shared/`, `_utils/`, `common/`, `misc/`); a third-party HTTP library; a dotenv parser (use `--env-file`); a color library; a dependency for a one-off helper. -## List-output envelope +## Commands -List commands wrap their items in a `ListEnvelope` (`{ data, returned, total?, limit?, truncated? }`) produced by `src/output/render.ts`. Each `src/commands//list.ts` exports its envelope schema as a named const: +- Use `defineMetabaseCommand({ meta, args, capabilities, run })` from `commands/runtime.ts`, never citty's `defineCommand`. Spread the shared flag sets from `commands/flags.ts` (`outputFlags`, `profileFlag`, `connectionFlags`, plus `listFlags` for a list command). A new global flag must also be added to `GLOBAL_FLAG_ARGS` in `commands/global-flags.ts` or it will not survive hoisting. +- Commands call the client, never the wire. An `/api/` path literal or a `requestParsed` / `requestRaw` / `requestStream` / `paginatePages` call inside `src/commands/` belongs in `resources/`. Both rules are absolute and carry no allowlist. The one sanctioned request a command issues by name is `tryDiscoverMetadata` in `auth/login.ts`, which runs before there is a transport to hang it on. +- Every command declares `capabilities` explicitly: `{ minVersion }` (bare Metabase major, e.g. `58`) and/or `{ tokenFeature }`, `{}` for the v58 baseline, or `null` for a command that never reaches a server. The field is required, so an omission is a compile error. Baseline and `null` commands never preflight. +- Import prompts from `output/prompt.ts`, never `@clack/prompts` directly — that is where cancel becomes `AbortError`. +- Anything that can block takes `interruptSignal` explicitly: client construction, every `WaitSchedule`, any long-running fetch. -```ts -import { listEnvelopeSchema } from "../../output/types"; -import { DatabaseCompact } from "../../domain/database"; +## Schemas -export const DatabaseListEnvelope = listEnvelopeSchema(DatabaseCompact); -``` +Every resource lives in `packages/client/src/domain/.ts` and exports exactly two things: -The export is consumed in two places: as the command's `outputSchema` (so `--help --json` documents the actual list shape, not just the per-item resource), and by the matching e2e test (`parseJson(stdout, DatabaseListEnvelope)` — never redeclared). The `listEnvelopeSchema` factory lives in `src/output/types.ts` next to the `ListEnvelope` interface, with an explicit `ZodType>` return type so the runtime type and parse schema can't drift. +1. **``** — `z.object({...}).loose()`, type aliased via `z.infer`. Loose so API additions don't break us. +2. **`Compact`** — `.pick({...}).strip()`. The trailing `.strip()` is mandatory: Zod 4's `.pick()` inherits a `.loose()` parent's catchall and silently passes every field through without it. -Forbidden in commands: typing a response as `Array>`, `any`, or any inline `as { ... }` shape cast on a nested API field. The Zod schema is the single source of truth; downstream code consumes `z.infer`. +- Trim to what an agent needs: ids, names, FK targets, base/semantic types, descriptions. Drop sync flags, fingerprints, timestamps, internal plumbing. +- Pin closed enums with `z.enum([...])` where the backend enumerates the values, so a new server value fails loudly instead of passing as an untyped string. +- Presentation is the CLI's: the terminal binding is `View` in `packages/cli/src/output/views/.ts`. Never inline a column list in a command; never put a `ColumnDef` on the client surface. +- Resource methods (`packages/client/src/resources/.ts`) are the only layer naming an `/api/` path. Path params positional, then params, then options; params use Metabase's own field names verbatim with no mapping layer; transport concerns (`signal`, `timeoutMs`, `retries`) only in the trailing options; wire envelopes stay module-private; methods return domain values, and a non-paginated list returns `ListResult`; every string path param goes through `encodeURIComponent`; every method carries the endpoint's description as a doc comment. +- List commands wrap items in `ListEnvelope` via one of `windowList` / `windowServerPage` / `collectForOutput` (`packages/cli/src/output/window.ts`), and export the envelope schema as a named const used both as the command's `outputSchema` and by its e2e test. `has_more` reports what the walk observed — only a source that ran dry may report `false`, and a server count never overrides rows already in hand. `has_more: true` carries a `next_offset` greater than `offset`, or `null` when `--max-bytes` left no room for a single row — an empty window has nowhere to resume from, so it reports the rows that remain and offers no offset to repeat. +- The client is embeddable: no message it produces may name the CLI, an `mb` command, or a CLI flag. ## Tests -- Test code is code. Same hard rules apply: no `as`, no `any`, no `!`, no `Record`, no inline types, no WHAT-comments, named types for fixtures. -- Layering & boundary rules in `tests/structure.test.ts` apply to production source only; `*.test.ts` files are exempt and may import across layers to construct realistic inputs (e.g. `output/help.test.ts` importing `defineMetabaseCommand` from `commands/runtime`). -- Tests must verify real behavior. No tautologies. No tests that re-encode the implementation. No tests that pass regardless of the system under test. -- Assertions must be full. Prefer `toEqual()` / `toEqual()` over poking individual properties. If you assert deep equality of a structure, that single assertion subsumes shape, length, and contents — don't add a separate length or property check before or after it. -- Field-by-field `toBe(...)` against a parsed object is a code smell: it leaves untested fields invisible, and a regression that flips an unchecked field passes silently. The fix is `toEqual({ ...full expected... })`. The only legitimate exception is when one specific field is genuinely the only thing under test (e.g. asserting an idempotent retry didn't bump `updated_at`) — and then the test name should say so. -- Exact exit codes only. `expect(result.exitCode).toBe()` — never `.not.toBe(0)`. The CLI's exit-code taxonomy is fixed (see `src/core/errors.ts`: `ConfigError`=2, `CapabilityError` (`src/core/version/preflight-error.ts`)=2, `AbortError`=130, everything else=1); a test that says "non-zero" doesn't distinguish "the right error fired" from "any failure at all" and lets a `ConfigError` regression hide behind an `HttpError` test (or vice versa). If you don't know which code is right, look up the error class. -- Exact error strings only. Prefer `toContain("")` or `toBe("")` over `toMatch(/.../i)`. Regexes hide which message actually fired and let a refactor that swaps "Not found." for "Endpoint missing" pass without notice. When the message contains a dynamic value (a byte count, a generated id, a path), build the expected string from the same source the production code used and assert with `toBe`/`toContain` — never paper over the dynamic part with `.*` or `\d+`. The narrow exception is asserting _absence_ of a pattern (`expect(out).not.toMatch(/pattern/)`), where the regex is load-bearing. -- One concept per test. Don't bundle unrelated assertions. Don't add belt-and-suspenders assertions that overlap. -- No fixtures or mocks beyond what the test exercises. Fixture values used by zero assertions are noise — drop them or use a real value. -- When asserting an error, assert the error type AND the exact message slice — not just "throws", not a regex match. -- `vi.mock(...)` is a last resort, not a default. Module mocks substitute the implementation under test for one you wrote in the test file — the assertion collapses to "the mock returned what I configured." Prefer real values flowing through real imports: construct a real `ZodError` / `Error` / `HttpError` and pass it; build a real Zod schema and `.parse` against it. If a branch only fires on a third-party private symbol or live process state, do not unit-test the branch — cover it in the smoke/integration tier where the real path runs end-to-end. Acceptable uses of `vi.mock`: (a) hermetic isolation of side-effecting deps in integration tests where the real thing pollutes the host (e.g. `@napi-rs/keyring` writing to the real OS keychain), (b) test fixtures injected into a fully-exercised pipeline. Not acceptable: mocking a one-line wrapper to make it "testable in isolation" — that is a tautology dressed as a test. +- Test code is code: the type rules above apply in full. +- Tests import production schemas from source and never redeclare them, and reuse the client's helpers (`parseJson`, `pollUntil`, `isFileNotFoundError`, `errorMessage`) rather than reimplementing them. +- Assertions are full and exact: `toEqual()` over field-by-field `toBe`; exact exit codes (`toBe(2)`, never `.not.toBe(0)`); exact error strings (`toContain("…")` / `toBe("…")`, never `toMatch(/…/i)`). When asserting an error, assert the type AND the exact message slice. +- One concept per test. No tautologies, no tests that re-encode the implementation, no fixture values used by zero assertions. Never `expect(Schema.parse(fixture)).toEqual(fixture)` — schema correctness is proven in e2e against live output. +- `vi.mock` is a last resort. Acceptable only to isolate a side-effecting dep that pollutes the host (e.g. the OS keyring) or to inject a fixture into a fully-exercised pipeline. Never to make a one-line wrapper "testable in isolation". +- Layering rules apply to production source only; `*.test.ts` may import across layers. -## Test tiers +| Tier | Glob | Network | When | +| ------ | ----------------------------- | ------------- | ------------------- | +| `unit` | `packages/*/src/**/*.test.ts` | none | every source change | +| `e2e` | `tests/e2e/**/*.e2e.test.ts` | live Metabase | every new command | -| Tier | Glob | Wall-clock | Network | When to add | -| ------ | ---------------------------- | --------------- | ------------- | ------------------- | -| `unit` | `src/**/*.test.ts` | <1 s/file | none | every `src/` change | -| `e2e` | `tests/e2e/**/*.e2e.test.ts` | seconds–minutes | live Metabase | every new command | +E2E invariants — the full contract is in `.claude/skills/add-e2e-test`: -Local prerequisites for e2e: `bun run e2e:up && bun run e2e:bootstrap` (~1 minute, run once a day). Then `bun run test:e2e` per iteration. `bun run e2e:down` wipes volumes. +- `runCli` is the only way to invoke the CLI. Never call `execa` / `child_process`, never `fetch` Metabase directly. +- Never hard-code an entity id or an API key. Read ids from `SEEDED`, credentials via `readBootstrap()`. +- State does not persist between tests — each opens on the restored snapshot. Capturing a snapshot is the bootstrap's alone. +- A gated suite passes a `lane` label to `requireServer(lane, {...})` / `requireOAuthServer(lane)`, so a skipped lane is reported rather than counted as passing. +- Never read or print the EE license token (`MB_PREMIUM_EMBEDDING_TOKEN`). To check whether a token-gated lane runs, test the env var for `undefined` — never its value. -## E2E test tier +## Gate -Lives under `tests/e2e/`. The whole point is to run the **built `dist/cli.mjs`** against a real Metabase via docker compose, with no mocks. - -- `tests/e2e/run-cli.ts` — `runCli({ args, configHome, env, stdin, timeoutMs })` is the ONLY way an e2e test invokes the CLI. It spawns `node dist/cli.mjs` via `execa`, with an isolated `XDG_CONFIG_HOME` (per-call temp dir by default), `MB_CLI_DISABLE_KEYRING=1`, and stripped env (no inherited `MB_*`/`METABASE_*` from the developer's shell). Tests never call `execa`/`child_process` directly; never call `fetch` against Metabase (that's bootstrap's job). -- `tests/e2e/bootstrap-data.ts` — sole owner of the `Bootstrap` Zod schema and the stack-scoped `BOOTSTRAP_FILE_PATH` (`.bootstrap..json`). The schema carries `seeded` (entity ids the bootstrap **discovers** — warehouse db/collection/card/dashboard/dashcard plus warehouse table & field ids resolved by name, never pinned) and `server` (the probed `{ version, tokenFeatures }`). The writer (`tests/e2e/setup/bootstrap.ts`) imports this schema; do not redeclare it. Tests read admin creds via `readBootstrap()` (async); they read seeded entity ids via the `SEEDED` const in `tests/e2e/seed/seeded.ts` (a sync `seededIds()` read, mirroring Metabase's own `cypress_sample_instance_data` pattern) — never hard-code an entity id, never invoke the setup wizard themselves, never hard-code an API key. -- `tests/e2e/setup/bootstrap.ts` — standalone script invoked by `bun run e2e:bootstrap` and by `tests/e2e/setup/global-setup.ts`. Idempotent: reuses `.bootstrap..json` when the stored key still authenticates, otherwise calls `/api/setup` (or logs in directly if already setup), mints a fresh admin API key, discovers seeded ids, and probes the server. The Metabase HTTP responses it parses are setup-only — their schemas live colocated here, not in `src/domain/`. -- `tests/e2e/setup/global-setup.ts` — vitest globalSetup. Verifies `dist/cli.mjs` exists, then spawns `bootstrap.ts` once per `bun run test:e2e`. -- `tests/e2e/defaults.ts` — sole owner of `DEFAULT_E2E_BASE_URL`/`resolveE2EBaseUrl()` (reads `METABASE_CLI_E2E_URL`), `DEFAULT_E2E_STACK`/`resolveStackId()` (reads `METABASE_CLI_E2E_STACK`, default `default`), and `resolveSnapshotName()` (`cli_`). Anything needing a base URL, stack id, or snapshot name imports from here. -- `tests/e2e/server-gate.ts` — `requireServer({ minVersion?, tokenFeature? })` returns a skip reason (or `null`) by feeding the persisted `server` block through the production `checkCapabilities`. Suites whose commands declare non-baseline capabilities self-skip via `describe.skipIf(requireServer(...) !== null)` (measure, transform, transform-job → v59; git-sync → v60 + remote_sync). This is how a lane "passes or skips" rather than failing on a server that can't satisfy the command. -- `tests/e2e/docker-compose.yml` — Postgres warehouse + Metabase (image via `METABASE_E2E_IMAGE`, host port via `METABASE_E2E_PORT`, project/volume namespaced by the runner's `-p mb-e2e-`). Token override via `MB_PREMIUM_EMBEDDING_TOKEN` env passes through; absence is fine — EE boots without a token, and token-gated suites skip themselves. -- `scripts/e2e-matrix.ts` (`bun run e2e:matrix`) — runs the suite against the version/edition matrix (oss/ee × 58–61 + oss/ee head), each in an isolated stack (own project, port, app-db volume, `.bootstrap..json`, `cli_.sql`). `--stack=` runs one; no flag runs all sequentially; `--parallel[=N]` runs N at a time. CI (`.github/workflows/e2e.yml`) runs one matrix job per stack with `fail-fast: false`. - -Adding a new e2e test for command `mb `: - -1. `tests/e2e/.e2e.test.ts` — drive the command via `runCli`, assert exit code, assert `--json` output through the schema imported from `src/commands//.ts` or `src/domain/.ts`. Each test gets its own config home via `mkTempConfigHome()` (or a small `makeIsolatedConfigHome` closure inside the file that pushes onto a `tempDirs` array drained in `afterEach`). -2. The seeded admin API key (`bootstrap.adminApiKey`) authenticates as a synthetic api-key user (`api-key-user-…@api-key.invalid`). For tests that need a real admin user, run the in-process OAuth login harness (`tests/e2e/setup/oauth-harness.ts` `consentingBrowser` with `bootstrap.admin`, OAuth-capable servers only — see `oauth.e2e.test.ts`) — but expose that need explicitly; don't paper over it. The harness is also the only sanctioned home for raw `fetch` against Metabase from the oauth suite. -3. Never mutate snapshot state in tests. Snapshot/restore (`/api/testing/*`) is reserved for the bootstrap script. - -Adding a new field to `.bootstrap.json`: - -- Update the `Bootstrap` schema in `tests/e2e/bootstrap-data.ts`. The writer in `setup/bootstrap.ts` consumes the same schema for its parameter type — drift is mechanically prevented. - -Running e2e — the suite is slow (~3–5 minutes for a full run, ~hundreds of ms to seconds per test): - -- **Plan for failures on the first run.** Capture the full output end-to-end the first time so every failure is in hand before deciding next steps. Pipe to a file when the buffer is unreliable: `bun run test:e2e 2>&1 | tee /tmp/e2e.log`. Vitest's per-file failure summary at the end is the authoritative list — read it before rerunning. Rerunning the full suite "to see what failed" is a 3-minute round-trip; don't do it. -- **Iterate on one file.** During development, scope to the file under change: `bun run test:e2e tests/e2e/.e2e.test.ts` (seconds, not minutes). Run the full suite only as the closing pre-merge check, not during back-and-forth. -- **The stack persists.** `e2e:up` is idempotent and the bootstrap reuses `.bootstrap..json` when the stored key still authenticates, so iteration cost is dominated by test execution, not setup. Don't `e2e:down` between iterations. -- **Bail-by-default is off.** Vitest runs all e2e files even after a failure, on purpose — the full failure inventory is more valuable than fast-fail when each rerun costs minutes. Don't pass `--bail=1` unless you genuinely want to abort early; you'll lose signal you'd have to rerun to recover. - -## Don't - -- Add a color library or any colored output. -- Reintroduce a catch-all module or bucket directory: `api.ts`, `schemas.ts`, `lib/`, `_shared/`, `_helpers/`, `_utils/`, `common/`, `misc/`. Name files by purpose, not by "where shared things live." -- Add a third-party HTTP library (`axios`, `got`, `node-fetch`, `undici`, etc.). `src/core/http/` is the HTTP boundary; it wraps native `fetch` (Node ≥ 20.6) with our project-specific contract (`requestParsed(schema)`, `HttpError`-with-sanitization-at-construction, idempotency-aware retries, `expectContentType` enforcement). Extend that module instead of importing a library — every off-the-shelf client would need to be wrapped to satisfy our contract anyway, and the wrapping is more code than the current implementation. -- Write a dotenv parser. Use Node's native `--env-file` (Node ≥ 20.6). -- Add deps for one-off helpers — inline. -- Read or print the EE license token. The dev token is supplied to the e2e stack via `MB_PREMIUM_EMBEDDING_TOKEN` (Metabase's own env var name), and the dev token-check URL via `METASTORE_DEV_SERVER_URL` (also Metabase's name; honored only when `MB_RUN_MODE=dev`, which the compose file already sets). Both flow shell → docker compose → JVM to enable EE features (transforms, measures, remote-sync) on the booted server. Never `cat`/`Read` `.env`/`.envrc`/shell rcs that may contain them, never `echo $MB_PREMIUM_EMBEDDING_TOKEN`, never `console.log` or `expect(...).toContain(token)`. To check whether a token-gated suite will run, inspect `process.env["MB_PREMIUM_EMBEDDING_TOKEN"] === undefined` — never the value. - -## Commands +`bun install` (npm is too old). The whole gate is `bun run check` — `tsc --noEmit`, `oxlint`, `oxfmt --check`, `bun run test`, `lint:skills`, in that order, stopping at the first failure. `bun run check:fix` applies the fixing variants. -Install: `bun install` (npm 7 too old). Typecheck `npx tsc --noEmit`. Lint `npx oxlint`. Format `npx oxfmt` (`--check` to verify). Build `npx tsdown`. +Nothing is done until `bun run check` passes. A change touching a command's behaviour isn't done until its e2e lane passes too (`bun run build && bun run e2e:up && bun run e2e:bootstrap`, then `bun run test:e2e`). -Tests: `bun run test` (unit), `bun run test:e2e` (e2e — requires `bun run build && bun run e2e:up && bun run e2e:bootstrap` first). Iteration: `bun run test:watch` for unit, `bun run test:e2e:watch` for e2e (Metabase stays up, restart not needed between runs). Stack control: `bun run e2e:up`, `bun run e2e:down`, `bun run e2e:logs`. +Never name the gate after an npm lifecycle hook (`prepare`, `postinstall`) — bun runs those during `bun install`. diff --git a/README.md b/README.md index 8646c50..a542490 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # metabase-cli -Command-line client for Metabase. Logs in to an instance in your browser (OAuth, Metabase v62+) or with an API key, and stores credentials securely on your machine. +Command-line client for Metabase. Logs in to an instance in your browser (OAuth, Metabase v63+) or with an API key, and stores credentials securely on your machine. ## Supported Metabase versions @@ -9,7 +9,7 @@ The minimum supported server is **Metabase v0.58** (major `58`). Anything older Commands that need more than a baseline OSS server declare it — a higher minimum major version or a premium token feature. The server version and token features are detected and cached when you run `mb auth login` (or `mb auth list`). For those commands, a preflight check runs before the first request and refuses with an actionable message (exit code `2`) when: - the server is older than the command's minimum version, or -- the command needs a premium feature (e.g. `remote_sync`, `transforms`) that isn't enabled. +- the command needs a premium feature (e.g. `remote_sync`, `library`) that isn't enabled. Plain OSS commands against a v0.58+ server (the majority) carry no elevated requirement and skip the preflight entirely. When a gated command runs but the server version can't be detected (no cached probe), it proceeds with a warning rather than refusing. To bypass the check for a single run, pass `--skip-preflight`; to bypass it process-wide (e.g. in CI), set `MB_CLI_SKIP_PREFLIGHT=1`. Both are footguns — only for servers you know are patched. @@ -25,11 +25,13 @@ Or build from source: ```sh bun install bun run build -node dist/cli.mjs --help +node packages/cli/dist/cli.mjs --help ``` The binary is `mb`. Examples below use that name. +File paths in this document are relative to the repository root, so they resolve in a checkout of rather than in the installed package — the npm tarball carries only `dist`, `skills` and `skill-data`. + ## Quick start ```sh @@ -37,6 +39,56 @@ mb auth login --url https://metabase.example.com mb auth status ``` +## Output + +Every `list` and `get` verb takes the same output flags. The per-command flag tables below list only what is specific to that command. + +| Flag | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `--json` | Emit JSON. Auto-enabled on non-TTY. Shorthand for `--format json`. | +| `--format ` | `auto` \| `json` \| `text` (default `auto`). | +| `--full` | Return every field. The default is a compact projection. | +| `--fields ` | Project comma-separated dot-paths. Mutually exclusive with `--full`. On list verbs the paths are relative to each `data[]` item. | +| `--max-bytes ` | Output size cap, default `24576`; `0` disables. On a list, trailing items are dropped and `truncated` is set. | +| `-p, --profile ` | Named profile (default `default`). | + +Every `list` verb additionally takes a window: + +| Flag | Description | +| -------------- | ------------------------------------------------------------------------------------------ | +| `--limit ` | How many items this call returns. Default: as many as fit the output cap. | +| `--offset ` | Where the window starts (default `0`). Pass the previous call's `next_offset` to continue. | + +### List envelope + +List verbs answer with a single envelope: + +```json +{ + "returned": 2, + "offset": 0, + "limit": 2, + "total": 42, + "has_more": true, + "next_offset": 2, + "truncated": { "reason": "max_bytes", "bytes": 123456 }, + "data": [] +} +``` + +| Field | Meaning | +| ------------- | ------------------------------------------------------------------------------------------------------- | +| `data` | The items in this window. | +| `returned` | How many items are in `data`. | +| `offset` | Where this window starts. | +| `limit` | Present only when you passed `--limit`. | +| `total` | The server's count where the endpoint reports one, otherwise `null`. A display value, not a bound. | +| `has_more` | Whether more items remain. This — not `returned` against `total` — is what says to keep going. | +| `next_offset` | Pass back as `--offset` for the next window; `null` when the walk is over. | +| `truncated` | Present when `--max-bytes` dropped trailing items; `bytes` is what the full answer would have measured. | + +When the cap leaves no room for even one item, the list comes back empty with `next_offset: null` — narrow it with `--fields` or raise the cap. A `get` whose single item is over the cap fails instead, with exit `2`. + ## Authentication Credentials are stored per-profile. The default profile is named `default`. Use `--profile ` to manage additional profiles. @@ -45,10 +97,10 @@ Credentials are stored per-profile. The default profile is named `default`. Use Log in to a Metabase instance and save the credential to a profile. Interactive login offers two methods: -- **In your browser** (recommended; requires Metabase v62 or newer) — the CLI opens Metabase, you sign in with your password or SSO and approve the CLI, and a short-lived access token plus a rotating refresh token are stored. Tokens refresh automatically; you never paste a secret. +- **In your browser** (recommended; requires Metabase v63 or newer) — the CLI opens Metabase, you sign in with your password or SSO and approve the CLI, and a short-lived access token plus a rotating refresh token are stored. Tokens refresh automatically; you never paste a secret. - **With an API key** — paste a key from Admin settings → Authentication → API keys. -Against a server older than v62 the CLI detects the missing OAuth support and falls back to the API key prompt automatically. Supplying an API key (flag, env, or stdin) always skips the browser flow, so CI and scripts behave exactly as before. +Against a server older than v63 the CLI detects the missing OAuth support and falls back to the API key prompt automatically. Supplying an API key (flag, env, or stdin) always skips the browser flow, so CI and scripts behave exactly as before. On success the server is probed once — the rendered output shows the user, role (`Admin`/`User`), and Metabase version, and the same values are cached in `/profiles.json` so later commands skip re-probing. Failure of either the auth probe (`/api/user/current`) or the server probe (`/api/session/properties`) rejects the login; an existing profile keeps its last-known-good credential and gains a `lastFailure` entry. @@ -114,7 +166,7 @@ mb auth logout --profile staging --yes ## Transforms -CRUD on `/api/transform`. Bodies for `create` / `update` are JSON; resolution order: `--body` → `--file` → piped stdin (auto-detected when stdin is not a TTY). +CRUD on `/api/transform`. Requires Metabase v59 or newer. Bodies for `create` / `update` are JSON; resolution order: `--body` → `--file` → piped stdin (auto-detected when stdin is not a TTY). ### `mb transform list` @@ -203,7 +255,7 @@ mb transform get-run 1 --json ### `mb transform runs` -List recent transform runs across all transforms, or filter to one. Drains all pages by default; pass `--limit` to cap. +List recent transform runs across all transforms, or filter to one. `/api/transform/run` is server-paged: the CLI pulls only as far as the output cap can display, then reports `has_more` / `next_offset` so you can continue. ```sh mb transform runs @@ -211,14 +263,15 @@ mb transform runs --transform-id 1 --json mb transform runs --limit 10 --json ``` -| Flag | Description | -| --------------------- | --------------------------------------------------- | -| `--transform-id ` | Filter to runs of a single transform id. | -| `--limit ` | Cap total runs returned (default: drain all pages). | +| Flag | Description | +| --------------------- | ---------------------------------------- | +| `--transform-id ` | Filter to runs of a single transform id. | + +Plus the shared output and window flags — see [Output](#output). ## Transform jobs -CRUD on `/api/transform-job`. Bodies for `create` / `update` follow the same `--body` / `--file` / stdin pattern as transforms. +CRUD on `/api/transform-job`. Requires Metabase v59 or newer. Bodies for `create` / `update` follow the same `--body` / `--file` / stdin pattern as transforms. ### `mb transform-job list` @@ -282,7 +335,7 @@ mb transform-job transforms 1 --json ### `mb transform-job set-active ` -Activate or deactivate every transform job at once (admin only). Inactive jobs do not run on schedule; manual runs ignore the flag. +Activate or deactivate every transform job at once (admin only; requires Metabase v61 or newer). Inactive jobs do not run on schedule; manual runs ignore the flag. ```sh mb transform-job set-active false @@ -291,7 +344,7 @@ mb transform-job set-active true --json ## Transform tags -CRUD on `/api/transform-tag`. Tags group transforms and jobs; reference them by id via the `tag_ids` field on a transform or job. The four built-in tags (`hourly`, `daily`, `weekly`, `monthly`) drive the built-in jobs. There is no get-by-id endpoint — use `list`. +CRUD on `/api/transform-tag`. Requires Metabase v59 or newer. Tags group transforms and jobs; reference them by id via the `tag_ids` field on a transform or job. The four built-in tags (`hourly`, `daily`, `weekly`, `monthly`) drive the built-in jobs. There is no get-by-id endpoint — use `list`. ### `mb transform-tag list` @@ -883,7 +936,7 @@ mb segment archive 1 --revision-message "deprecated" ## Measures -CRUD on `/api/measure`. A measure is a saved MBQL aggregation (a single `:aggregation` clause) tied to a table — referenced from cards and metrics to share a reusable computation. Mutating endpoints require a `revision_message` for the audit log. +CRUD on `/api/measure`. Requires Metabase v59 or newer. A measure is a saved MBQL aggregation (a single `:aggregation` clause) tied to a table — referenced from cards and metrics to share a reusable computation. Mutating endpoints require a `revision_message` for the audit log. ### `mb measure list` @@ -1285,7 +1338,7 @@ mb collection get 4 --json --full ### `mb collection items ` -List the cards, dashboards, sub-collections, and other content stored inside a collection. The CLI drains all pages of `/api/collection/:id/items`; pass `--limit` to cap the result. `` accepts the same forms as `collection get` — including `root` for top-level content (items there have `collection_id: null`). +List the cards, dashboards, sub-collections, and other content stored inside a collection. `/api/collection/:id/items` is server-paged: the CLI pulls only as far as the output cap can display, then reports `has_more` / `next_offset` so you can continue. `` accepts the same forms as `collection get` — including `root` for top-level content (items there have `collection_id: null`). ```sh mb collection items 4 @@ -1299,7 +1352,8 @@ mb collection items 4 --pinned-state is_pinned --json | `--models ` | Restrict to one or more models (`card`, `dataset`, `metric`, `dashboard`, `snippet`, `collection`, `document`, …). | | `--archived` | Return archived items instead of unarchived. | | `--pinned-state ` | One of `all`, `is_pinned`, `is_not_pinned`. | -| `--limit ` | Cap total items returned. Default: drain all pages. | + +Plus the shared output and window flags — see [Output](#output). ### `mb collection tree` @@ -1338,7 +1392,7 @@ mb collection archive 4 --json ## Library -Curate the Metabase **Library** — a governed subtree (`library-data` "Data" for published tables, `library-metrics` "Metrics" for official metrics, under a `library` root). Tables published to Data appear first when people pick a data source and rank up in search, steering everyone toward trusted, analysis-ready tables. Requires the `library` premium feature (Pro/Enterprise) and admin or data-analyst permission (Curate alone won't publish tables). Publish status surfaces on the table via `is_published` (`table get`/`table list`). +Curate the Metabase **Library** — a governed subtree (`library-data` "Data" for published tables, `library-metrics` "Metrics" for official metrics, under a `library` root). Tables published to Data appear first when people pick a data source and rank up in search, steering everyone toward trusted, analysis-ready tables. Requires Metabase v59 or newer, the `library` premium feature (Pro/Enterprise), and admin or data-analyst permission (Curate alone won't publish tables). Publish status surfaces on the table via `is_published` (`table get`/`table list`). ### `mb library get` @@ -1497,17 +1551,18 @@ mb search --models card,dashboard --limit 10 --json mb search products --archived ``` -| Flag | Description | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--models`, `-m` | Comma-separated model filter: `card,dataset,metric,dashboard,collection,database,table,segment,measure,snippet,document,action,transform,indexed-entity`. | -| `--archived` | Include archived items only. | -| `--limit` | Max results to return (default `20`). | -| `--db-id` | Restrict to items on a given database id. | -| `--verified` | Only verified content. | +| Flag | Description | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--models`, `-m` | Comma-separated model filter: `card,dataset,metric,dashboard,collection,database,table,segment,measure,document,action,transform,indexed-entity`. | +| `--archived` | Include archived items only. | +| `--limit` | Max results to return (default `20` — `search` is the one list verb with its own default). | +| `--offset` | Where the window starts, applied by the server (default `0`). | +| `--db-id` | Restrict to items on a given database id. | +| `--verified` | Only verified content. | ## Git Sync -Drive Metabase Enterprise Remote Sync (`/api/ee/remote-sync`) — import / export Metabase content against a configured git remote, inspect dirty state, and manage branches. All git-sync commands require an active EE token and superuser credentials. +Drive Metabase Enterprise Remote Sync (`/api/ee/remote-sync`) — import / export Metabase content against a configured git remote, inspect dirty state, and manage branches. All git-sync commands require Metabase v60 or newer, the `remote_sync` premium feature on an active EE token, and superuser credentials. ### `mb git-sync status` @@ -1863,8 +1918,8 @@ Bundled skills: Discovery surfaces: -- **Claude Code plugin marketplace**: `.claude-plugin/marketplace.json` declares a `metabase-cli` plugin pointing at the in-repo discovery stub. Users install with `/plugin marketplace add metabase/mb-cli` then `/plugin install metabase-cli@metabase`. -- **`npx skills add`**: the same stub at `skills/metabase-cli/SKILL.md` is picked up by `npx skills add metabase/mb-cli`. The stub is intentionally minimal — it redirects the agent at `mb skills get core` so the real workflow content always comes from the installed CLI version. +- **Claude Code plugin marketplace**: `.claude-plugin/marketplace.json` declares a `metabase-cli` plugin pointing at the in-repo discovery stub. Users install with `/plugin marketplace add metabase/mb-cli` then `/plugin install metabase-cli@metabase`. The manifest lives at the repo root and is served from GitHub, not from the npm tarball: its `source: "./packages/cli"` is resolved relative to the repo checkout, so a copy inside the published package would point at nothing. `files` in `packages/cli/package.json` therefore omits `.claude-plugin` by design. +- **`npx skills add`**: the same stub at `packages/cli/skills/metabase-cli/SKILL.md` is picked up by `npx skills add metabase/mb-cli`. The stub is intentionally minimal — it redirects the agent at `mb skills get core` so the real workflow content always comes from the installed CLI version. Exit codes: `0` success, `2` `ConfigError` (missing name, unknown name, `MB_SKILLS_DIR` not a directory), `1` unexpected I/O. @@ -1889,7 +1944,7 @@ The former `METABASE_`-prefixed names (`METABASE_URL`, `METABASE_API_KEY`, `META Every node of the command tree answers `--help --json` with machine-readable help, mirroring what text help shows at that level: - A leaf command emits its full entry — name, description, `details`, examples, citty args with types/defaults/enums, `capabilities` (min server version / token feature), and the input and output Zod schemas rendered as JSON Schema (`inputSchema` is the exact validator `readBody` enforces on the JSON body, `null` for commands that take none). -- A command group (and the root) emits a flat `{ commands: [{ command, description }] }` index of every leaf in its subtree, with full-path names. +- A command group (and the root) emits `{ description, skills, commands }` — its own sentence (`null` when it declares none), its own agent-skill pointers, and a flat `commands: [{ command, description }]` index of every leaf in its subtree, with full-path names. ```sh mb --help --json | jq -r '.commands[].command' # every command @@ -1897,7 +1952,7 @@ mb card query --help --json | jq .outputSchema # one command's output schema mb card create --help --json | jq .inputSchema # the JSON-body contract it validates ``` -The entry and index schemas (`CommandHelpEntry`, `CommandHelpIndex`) are exported from `src/runtime/command-help.ts`. +The entry and index schemas (`CommandHelpEntry`, `CommandHelpIndex`) are exported from `packages/cli/src/runtime/command-help.ts`. ## Exit codes @@ -1907,3 +1962,35 @@ The entry and index schemas (`CommandHelpEntry`, `CommandHelpIndex`) are exporte | `1` | Verification or operation failed. | | `2` | Configuration error (invalid flag, missing TTY, etc.). | | `130` | Interactive prompt cancelled (Ctrl+C). | + +## Working in the repo + +```sh +bun install +bun run check # the full gate: typecheck, lint, format, unit tests, skill lint +bun run build +bun run test # unit tests +bun run typecheck +bun run lint +``` + +`bin/mb-dev` runs the CLI straight from source against a scratch config directory, so a +dev run never touches your real profiles or the OS keychain. + +The e2e tier drives the built binary against a real Metabase in docker compose: + +```sh +bun run e2e:up +bun run e2e:bootstrap +bun run test:e2e +``` + +`bun run e2e:down` wipes the stack's volumes, and is the routine way back to a +known-good stack: the app-db lives in a docker volume while `bun run e2e:bootstrap` +writes its record of the seed into the working tree, so a fresh worktree or a +`git clean -x` leaves a seeded server the bootstrap can no longer recognise. It +refuses to re-seed one and names this command. `bun run e2e:matrix` runs the suite +across the supported version/edition matrix. + +`docs/architecture.md` explains how the repo is laid out and why; `CLAUDE.md` carries the +binding rules. diff --git a/bin/mb-dev b/bin/mb-dev index dd8004d..bf33d0c 100755 --- a/bin/mb-dev +++ b/bin/mb-dev @@ -4,4 +4,4 @@ ROOT=$(cd "$(dirname "$0")/.." && pwd) export XDG_CONFIG_HOME="$ROOT/.dev-state" export MB_CLI_DISABLE_KEYRING=1 mkdir -p "$XDG_CONFIG_HOME" -exec bun "$ROOT/src/cli.ts" "$@" +exec bun "$ROOT/packages/cli/src/cli.ts" "$@" diff --git a/bun.lock b/bun.lock index 589c443..88301e8 100644 --- a/bun.lock +++ b/bun.lock @@ -3,19 +3,10 @@ "configVersion": 1, "workspaces": { "": { - "name": "metabase-cli", - "dependencies": { - "@clack/prompts": "^0.8.2", - "@napi-rs/keyring": "^1.3.0", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "citty": "^0.2.2", - "cli-table3": "^0.6.5", - "semver": "^7.8.0", - "yaml": "^2.8.4", - "zod": "^4.0.0", - }, + "name": "metabase-cli-monorepo", "devDependencies": { + "@clack/core": "^0.3.5", + "@metabase/client": "workspace:*", "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.0", "@types/semver": "^7.7.1", @@ -25,34 +16,54 @@ "oxfmt": "^0.47.0", "oxlint": "^1.62.0", "publint": "^0.3.0", - "tsdown": "^0.9.0", + "tsdown": "^0.22.14", "typescript": "^5.7.2", + "unrun": "^0.3.1", "vitest": "^2.1.8", + "zod": "^4.0.0", + }, + }, + "packages/cli": { + "name": "@metabase/cli", + "version": "0.3.0", + "bin": { + "mb": "./dist/cli.mjs", + }, + "dependencies": { + "@clack/prompts": "^0.8.2", + "@napi-rs/keyring": "^1.3.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "citty": "^0.2.2", + "cli-table3": "^0.6.5", + "semver": "^7.8.0", + "yaml": "^2.8.4", + "zod": "^4.0.0", + }, + }, + "packages/client": { + "name": "@metabase/client", + "version": "0.1.0", + "dependencies": { + "semver": "^7.8.0", + }, + "peerDependencies": { + "zod": "^4.0.0", }, }, }, "packages": { - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], - - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@clack/core": ["@clack/core@0.3.5", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ=="], "@clack/prompts": ["@clack/prompts@0.8.2", "", { "dependencies": { "@clack/core": "0.3.5", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-6b9Ab2UiZwJYA9iMyboYyW9yJvAO9V753ZhS+DHKEjZRKAxPPOb7MXXu84lsPFG+vZt6FRFniZ8rXi+zCIw4yQ=="], "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], @@ -100,15 +111,11 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@metabase/cli": ["@metabase/cli@workspace:packages/cli"], + + "@metabase/client": ["@metabase/client@workspace:packages/client"], "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], @@ -136,55 +143,9 @@ "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], - - "@oxc-project/types": ["@oxc-project/types@0.66.0", "", {}, "sha512-KF5Wlo2KzQ+jmuCtrGISZoUfdHom7qHavNfPLW2KkeYJfYMGwtiia8KjwtsvNJ49qRiXImOCkPeVPd4bMlbR7w=="], - - "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@9.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MVyRgP2gzJJtAowjG/cHN3VQXwNLWnY+FpOEsyvDepJki1SdAX/8XDijM1yN6ESD1kr9uhBKjGelC6h3qtT+rA=="], - - "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@9.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-7kV0EOFEZ3sk5Hjy4+bfA6XOQpCwbDiDkkHN4BHHyrBHsXxUR05EcEJPPL1WjItefg+9+8hrBmoK0xRoDs41+A=="], - - "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@9.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6OvkEtRXrt8sJ4aVfxHRikjain9nV1clIsWtJ1J3J8NG1ZhjyJFgT00SCvqxbK+pzeWJq6XzHyTCN78ML+lY2w=="], - - "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@9.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-aYpNL6o5IRAUIdoweW21TyLt54Hy/ZS9tvzNzF6ya1ckOQ8DLaGVPjGpmzxdNja9j/bbV6aIzBH7lNcBtiOTkQ=="], - - "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@9.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RGFW4vCfKMFEIzb9VCY0oWyyY9tR1/o+wDdNePhiUXZU4SVniRPQaZ1SJ0sUFI1k25pXZmzQmIP6cBmazi/Dew=="], - - "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@9.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-lxx/PibBfzqYvut2Y8N2D0Ritg9H8pKO+7NUSJb9YjR/bfk2KRmP8iaUz3zB0JhPtf/W3REs65oKpWxgflGToA=="], - - "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@9.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-yD28ptS/OuNhwkpXRPNf+/FvrO7lwURLsEbRVcL1kIE0GxNJNMtKgIE4xQvtKDzkhk6ZRpLho5VSrkkF+3ARTQ=="], - - "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@9.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-WBwEJdspoga2w+aly6JVZeHnxuPVuztw3fPfWrei2P6rNM5hcKxBGWKKT6zO1fPMCB4sdDkFohGKkMHVV1eryQ=="], - - "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@9.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-a2z3/cbOOTUq0UTBG8f3EO/usFcdwwXnCejfXv42HmV/G8GjrT4fp5+5mVDoMByH3Ce3iVPxj1LmS6OvItKMYQ=="], - - "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@9.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-bHZF+WShYQWpuswB9fyxcgMIWVk4sZQT0wnwpnZgQuvGTZLkYJ1JTCXJMtaX5mIFHf69ngvawnwPIUA4Feil0g=="], - - "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@9.0.2", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.9" }, "cpu": "none" }, "sha512-I5cSgCCh5nFozGSHz+PjIOfrqW99eUszlxKLgoNNzQ1xQ2ou9ZJGzcZ94BHsM9SpyYHLtgHljmOZxCT9bgxYNA=="], - - "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@9.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-5IhoOpPr38YWDWRCA5kP30xlUxbIJyLAEsAK7EMyUgqygBHEYLkElaKGgS0X5jRXUQ6l5yNxuW73caogb2FYaw=="], - - "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@9.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Qc40GDkaad9rZksSQr2l/V9UubigIHsW69g94Gswc2sKYB3XfJXfIfyV8WTJ67u6ZMXsZ7BH1msSC6Aen75mCg=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@oxc-transform/binding-darwin-arm64": ["@oxc-transform/binding-darwin-arm64@0.67.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P3zBMhpOQceNSys3/ZqvrjuRvcIbVzfGFN/tH34HlVkOjOmfGK1mOWjORsGAZtbgh1muXrF6mQETLzFjfYndXQ=="], - - "@oxc-transform/binding-darwin-x64": ["@oxc-transform/binding-darwin-x64@0.67.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-B52aeo/C3spYHcwFQ4nAbDkwbMKf0K6ncWM8GrVUgGu8PPECLBhjPCW11kPW/lt9FxwrdgVYVzPYlZ6wmJmpEA=="], - - "@oxc-transform/binding-linux-arm-gnueabihf": ["@oxc-transform/binding-linux-arm-gnueabihf@0.67.0", "", { "os": "linux", "cpu": "arm" }, "sha512-5Ir1eQrC9lvj/rR1TJVGwOR4yLgXTLmfKHIfpVH7GGSQrzK7VMUfHWX+dAsX1VutaeE8puXIqtYvf9cHLw78dw=="], - - "@oxc-transform/binding-linux-arm64-gnu": ["@oxc-transform/binding-linux-arm64-gnu@0.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zTqfPET5+hZfJ3/dMqJboKxrpXMXk+j2HVdvX0wVhW2MI7n7hwELl+In6Yu20nXuEyJkNQlWHbNPCUfpM+cBWw=="], - - "@oxc-transform/binding-linux-arm64-musl": ["@oxc-transform/binding-linux-arm64-musl@0.67.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jzz/ATUhZ8wetb4gm5GwzheZns3Qj1CZ+DIMmD8nBxQXszmTS/fqnAPpgzruyLqkXBUuUfF3pHv5f/UmuHReuQ=="], - - "@oxc-transform/binding-linux-x64-gnu": ["@oxc-transform/binding-linux-x64-gnu@0.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Qy2+tfglJ8yX6guC1EDAnuuzRZIXciXO9UwOewxyiahLxwuTpj/wvvZN3Cb1SA3c14zrwb2TNMZvaXS1/OS5Pg=="], - - "@oxc-transform/binding-linux-x64-musl": ["@oxc-transform/binding-linux-x64-musl@0.67.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tHoYgDIRhgvh+/wIrzAk3cUoj/LSSoJAdsZW9XRlaixFW/TF2puxRyaS1hRco0bcKTwotXl/eDYqZmhIfUyGRQ=="], - - "@oxc-transform/binding-wasm32-wasi": ["@oxc-transform/binding-wasm32-wasi@0.67.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.9" }, "cpu": "none" }, "sha512-ZPT+1HECf7WUnotodIuS8tvSkwaiCdC2DDw8HVRmlerbS6iPYIPKyBCvkSM4RyUx0kljZtB9AciLCkEbwy5/zA=="], - - "@oxc-transform/binding-win32-arm64-msvc": ["@oxc-transform/binding-win32-arm64-msvc@0.67.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-+E3lOHCk4EuIk6IjshBAARknAUpgH+gHTtZxCPqK4AWYA+Tls2J6C0FVM48uZ4m3rZpAq8ZszM9JZVAkOaynBQ=="], - - "@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.67.0", "", { "os": "win32", "cpu": "x64" }, "sha512-3pIIFb9g5aFrAODTQVJYitq+ONHgDJ4IYk/7pk+jsG6JpKUkURd0auUlxvriO11fFit5hdwy+wIbU4kBvyRUkg=="], + "@oxc-project/types": ["@oxc-project/types@0.140.0", "", {}, "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.47.0", "", { "os": "android", "cpu": "arm" }, "sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw=="], @@ -266,29 +227,37 @@ "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.8-commit.151352b", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2F4bhDtV6CHBx7JMiT9xvmxkcZLHFmonfbli36RyfvgThDOAu92bis28zDTdguDY85lN/jBRKX/eOvX+T5hMkg=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.0", "", { "os": "android", "cpu": "arm64" }, "sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-NqKYaq0355ZmNMG4QGpxtEDxsc7tGDhjhCm4PpE0cwnBW+5Il95LJyq414niEiaKLVjnVHBEjSo1wngKxJNiFw=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.8-commit.151352b", "", { "os": "darwin", "cpu": "x64" }, "sha512-8VMChhFLeD/oOAQUspFtxZaV7ctDob63w626kwvBBIHtlpY2Ohw4rsfjjtGckyrTCI/RROgZv/TVVEsG3GkgLw=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-3vPoHzh6eBTz9IbB0/qZdSr0Qeks2echn+I4cHu2joV74VriPDdldswksEDzrl1mBB+oPRi+67+3Ib59paxIPQ=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.8-commit.151352b", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4W28EgaIidbWIpwB3hESMBfiOSs7LBFpJGa8JIV488qLEnTR/pqzxDEoOPobhRSJ1lJlv0vUgA8+DKBIldo2gw=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-E6NNefZ1bUVmKJq2tJkf45J4Zyczj7qm9rUT7NY+Xo2474Y13qWAwc2tvBt0BAVbmtXR1llkxXg0Ou1jbDf2SQ=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.8-commit.151352b", "", { "os": "linux", "cpu": "arm" }, "sha512-1ECtyzIKlAHikR7BhS4hk7Hxw8xCH6W3S+Sb74EM0vy5AqPvWSbgLfAwagYC7gNDcMMby3I757X7qih5fIrGiw=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-D+TgkdgM1vu+7/Fpf8+v0ARW+RXEP9Ccazgm8zQ4JFFd9Q7SrYQ2TakU5S5ihazQDgpKyAgZDOcIFsvoHmTZ8w=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.8-commit.151352b", "", { "os": "linux", "cpu": "arm64" }, "sha512-wU1kp8qPRUKC8N82dNs3F5+UyKRww9TUEO5dQ5mxCb0cG+y4l5rVaXpMgvL0VuQahPVvTMs577QPhJGb4iDONw=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wUqdwJBbAv0APN87GecstdMUtLjjNTs0hBALpxETD73mccFxdmt/XeizXDtN5RAlBwNKmI+Tg+blect2G+8IeQ=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.8-commit.151352b", "", { "os": "linux", "cpu": "arm64" }, "sha512-odDjO2UtEEMAzwmLHEOKylJjQa+em1REAO9H19PA+O+lPu6evVbre5bqu8qCjEtHG1Q034LpZR86imCP2arb/w=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9DtF35qR9/NrfhM4oxLplCzVVjE+KKm8Pjemi0i/sdhAWkUasjmSo8WTTubNJClhSHCfyk2yeyoXDQEDPtDAAw=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.8-commit.151352b", "", { "os": "linux", "cpu": "x64" }, "sha512-Ty2T67t2Oj1lg417ATRENxdk8Jkkksc/YQdCJyvkGqteHe60pSU2GGP/tLWGB+I0Ox+u387bzU/SmfmrHZk9aw=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-RzuHrBh8X8Hntd2N4VR02QGEciq/9JhcZoTpR/Cee6otRrlILGCf3cg2ygHuih+ZebUnWmMrDX6ITI85btO6rQ=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.8-commit.151352b", "", { "os": "linux", "cpu": "x64" }, "sha512-Fm1TxyeVE+gy74HM26CwbEOUndIoWAMgWkVDxYBD64tayvp5JvltpGHaqCg6x5i+X2F5XCDCItqwVlC7/mTxIw=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-MK7L0018jjh1jR3mh21G2j1zAVcpscJBlPo2z19pRjv2XOYGRhaV4LyiD8HO6nCDdZln9IFgCMIV5yt4E3klGQ=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.8-commit.151352b", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.4" }, "cpu": "none" }, "sha512-AEZzTyGerfkffXmtv7kFJbHWkryNeolk0Br+yhH1wZyN6Tt6aebqICDL8KNRO2iExoEWzyYS6dPxh0QmvNTfUQ=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-gyrxLQ9NfGb/9LoVnC4kb9miUghw1mghnkfYvNHSnVIXriabnfgGPUP4RLcJm87q3KgYz4FYUG8IDiWUT+CpSw=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.8-commit.151352b", "", { "os": "win32", "cpu": "arm64" }, "sha512-0lskDFKQwf5PMjl17qHAroU6oVU0Zn8NbAH/PdM9QB1emOzyFDGa20d4kESGeo3Uq7xOKXcTORJV/JwKIBORqw=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.0", "", { "os": "none", "cpu": "arm64" }, "sha512-/6VFMQGRmrhP77KXDC+StIxGzcNp5JOIyYtw0CQ8gPlzhpiIRucYfoM5FaFamHd5BJYIdH86yfP46l1p3WdrFA=="], - "@rolldown/binding-win32-ia32-msvc": ["@rolldown/binding-win32-ia32-msvc@1.0.0-beta.8-commit.151352b", "", { "os": "win32", "cpu": "ia32" }, "sha512-DfG1S0zGKnUfr95cNCmR4YPiZ/moS7Tob5eV+9r5JGeHZVWFHWwvJdR0jArj6Ty0LbBFDTVVB3iAvqRSji+l0Q=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.2.0", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-rwdbUL465kisF24WEJLvP3JrEG6E5GRuIHt5wpMwHGERtHe4Wm2CIvtf5gTBgr2tGOHKh5NdKEAFS2VkOPE91g=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.8-commit.151352b", "", { "os": "win32", "cpu": "x64" }, "sha512-5HZEtc8U2I1O903hXBynWtWaf+qzAFj66h5B7gOtVcvqIk+lKRVSupA85OdIvR7emrsYU25ikpfiU5Jhg9kTbQ=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-+5suHwRiKGmhwyUaNT8a5QbrBvLFh2DbO910TEmGRH1aSxwrCezodvGQnulv4uiWEIv1Kq4ypRsJ5+O+ry1DiA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WfFv6/qGufotqBSBzBYwgpCkJBk8Nj7697LL9vTz/XWc67e0r3oewu8iMRwQj3AUL45GVD7wVsPjCsAAtW66Wg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], @@ -344,7 +313,7 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -354,8 +323,6 @@ "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], - "@valibot/to-json-schema": ["@valibot/to-json-schema@1.0.0", "", { "peerDependencies": { "valibot": "^1.0.0" } }, "sha512-/9crJgPptVsGCL6X+JPDQyaJwkalSZ/52WuF8DiRUxJgcmpNdzYRfZ+gqMEP8W3CTVfuMWPqqvIgfwJ97f9Etw=="], - "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], @@ -370,7 +337,51 @@ "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "@yuku-codegen/binding-darwin-arm64": ["@yuku-codegen/binding-darwin-arm64@0.8.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7cSJH6PaKLRBdCfiB4pM6EukvgOk5xV4tyuLOIOEqrHsbnV7brtyff7CjhZbeGozdIHoOnKOi5R7rrmCWN3QSw=="], + + "@yuku-codegen/binding-darwin-x64": ["@yuku-codegen/binding-darwin-x64@0.8.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-mhooLL+L5ytMxgz4ueXCIirU796X2xj97d4KSQW1HxZGzX6h8wOk5bIAhGqcmOL2bqAmMZ+UkBTbPC9VpzKb/g=="], + + "@yuku-codegen/binding-freebsd-x64": ["@yuku-codegen/binding-freebsd-x64@0.8.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MHLOAlgGhdOh0ZfnWWnno4ljlFB04/Lox/7MIGYIvISMKFvejfC9Atb1t9G7pTVBvy+l5uqxzHGBSJUsDOOkTA=="], + + "@yuku-codegen/binding-linux-arm-gnu": ["@yuku-codegen/binding-linux-arm-gnu@0.8.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Ur3Awo45Sc5/Fglr8WN5XIf4IwAsq8wLd917Du8ow6mStxsBTHqFiH+tT7d5jV0FqcJnwy8EHVczmgde0zTo1A=="], + + "@yuku-codegen/binding-linux-arm-musl": ["@yuku-codegen/binding-linux-arm-musl@0.8.0", "", { "os": "linux", "cpu": "arm" }, "sha512-FIy7Ttx8oeUCd/8Y6IjnOsu+lRc6En+V/H67BlVphOeCySZAo5LU8VWrb4tv0DvjaSOzdm3DmdmRzmzN7NCqWQ=="], + + "@yuku-codegen/binding-linux-arm64-gnu": ["@yuku-codegen/binding-linux-arm64-gnu@0.8.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-s+25wl1TLvf+7LzasPEi1RR1sDfVAU0i1QH601mn3vj+HudFYBYNZtUBKFaZvl645QR6vcaDaGHnOaMZhBR3ig=="], + + "@yuku-codegen/binding-linux-arm64-musl": ["@yuku-codegen/binding-linux-arm64-musl@0.8.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-pRha3Cjm4AnA5wEuhpg+8XXoGfwz5X61/9a/VNxeau47+kH6xjJspkEloIy/HbHgUnvVRqVHoEHczdGZT2J9NA=="], + + "@yuku-codegen/binding-linux-x64-gnu": ["@yuku-codegen/binding-linux-x64-gnu@0.8.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GySXmiw5Dw99Ba3GMV2ExQVckihuangnrIpSJagPx8RFUNtwfmzpr2ibf8k31eEAE4YUofV7mEfJN5tN6+POnQ=="], + + "@yuku-codegen/binding-linux-x64-musl": ["@yuku-codegen/binding-linux-x64-musl@0.8.0", "", { "os": "linux", "cpu": "x64" }, "sha512-8YFfcPZz44v8YyekWVKkNz/cD4t6DW62/dr03OVtMfwUtHfmo/8wpby0JKMleAOXbgWItSGw81LtwqM4I9oS0A=="], + + "@yuku-codegen/binding-win32-arm64": ["@yuku-codegen/binding-win32-arm64@0.8.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-YhENbgkuzjsil+zDNV35oU3PQMDg2RXh8BPt915WCNAbIIHcqgYupHJF3564206+DbPkZtcDvcoa34Wb5B8tbQ=="], + + "@yuku-codegen/binding-win32-x64": ["@yuku-codegen/binding-win32-x64@0.8.0", "", { "os": "win32", "cpu": "x64" }, "sha512-qvzSRABXe6/ndubx+RNwgbFVbs7Pqroz/q/UR6vm++xsmfcpkMF43B23jgNl2xi2IUrEt8C/L1bBslXp+LtgnA=="], + + "@yuku-parser/binding-darwin-arm64": ["@yuku-parser/binding-darwin-arm64@0.8.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-04AakSJhI4mPrqhZzXdFyaEDh0YkfeqbnyYY3aCrmxeWfR/Xr8+kFn5sh+wZYN/5HatPniELKHixJuUCUyfBvg=="], + + "@yuku-parser/binding-darwin-x64": ["@yuku-parser/binding-darwin-x64@0.8.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-BQJGI9bDeyb/X2rwhtXoBTQ9EtbkJtteX6C7cZ9jow0pqqmoOufgHPP2+m65GLk2eVNCBcfl3xlTGpJ7RFcviw=="], + + "@yuku-parser/binding-freebsd-x64": ["@yuku-parser/binding-freebsd-x64@0.8.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-04hmgnU152wya88raI+RQhxZPgwXcHbfdNZLu3x5ggKnJVHLD8xZZLcWIKSs59CiEXL6PKVX1/cx8GoTYlaCaQ=="], + + "@yuku-parser/binding-linux-arm-gnu": ["@yuku-parser/binding-linux-arm-gnu@0.8.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+cuJWUK13lwce721XGRjz7izSr2Q1U0RlHkDzdkohWpRWlttTqRdxNnaW13nDc47GBDrNsXMHx+KLcebeOeeGg=="], + + "@yuku-parser/binding-linux-arm-musl": ["@yuku-parser/binding-linux-arm-musl@0.8.0", "", { "os": "linux", "cpu": "arm" }, "sha512-rOVPRqt9cm1YP/wPV+yyZh0FYr6UR/wm/BXslvLuge0LDewVvDrm/AxcDrwWuPAu61Nzdg2rMVfcNnrplCbC1w=="], + + "@yuku-parser/binding-linux-arm64-gnu": ["@yuku-parser/binding-linux-arm64-gnu@0.8.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-pAoIozKr6E+ptpaQz4CZv1O7cay2f4m7kbd+DSQug5MKdUBTZZ18GdWSPvq0fwQYraH9hZlyLqrMaeP5W/ncrA=="], + + "@yuku-parser/binding-linux-arm64-musl": ["@yuku-parser/binding-linux-arm64-musl@0.8.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-JCAg50ahXuYlrsIi1jmymX9X/9B0JYBRroAHnYttN44tAvCo1PFqukHrw1up6HvEOoLA0OjlM0Zwh60u3Gc/Zg=="], + + "@yuku-parser/binding-linux-x64-gnu": ["@yuku-parser/binding-linux-x64-gnu@0.8.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tEeVQ14etp7lpUqXzq+X5AlQzFH+m3TVDiCKIq17zxnxJ117DOVvoveWSkSMt/lj68z48SvZUMHe7xLvqtO1lg=="], + + "@yuku-parser/binding-linux-x64-musl": ["@yuku-parser/binding-linux-x64-musl@0.8.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+UGRYnF37nnbZNMsMjSGDXKUTIxYUmbbk3Lzib88sLK7kg2y80vjchYYoYsDUK9kAgqPWyA9hKGURHy/Kk9Few=="], + + "@yuku-parser/binding-win32-arm64": ["@yuku-parser/binding-win32-arm64@0.8.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-l+7Va9/sX1ccRjzjJj6MR0arKsvHB5b419+pKbwzn+/18A/xfccWqmxXrn0C0NyVLlbEb3AV9Is+AbF72O85yA=="], + + "@yuku-parser/binding-win32-x64": ["@yuku-parser/binding-win32-x64@0.8.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dolKDTJv2xrWowDBEkvSaDvCsVFZOA7DCUuD+7DatglS68WbuxS4LudU5bzSeAOL5vgPpyGm82469cMLnL+6vQ=="], + + "@yuku-toolchain/types": ["@yuku-toolchain/types@0.8.0", "", {}, "sha512-hL/raFM5V9UT2lVE/lIWDTWvANqSB8TvMVp+PgICehBa96KhTl/UpGm370JXaacukR+QnaHM2Yv6O/UcrzAFGg=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -378,28 +389,22 @@ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansis": ["ansis@3.17.0", "", {}, "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg=="], + "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-kit": ["ast-kit@1.4.3", "", { "dependencies": { "@babel/parser": "^7.27.0", "pathe": "^2.0.3" } }, "sha512-MdJqjpodkS5J149zN0Po+HPshkTdUyrvF7CKTafUgv69vBSPtncrj+3IiUgqdd7ElIEkbeXCsEouBUwLrw9Ilg=="], - - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], - "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -410,13 +415,11 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "diff": ["diff@7.0.0", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="], - - "dts-resolver": ["dts-resolver@1.2.0", "", { "dependencies": { "oxc-resolver": "^9.0.0", "pathe": "^2.0.3" } }, "sha512-+xNF7raXYI1E3IFB+f3JqvoKYFI8R+1Mh9mpI75yNm3F5XuiC6ErEXe2Lqh9ach+4MQ1tOefzjxulhWGVclYbg=="], + "dts-resolver": ["dts-resolver@3.0.0", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "empathic": ["empathic@1.1.0", "", {}, "sha512-rsPft6CK3eHtrlp9Y5ALBb+hfK+DWnA4WFebbazxjWyx8vSm3rZeoM3z9irsjcqO3PYRzlfv27XIB4tz2DV7RA=="], + "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], @@ -442,12 +445,14 @@ "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], - "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], - "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + "import-without-cache": ["import-without-cache@0.4.0", "", {}, "sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], @@ -458,12 +463,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -502,9 +503,7 @@ "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], - "oxc-resolver": ["oxc-resolver@9.0.2", "", { "optionalDependencies": { "@oxc-resolver/binding-darwin-arm64": "9.0.2", "@oxc-resolver/binding-darwin-x64": "9.0.2", "@oxc-resolver/binding-freebsd-x64": "9.0.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "9.0.2", "@oxc-resolver/binding-linux-arm64-gnu": "9.0.2", "@oxc-resolver/binding-linux-arm64-musl": "9.0.2", "@oxc-resolver/binding-linux-riscv64-gnu": "9.0.2", "@oxc-resolver/binding-linux-s390x-gnu": "9.0.2", "@oxc-resolver/binding-linux-x64-gnu": "9.0.2", "@oxc-resolver/binding-linux-x64-musl": "9.0.2", "@oxc-resolver/binding-wasm32-wasi": "9.0.2", "@oxc-resolver/binding-win32-arm64-msvc": "9.0.2", "@oxc-resolver/binding-win32-x64-msvc": "9.0.2" } }, "sha512-w838ygc1p7rF+7+h5vR9A+Y9Fc4imy6C3xPthCMkdFUgFvUWkmABeNB8RBDQ6+afk44Q60/UMMQ+gfDUW99fBA=="], - - "oxc-transform": ["oxc-transform@0.67.0", "", { "optionalDependencies": { "@oxc-transform/binding-darwin-arm64": "0.67.0", "@oxc-transform/binding-darwin-x64": "0.67.0", "@oxc-transform/binding-linux-arm-gnueabihf": "0.67.0", "@oxc-transform/binding-linux-arm64-gnu": "0.67.0", "@oxc-transform/binding-linux-arm64-musl": "0.67.0", "@oxc-transform/binding-linux-x64-gnu": "0.67.0", "@oxc-transform/binding-linux-x64-musl": "0.67.0", "@oxc-transform/binding-wasm32-wasi": "0.67.0", "@oxc-transform/binding-win32-arm64-msvc": "0.67.0", "@oxc-transform/binding-win32-x64-msvc": "0.67.0" } }, "sha512-QXwmpLfNrXZoHgIjEtDEf6lhwmvHouNtstNgg/UveczVIjo8VSzd5h25Ea96PoX9KzReJUY/qYa4QSNkJpZGfA=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], "oxfmt": ["oxfmt@0.47.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.47.0", "@oxfmt/binding-android-arm64": "0.47.0", "@oxfmt/binding-darwin-arm64": "0.47.0", "@oxfmt/binding-darwin-x64": "0.47.0", "@oxfmt/binding-freebsd-x64": "0.47.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.47.0", "@oxfmt/binding-linux-arm-musleabihf": "0.47.0", "@oxfmt/binding-linux-arm64-gnu": "0.47.0", "@oxfmt/binding-linux-arm64-musl": "0.47.0", "@oxfmt/binding-linux-ppc64-gnu": "0.47.0", "@oxfmt/binding-linux-riscv64-gnu": "0.47.0", "@oxfmt/binding-linux-riscv64-musl": "0.47.0", "@oxfmt/binding-linux-s390x-gnu": "0.47.0", "@oxfmt/binding-linux-x64-gnu": "0.47.0", "@oxfmt/binding-linux-x64-musl": "0.47.0", "@oxfmt/binding-openharmony-arm64": "0.47.0", "@oxfmt/binding-win32-arm64-msvc": "0.47.0", "@oxfmt/binding-win32-ia32-msvc": "0.47.0", "@oxfmt/binding-win32-x64-msvc": "0.47.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA=="], @@ -522,7 +521,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "postcss": ["postcss@8.5.13", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag=="], @@ -534,15 +533,13 @@ "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "rolldown": ["rolldown@1.0.0-beta.8-commit.151352b", "", { "dependencies": { "@oxc-project/types": "0.66.0", "@valibot/to-json-schema": "1.0.0", "ansis": "^3.17.0", "valibot": "1.0.0" }, "optionalDependencies": { "@rolldown/binding-darwin-arm64": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-darwin-x64": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-freebsd-x64": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.8-commit.151352b" }, "peerDependencies": { "@oxc-project/runtime": "0.66.0" }, "optionalPeers": ["@oxc-project/runtime"], "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-TCb6GVaFBk4wB0LERofFDxTO5X1/Sgahr7Yn5UA9XjuFtCwL1CyEhUHX5lUIstcMxjbkLjn2z4TAGwisr6Blvw=="], + "rolldown": ["rolldown@1.2.0", "", { "dependencies": { "@oxc-project/types": "=0.140.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.0", "@rolldown/binding-darwin-arm64": "1.2.0", "@rolldown/binding-darwin-x64": "1.2.0", "@rolldown/binding-freebsd-x64": "1.2.0", "@rolldown/binding-linux-arm-gnueabihf": "1.2.0", "@rolldown/binding-linux-arm64-gnu": "1.2.0", "@rolldown/binding-linux-arm64-musl": "1.2.0", "@rolldown/binding-linux-ppc64-gnu": "1.2.0", "@rolldown/binding-linux-s390x-gnu": "1.2.0", "@rolldown/binding-linux-x64-gnu": "1.2.0", "@rolldown/binding-linux-x64-musl": "1.2.0", "@rolldown/binding-openharmony-arm64": "1.2.0", "@rolldown/binding-wasm32-wasi": "1.2.0", "@rolldown/binding-win32-arm64-msvc": "1.2.0", "@rolldown/binding-win32-x64-msvc": "1.2.0" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA=="], - "rolldown-plugin-dts": ["rolldown-plugin-dts@0.9.11", "", { "dependencies": { "@babel/generator": "^7.27.1", "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "ast-kit": "^1.4.3", "debug": "^4.4.0", "dts-resolver": "^1.0.1", "get-tsconfig": "^4.10.0", "oxc-transform": "^0.67.0" }, "peerDependencies": { "rolldown": "^1.0.0-beta.7", "typescript": "^5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-iCIRKmvPLwRV4UKSxhaBo+5wDkvc3+MFiqYYvu7sGLSohzxoDn9WEsjN3y7A6xg3aCuxHh6rlRp8xbX98r1rSg=="], + "rolldown-plugin-dts": ["rolldown-plugin-dts@0.27.14", "", { "dependencies": { "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.5", "obug": "^2.1.4", "yuku-ast": "^0.8.0", "yuku-codegen": "^0.8.0", "yuku-parser": "^0.8.0" }, "peerDependencies": { "@typescript/native-preview": "*", "@volar/typescript": "~2.4.0", "rolldown": "^1.0.0", "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@typescript/native-preview", "@volar/typescript", "typescript", "vue-tsc"] }, "sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw=="], "rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], @@ -574,9 +571,9 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], @@ -584,25 +581,23 @@ "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], - "tsdown": ["tsdown@0.9.9", "", { "dependencies": { "ansis": "^3.17.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.2", "debug": "^4.4.0", "diff": "^7.0.0", "empathic": "^1.0.0", "hookable": "^5.5.3", "lightningcss": "^1.29.3", "rolldown": "1.0.0-beta.8-commit.151352b", "rolldown-plugin-dts": "^0.9.4", "tinyexec": "^1.0.1", "tinyglobby": "^0.2.13", "unconfig": "^7.3.2", "unplugin-lightningcss": "^0.3.3" }, "peerDependencies": { "publint": "^0.3.0", "unplugin-unused": "^0.4.0" }, "optionalPeers": ["publint", "unplugin-unused"], "bin": { "tsdown": "dist/run.js" } }, "sha512-IIGX55rkhaPomNSVrIbA58DRBwTO4ehlDTsw20XSooGqoEZbwpunDc1dRE73wKb1rHdwwBO6NMLOcgV2n1qhpA=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + + "tsdown": ["tsdown@0.22.14", "", { "dependencies": { "ansis": "^4.3.1", "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.4", "picomatch": "^4.0.5", "rolldown": "~1.2.0", "rolldown-plugin-dts": "^0.27.13", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.3.0" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.14", "@tsdown/exe": "0.22.14", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": "^0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "unconfig": ["unconfig@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "defu": "^6.1.4", "jiti": "^2.6.1", "quansync": "^1.0.0", "unconfig-core": "7.5.0" } }, "sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA=="], - "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], - "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "unrun": ["unrun@0.3.1", "", { "dependencies": { "rolldown": "^1.0.0" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "./dist/cli.mjs" } }, "sha512-onIck/oNnCaytwths1ZVp1LK2Gq2hPoyFhiHebObuUXqR3S0uHuLLaBK8K6mRRgV7Ptip8AnNvaUsgzwWwBZuA=="], - "unplugin-lightningcss": ["unplugin-lightningcss@0.3.3", "", { "dependencies": { "lightningcss": "^1.29.3", "magic-string": "^0.30.17", "unplugin": "^2.3.2" } }, "sha512-mMNRCNIcxc/3410w7sJdXcPxn0IGZdEpq42OBDyckdGkhOeWYZCG9RkHs72TFyBsS82a4agFDOFU8VrFKF2ZvA=="], - - "valibot": ["valibot@1.0.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-1Hc0ihzWxBar6NGeZv7fPLY0QuxFMyxwYR2sF1Blu7Wq7EnremwY2W02tit2ij2VJT8HcSkHAQqmFfl77f73Yw=="], + "verkit": ["verkit@0.3.1", "", {}, "sha512-w2Eo8LSIIoW7qxNBzT7/17k+bh8plXo7G3dHjEIDqPlnluhzaxr9JX8F28VSYEtDvc1/a3WBDih6xNUZseebXg=="], "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], @@ -610,8 +605,6 @@ "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], - "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -620,14 +613,18 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "zod": ["zod@4.4.2", "", {}, "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw=="], + "yuku-ast": ["yuku-ast@0.8.0", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.0" } }, "sha512-trBzFsSa6k32vzNUCH6pFhAoTzWD/NifSYOIQ/6v14vXKh7TRhd2vDNIwRguGdXvcyfBNEEGcsfxFHrnJbS+FQ=="], - "ast-kit/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "yuku-codegen": ["yuku-codegen@0.8.0", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.0" }, "optionalDependencies": { "@yuku-codegen/binding-darwin-arm64": "0.8.0", "@yuku-codegen/binding-darwin-x64": "0.8.0", "@yuku-codegen/binding-freebsd-x64": "0.8.0", "@yuku-codegen/binding-linux-arm-gnu": "0.8.0", "@yuku-codegen/binding-linux-arm-musl": "0.8.0", "@yuku-codegen/binding-linux-arm64-gnu": "0.8.0", "@yuku-codegen/binding-linux-arm64-musl": "0.8.0", "@yuku-codegen/binding-linux-x64-gnu": "0.8.0", "@yuku-codegen/binding-linux-x64-musl": "0.8.0", "@yuku-codegen/binding-win32-arm64": "0.8.0", "@yuku-codegen/binding-win32-x64": "0.8.0" } }, "sha512-f82SDo8moLRymtdYN7/cz2yRbWE6Pmbmph+mLj24QkIR8ASG/c2nETdHzBhV/3rR/r8K+qmy7+JqQV3thHAm8g=="], - "dts-resolver/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "yuku-parser": ["yuku-parser@0.8.0", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.0", "yuku-ast": "^0.8.0" }, "optionalDependencies": { "@yuku-parser/binding-darwin-arm64": "0.8.0", "@yuku-parser/binding-darwin-x64": "0.8.0", "@yuku-parser/binding-freebsd-x64": "0.8.0", "@yuku-parser/binding-linux-arm-gnu": "0.8.0", "@yuku-parser/binding-linux-arm-musl": "0.8.0", "@yuku-parser/binding-linux-arm64-gnu": "0.8.0", "@yuku-parser/binding-linux-arm64-musl": "0.8.0", "@yuku-parser/binding-linux-x64-gnu": "0.8.0", "@yuku-parser/binding-linux-x64-musl": "0.8.0", "@yuku-parser/binding-win32-arm64": "0.8.0", "@yuku-parser/binding-win32-x64": "0.8.0" } }, "sha512-obrazyE8Cyh79xTQS9wv44khnhvkXG1CDSSy0bg+Pjjla+iXkKXK2b6+LTYXSN3qvVfQxc0MN5qhNKYr9sl3Zw=="], + + "zod": ["zod@4.4.2", "", {}, "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "vite-node/cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "vitest/tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1bb65f1 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,73 @@ +# Architecture + +Why the repo is shaped the way it is. The binding rules are in `CLAUDE.md`; this file explains the parts whose rationale is not evident from the code. + +## Two packages + +A bun workspace. `packages/client` (`@metabase/client`) is the Metabase API client — Zod domain schemas, the HTTP boundary, the OAuth protocol and login flow, the version/capability model. `packages/cli` (`@metabase/cli`) is the publishable CLI built on it. + +The CLI consumes the client as `@metabase/client/` and inlines it at build time through tsdown's `noExternal`, so nothing under `dist/` names the private package. That is why the CLI's manifest never lists it as a dependency. + +`zod` is a **peer** dependency of the client, not a normal one. The client's public API is Zod schemas, so the consumer's zod instance is part of the contract — shipping our own copy would hand embedders two incompatible `ZodType` identities. + +Root devDependencies hoist and are therefore reachable from the client's source tree. The client's dependency budget (`zod` + `semver` + `node:` builtins) is a rule to uphold, not something the resolver rejects. + +The split is internal. To anyone outside the repo this is the CLI, which is why the root `README.md` is the CLI's user-facing command reference rather than a workspace map, and why `packages/client/README.md` — the client's own reference — is reached only from inside `packages/`. npm renders a README only from the package root, so `packages/cli/README.md` is a pointer to the root one: install, `mb --help`, and the link. The reference itself has one home. + +## Resolution: source, not `dist` + +`packages/client`'s `exports` map names built output, and `tsdown` emits one `dist/*.js` + `dist/*.d.ts` pair per entry, deriving its entry list from that map — including by globbing `src/domain/*.ts` and `src/resources/*.ts` for the two wildcards. Adding a file to either directory publishes it automatically. + +Nothing in this workspace compiles that output. The root `tsconfig.json`'s `paths` sends `@metabase/client/` to `packages/client/src/` for the type-checker, for bun, and for the rolldown pass behind both tsdown builds; `vitest.workspace.ts` carries the same mapping as a `resolve.alias`. + +**A tool that consults neither resolves the export map and reads `dist`, which is stale the moment anyone edits the source.** Give a new tool the mapping when you add it — with `dist` present, a lost mapping fails silently rather than loudly. + +## What the exports map costs + +Because the entry list is derived by globbing, every filename under `domain/` and `resources/` is a published specifier: renaming a file is a breaking change for consumers, and internal helpers in those directories are public whether or not anyone outside imports them. This is a publish-shape decision, not a cleanup — settle it before `private: true` comes off. + +## Process state belongs to the CLI + +The client never touches `process`. Signals reach it from its caller: `ClientOptions.signal` is composed into every request alongside per-request signals and the timeout, and the package registers no process signal handler. + +TLS trust follows the same split. `trustSystemCa()` merges the OS trust store into Node's default CA list so a Metabase behind a corporate proxy or a local development CA verifies without the user setting `NODE_USE_SYSTEM_CA=1`. It mutates global TLS state, which is a CLI's prerogative and not a library's — hence `node:tls` appears nowhere in `packages/client`. An embedder sees Node's bundled CA list unless it opts into more. + +The CLI entry owns the only SIGINT handler. It aborts `interruptSignal` so in-flight work unwinds through the error boundary as an `AbortError` (exit 130), and holds a deadline after which the process ends itself at that code — registering any SIGINT listener removes Node's default terminate-on-Ctrl-C. + +## Retries are idempotency-aware + +GET/HEAD/OPTIONS retry on retryable status codes, network failures and timeouts, and earn one attempt on a fresh socket when the first try hit a pooled connection the peer had already reaped — even with retries disabled. + +POST/PUT/PATCH/DELETE retry on none of it. Metabase offers no idempotency-key framework, so a lost connection cannot prove the write never landed, and a resend would double-create. Callers who know an endpoint tolerates a resend opt back in via `idempotent`. + +## Pagination invariants + +`paginatePages` is the limit/offset iterator over Metabase list endpoints. It yields `Page` (`{ items, total }`) rather than a bare array so the server's count reaches the caller instead of being spent on loop control. + +A walk ends on an empty page, on a server count the rows corroborate, or on the caller's `max`. It does **not** end on a short page, which can equally come from a server-side page cap or a filter applied after the limit; and it does not end on a count the rows in hand already exceed, which is a count the server has disproved. + +An endpoint returning the same first row at two different offsets is ignoring `offset`. The walk refuses with a `ConfigError` whether or not a count bounds it: a walk that cannot advance either requests forever or serves the same rows twice, and neither is a listing. + +At the CLI layer the same principle governs `has_more`. Over-reporting costs one wasted request; under-reporting strands rows the caller can never ask for again. So a count that contradicts the rows in hand is the count that is wrong, and only a source that ran dry may report `has_more: false`. + +Truncation by the output byte cap recomputes `has_more` / `next_offset`, making a capped result a resumption point. An envelope pointing the caller back at the offset it just used would livelock an agent, so a window that returned no rows reports no continuation. + +## Capabilities and preflight + +The minimum supported server is Metabase v0.58, which is why `{ minVersion: 58 }` is the baseline and is treated as "no gating" — no probe, no enforcement. + +The server version and token features are probed once on `auth login` / `auth list` and cached in the profile record. A non-baseline command preflights against that cache and throws `CapabilityError` (exit 2) on a mismatch, or warns and proceeds when the version is unknown. `--skip-preflight` or `MB_CLI_SKIP_PREFLIGHT=1` bypasses it. + +To find the right `minVersion` for a new endpoint, validate against `../metabase` at `origin/release-x.58.x` — route files `src/metabase/api_routes/routes.clj` and `enterprise/backend/src/metabase_enterprise/api_routes/routes.clj`. Token-feature keys are the underscored map keys in `src/metabase/premium_features/settings.clj`. + +## The e2e stack + +The suite drives the built binary against a real Metabase in docker compose, with no mocks. `scripts/e2e-matrix.ts` namespaces the compose project and its volumes per stack, so each matrix entry gets its own server, app-db volume, bootstrap artifact and snapshot. `bun run e2e:up` sets no project name and therefore always targets the default stack's containers, whatever `METABASE_CLI_E2E_STACK` says — reach for `e2e:matrix --stack=` when you need a genuinely separate server. + +The bootstrap is idempotent and reuses its artifact while the stored key still authenticates, so iteration cost is test execution rather than setup. It refuses to seed a server that already holds seed content, because seeding twice duplicates every entity. The artifact and snapshot are working-tree state while the app-db is a docker volume, so a fresh worktree or a `git clean -x` puts them out of step; `bun run e2e:down && bun run e2e:up && bun run e2e:bootstrap` is the way back. + +A green summary reports the lanes that ran. The closing coverage block reports the ones that did not and why — locally, absent an EE license token, that is every `remote_sync`- and `library`-gated lane. When pasting a run as evidence, paste that block too. + +## Generated and vendored files + +`packages/cli/src/core/schema/data/` holds the vendored `@metabase/representations` common JSON schemas, written by `scripts/sync-representations.ts` and reaching the CLI only as opaque `import … with { type: "json" }` payloads. The directory is in `.oxfmtrc.json`'s `ignorePatterns`: it is a generated artifact whose writer owns its bytes, and a formatter that reformats it turns every regeneration into a diff the other tool undoes. A generator writing under a formatted tree either emits the formatter's exact output or joins that list. diff --git a/package.json b/package.json index 48a9e98..c056acd 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,14 @@ { - "name": "@metabase/cli", - "version": "0.2.2", - "description": "Metabase CLI", - "license": "AGPL-3.0", - "repository": { - "type": "git", - "url": "git+https://github.com/metabase/mb-cli.git" - }, - "bin": { - "mb": "./dist/cli.mjs" - }, - "files": [ - "dist", - "skills", - "skill-data", - ".claude-plugin" + "name": "metabase-cli-monorepo", + "private": true, + "workspaces": [ + "packages/*" ], "type": "module", - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, "scripts": { - "build": "tsdown", - "dev": "tsdown --watch", - "start": "node dist/cli.mjs", + "build": "bun run --filter @metabase/client build && bun run --filter @metabase/cli build", + "dev": "bun run --filter @metabase/cli dev", + "start": "bun run --filter @metabase/cli start", "test": "vitest run --project unit", "test:watch": "vitest --project unit", "test:e2e": "vitest run --project e2e", @@ -34,27 +18,19 @@ "e2e:bootstrap": "bun tests/e2e/setup/bootstrap.ts", "e2e:matrix": "bun scripts/e2e-matrix.ts", "e2e:logs": "docker compose -f tests/e2e/docker-compose.yml logs -f metabase", + "check": "bun run typecheck && bun run lint && bun run format:check && bun run test && bun run lint:skills", + "check:fix": "bun run lint:fix && bun run format", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:fix": "oxlint --fix", - "lint:skills": "uvx skillsaw lint skill-data/ --strict", + "lint:skills": "uvx skillsaw@0.17.0 lint packages/cli/skill-data/ --strict", "format": "oxfmt", "format:check": "oxfmt --check", - "sync:representations": "bun run scripts/sync-representations.ts", - "prepublishOnly": "tsdown && publint" - }, - "dependencies": { - "@clack/prompts": "^0.8.2", - "@napi-rs/keyring": "^1.3.0", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "citty": "^0.2.2", - "cli-table3": "^0.6.5", - "semver": "^7.8.0", - "yaml": "^2.8.4", - "zod": "^4.0.0" + "sync:representations": "bun run scripts/sync-representations.ts" }, "devDependencies": { + "@clack/core": "^0.3.5", + "@metabase/client": "workspace:*", "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.0", "@types/semver": "^7.7.1", @@ -64,11 +40,13 @@ "oxfmt": "^0.47.0", "oxlint": "^1.62.0", "publint": "^0.3.0", - "tsdown": "^0.9.0", + "tsdown": "^0.22.14", "typescript": "^5.7.2", - "vitest": "^2.1.8" + "unrun": "^0.3.1", + "vitest": "^2.1.8", + "zod": "^4.0.0" }, "engines": { - "node": ">=20.6" + "node": "^20.19.0 || >=22.12.0" } } diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..1aba39e --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,11 @@ +# @metabase/cli + +Command-line client for Metabase — the `mb` binary. + +```sh +npm install -g @metabase/cli +mb --help +``` + +Commands, authentication, output flags and environment variables are documented at +. diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..a1c3b38 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,44 @@ +{ + "name": "@metabase/cli", + "version": "0.3.0", + "description": "Metabase CLI", + "license": "AGPL-3.0", + "repository": { + "type": "git", + "url": "git+https://github.com/metabase/mb-cli.git", + "directory": "packages/cli" + }, + "bin": { + "mb": "./dist/cli.mjs" + }, + "files": [ + "dist", + "skills", + "skill-data" + ], + "type": "module", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "start": "node dist/cli.mjs", + "prepublishOnly": "tsdown && publint && ! grep -rE \"(from|import|require)[[:space:]]*\\(?['\\\"]@metabase\" dist/" + }, + "dependencies": { + "@clack/prompts": "^0.8.2", + "@napi-rs/keyring": "^1.3.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "citty": "^0.2.2", + "cli-table3": "^0.6.5", + "semver": "^7.8.0", + "yaml": "^2.8.4", + "zod": "^4.0.0" + }, + "engines": { + "node": ">=20.6" + } +} diff --git a/skill-data/core/SKILL.md b/packages/cli/skill-data/core/SKILL.md similarity index 81% rename from skill-data/core/SKILL.md rename to packages/cli/skill-data/core/SKILL.md index caa954c..2d91bca 100644 --- a/skill-data/core/SKILL.md +++ b/packages/cli/skill-data/core/SKILL.md @@ -23,7 +23,7 @@ The conventions below — auth, flags, output, body input — hold across **ever **The agent does not log in for the user.** Authentication is the human's job — they pick the base URL, paste credentials, and store them as a named profile. The agent checks what profiles exist, asks which to use, and passes `--profile ` through every command. ```bash -mb auth list --json # → {data:[{profile,url,authenticated,status,…}], returned, total} +mb auth list --json # → list envelope; data is [{profile,url,authenticated,status,…}] mb auth status --json # → {profile, present, url} for the default profile mb auth status --profile --json # health probe for one profile ``` @@ -60,26 +60,29 @@ Every list/get verb supports the same output flags: - `--json` — emit the full JSON envelope, safe for `jq`. Default is human-readable text. - `--full` — include every field (the compact projection is the default, and is the agent-facing contract). - `--fields a,b.c.d` — project specific dot-paths. Mutually exclusive with `--full`. **Paths are relative to each `data[]` item on list verbs, and to the root on single-item verbs.** So it's `--fields id,name` on `… list` / `database schema-tables` (`data.id` and `data[].id` both fail with `unknown field path: "data.id"`), and `--fields id,name,display` on `card get`, `--fields data.rows` on `mb query` (whose `data` is an object). -- `--max-bytes ` — cap **list** output size (drops trailing items, sets `truncated`). Default 24576 (sized to fit under agent-harness tool-output limits); `0` disables. Single-item commands (`get`) never truncate — when their output exceeds the cap they throw a `ConfigError` (exit 2: "output is N bytes, over the M-byte --max-bytes cap; …") whose tail names the remedy: on schema-shaped commands it is the exact narrower command to run instead — follow it rather than raising the cap. +- `--max-bytes ` — cap output size. Default 24576 (sized to fit under agent-harness tool-output limits); `0` disables. On a list it drops trailing items and sets `truncated` (see below). Single-item commands (`get`) never truncate — over the cap they throw a `ConfigError` (exit 2: "output is N bytes, over the M-byte --max-bytes cap; …") whose tail names the remedy: on schema-shaped commands it is the exact narrower command to run instead — follow it rather than raising the cap. - JSON output is a single line when stdout is piped (pretty-printed only at a TTY) — always parse it, never scrape by line position. -List envelope shape (metadata first, so counts and the truncation marker survive if a consumer cuts the tail): - -```json -{ - "returned": 10, - "total": 42, - "limit": 50, - "truncated": { "reason": "max_bytes", "bytes": 123456 }, - "data": [ - /* items */ - ] -} -``` +## List windows and resumption + +Every list verb takes `--limit ` (items this call returns) and `--offset ` (where the window starts, default `0`), and answers with one envelope, metadata first so the counts and the resumption point survive a cut tail: `{returned, offset, limit?, total, has_more, next_offset, truncated?, data}`. `truncated` is `{reason: "max_bytes", bytes: N}`. + +- **`has_more` decides whether to keep going — never compare counts.** `total` is the server's count on endpoints that report one and `null` on those that don't, so arithmetic over it is not a termination condition. +- **To continue, pass `next_offset` back as `--offset`.** When `has_more` is true `next_offset` is past the offset you sent, so the loop advances; when false the walk is over and `next_offset` is `null`. +- **`truncated` means the byte cap cut the output, not that the data ran out.** `has_more`/`next_offset` are recomputed to the cut point, so a capped list resumes like any window. Its `bytes` is what the untruncated answer would have measured, so it sizes the work left rather than the reply you hold. Narrow rows with `--fields` rather than raising `--max-bytes` — a bigger cap spends context on fields you didn't ask for, and the cap counts only what you asked for, so `--fields` buys rows directly. A capped list always returns at least one row; when not even one fits it exits 2 with "the smallest response this list can produce is N bytes, over the M-byte --max-bytes cap; …". +- `limit` is echoed only when you passed `--limit` — except `mb search`, which defaults to `--limit 20` (an unbounded search is expensive server-side) and so always reports one. On nouns the server doesn't page, one large `--limit` with narrow `--fields` is a single request; many small `--offset` hops are one request each. -`truncated` is present only when the cap dropped trailing items. +The whole walk, literally: -`total` is best-effort and may be `null` (empty / permissions-filtered collections, or `--limit` early-stop); use `returned` for the count you got and `data.length` for the rendered slice. +```bash +offset=0 +while : ; do + out=$(mb table list --db-id 1 --limit 50 --offset "$offset" --fields id,name --profile --json) + echo "$out" | jq -c '.data[]' + [ "$(echo "$out" | jq -r '.has_more')" = "true" ] || break + offset=$(echo "$out" | jq -r '.next_offset') +done +``` ## Body input (create / update / run) @@ -87,10 +90,10 @@ Verbs that take a payload accept it from one of four sources, **first non-empty 1. `--body ''` 2. `--file ` — JSON file -3. stdin (auto-detected when piped, or explicit `--stdin` where supported) +3. stdin (auto-detected when piped; `--file -` names it explicitly) 4. positional argument -Exactly one required; passing two of `--body` / `--file` / `--stdin` is rejected with a `ConfigError`. +Exactly one required; passing more than one of `--body` / `--file` / a positional argument is rejected with a `ConfigError`. ```bash cat > ./.scratch/body.json <<'EOF' @@ -122,18 +125,18 @@ mb transform --help --json | jq -r '.commands[].command' # verbs under "transfo Routine verb shapes (list / get / create / update), every flag, and output schemas live in each command's `--help` (add `--json` for output schemas). Below is only what help does _not_ tell you: footguns and non-obvious behaviors. -- **db traversal: the hydration ladder.** Start with `database get --include tables` — the compact table map (id, name, schema, description per table), one call that fits most databases. Pick the relevant tables, then `table fields ` per table (bounded: fields are per-table). `--include tables.fields` is the full rollup — small databases only; when either outgrows the cap the error message names the next command down the ladder. Hundreds of tables? Traverse by schema (`database schemas ` → `database schema-tables `) or look tables up by name (`search --models table --db-id --limit 10`). `sync-schema` / `rescan-values` queue async work and return `{status:"ok"}` immediately; `sync-schema --wait` blocks until `initial_sync_status: complete`. +- **db traversal: the hydration ladder.** Start with `database get --include tables` — the compact table map (id, name, schema, description per table), one call that fits most databases. Pick the relevant tables, then `table fields ` per table (bounded: fields are per-table). `--include tables.fields` is the full rollup — small databases only. Hundreds of tables? Traverse by schema (`database schemas ` → `database schema-tables `) or look tables up by name (`search --models table --db-id --limit 10`). `sync-schema` / `rescan-values` queue async work and return `{status:"ok"}` immediately; `sync-schema --wait` blocks until `initial_sync_status: complete`. - **table fields.** `table get` never returns fields on its own — pass `--include fields` (compact; the underlying query_metadata response also carries FK targets and dimensions, visible under `--full`) or use `table fields ` (list envelope). `table update` patches table-level metadata only; physical columns aren't editable. - **field has no `list`.** Fields are per-table — get them via `table get --include fields`. Never enumerate fields across a whole db (context blow-up). `field summary` is live cardinality `{field_id, count, distincts}`; `field values` is the cached distinct set (`has_more_values: true` ⇒ truncated cache). `field update` patches metadata only (`base_type` isn't editable) — this is where you set a column's `semantic_type` or foreign-key target. - **upload (CSV → tables).** `upload csv --file ` creates a new table + model (prints `{model_id, table_id}`); `upload append ` / `upload replace --file ` add to / overwrite a table **previously created by upload** (columns must match). The destination db+schema is admin-configured, not per-call — check with `mb setting get uploads-settings --json` (`db_id: null` ⇒ uploads off/unconfigured; needs admin to read). `--collection ` only sets the model's collection. Max 50 MB. Errors: **"The uploads database is not configured."** = no db has uploads enabled; **"Uploads are not enabled."** = the append/replace target isn't an uploaded table. - **card.** `dataset_query` is the **flat** `mbql/query` value, not a legacy `{type:"query",query:…}` envelope (→ `mbql`). `--export-format csv|xlsx` streams the raw export (pipe to a file), bypassing the JSON envelope. `archive` is the only delete; unarchive with `update --body '{"archived":false}'`. `visualization_settings` keys are scoped by `display` and aren't pre-flighted — see `visualization`. - **dashboard.** Dashcards round-trip through `PUT /api/dashboard/:id` (no per-dashcard endpoint): `update-dashcard ` patches one safely; `update --body '{"dashcards":[…]}'` replaces the whole set (omitted ids are deleted server-side; negative ids for new cards). `create` accepts the **same** `dashcards` array in its initial body — lay out the whole dashboard in one call: negative ids for new cards, and `card_id:null` plus a `visualization_settings.virtual_card` block (`{display:"text"|"heading"|"link"|…}`) for non-question cards. `create`/`update` pre-flight every positive `card_id` and exit **2** with `{ok:false,errors:[…]}` on a bad ref (non-bypassable). `dashboard get ` (or `--full`) hydrates dashcards/tabs; `list` omits them. **The grid is 24 columns wide:** each dashcard's `{col, row, size_x, size_y}` is in grid units — **full-width is `size_x: 24`** (`size_x: 12` is half a row, the usual cause of a card filling only half the width). Keep `col + size_x ≤ 24`, start a full-width stack's `col` at 0, and don't overlap (the server stores collisions as sent — no auto-fix). Layout patterns and per-chart default sizes → the `dashboard` skill; load it before composing any `dashcards` array. -- **dashboard parameters (filters).** A dashboard's `parameters` array holds its filter widgets; they're part of the dashboard record, so read them with `dashboard get --fields parameters --json` (no separate verb). **Editing replaces the _whole_ array** (like dashcards), so it's a read-modify-write loop: pull the current set with `dashboard get --fields parameters --json`, add/change entries, and send the full array back via `dashboard update --body '{"parameters":[…]}'` (or supply it in `create`). Omitting a parameter deletes it. Each parameter is `{id, type, …}`. **`id` is a descriptive slug-like string you pick (e.g. `order_status`), unique within this dashboard — Metabase stores any non-blank string verbatim. Do NOT invent a random/opaque id by guessing; reuse the `slug`. If you genuinely need an opaque id, mint one with `mb uuid` — never fabricate one.** `type` is a **closed enum**; an unlisted value is a hard parse error that echoes the full allowed set back to you: string ops `string/=` `string/!=` `string/contains` `string/does-not-contain` `string/starts-with` `string/ends-with`; number ops `number/=` `number/!=` `number/between` `number/>=` `number/<=`; date `date/single` `date/range` `date/relative` `date/month-year` `date/quarter-year` `date/all-options`; plus `category`, `id`, `boolean/=`, `temporal-unit`, and bare `number`/`text`/`date`/`boolean`. A parameter only filters a card once it is **mapped**: each dashcard's `parameter_mappings` is `[{parameter_id, target}]` where `parameter_id` must match a parameter's `id` exactly, and `target` is `["dimension", ["field", , null]]` for an MBQL card column, `["dimension", ["template-tag", ""]]` for a native field-filter tag, or `["variable", ["template-tag", ""]]` for a native raw-value tag. Populate a dropdown with `values_source_type`: `"static-list"` + `values_source_config.values`, or `"card"` + `{card_id, value_field, label_field}`; omit it to pull live distinct values from the mapped field. `dashboard parameter-values [--query ]` fetches those selectable values (`{values, has_more_values}`); `--query` is a case-insensitive substring search. +- **dashboard parameters (filters).** A dashboard's `parameters` array holds its filter widgets; they're part of the dashboard record, so read them with `dashboard get --fields parameters --json` (no separate verb). **Editing replaces the _whole_ array** (like dashcards), so it's a read-modify-write loop and omitting a parameter deletes it. A parameter only filters a card once it is **mapped** onto that dashcard's `parameter_mappings` — an unmapped parameter is an inert widget. `type` is a **closed enum**; an unlisted value is a hard parse error that echoes the full allowed set back to you. `dashboard parameter-values [--query ]` fetches a widget's selectable values (`{values, has_more_values}`; `--query` is a case-insensitive substring search). Parameter types, ids, mapping targets, and value sources → the `dashboard` skill; load it before authoring a `parameters` array. - **alert / subscription are two unrelated systems.** `alert` watches one **card** and fires on a send condition (`/api/notification`: a cron string, `channel/email`-prefixed handlers, typed recipients); `subscription` delivers one **dashboard** on a schedule (`/api/pulse`: structured `schedule_type` + hour/day/frame, bare `email` channels, `{id}|{email}` recipients). The bodies are not interchangeable. Both silently deliver nowhere if the server has no SMTP / Slack app — check `mb setting get 'email-configured?'` (quote it; the `?` is a shell glob) before creating either. Their list-valued fields (`handlers`/`subscriptions`, `channels`/`cards`) **replace wholesale** on update, so adding one recipient is a read-modify-write. `mb card alerts ` and `mb dashboard subscriptions ` list what's already attached to a card/dashboard; `archive` deactivates rather than deletes. Load the `notification` skill before authoring either body. - **snippet `--archived` is a swap, not a union** — list returns _either_ active _or_ archived rows, never both. (Same for `--filter archived` on dashboard/collection.) - **segment / measure.** `update` and `archive` require a non-blank `revision_message` (audit-logged); the CLI does not synthesize it on `update`. `archive` defaults to `"Archived via mb CLI"` — override with `--revision-message`. `definition` is a flat MBQL clause (→ `mbql`): segment = a filter, measure = exactly one aggregation. - **timeline / timeline-event.** Timelines are collection-scoped event annotations for time-series charts: a timeline's events render only on time-series questions saved in the **same collection** (`collection_id`; null = root) — sub-collections do **not** inherit, and events never draw on dashboard cards, only in the question (and collection) view. To annotate a question's chart, create the timeline in that question's collection, then add events; an event only draws when its `timestamp` falls inside the chart's displayed time range. Event `create` requires `timestamp` (ISO 8601), `timezone` (IANA name), `time_matters` (true = the time of day is significant, false = date-only), and `timeline_id` — the API never auto-creates a default timeline (that's UI-only). There is no `timeline-event list`; enumerate with `timeline events ` (`--archived` to include archived). Archiving a timeline cascades `archived` to its events; `delete` is a **hard** delete of the timeline and all its events — prefer `archive`. -- **collection ``** accepts four forms only — positive int, `root`, `trash`, or a 21-char entity_id; anything else is a client-side `ConfigError`. `collection items` auto-paginates (cap with `--limit`, which then omits `total`). `collection tree` is **JSON-only** (`--format text` is rejected). A transform collection needs `collection create --namespace transforms`. +- **collection ``** accepts four forms only — positive int, `root`, `trash`, or a 21-char entity_id; anything else is a client-side `ConfigError`. `collection items` pages the server endpoint, pulling only as far as the output cap can show — read `has_more`/`next_offset` to continue. `collection tree` is **JSON-only** (`--format text` is rejected). A transform collection needs `collection create --namespace transforms`. - **setting set** parses the value as **strict JSON**: a string is `'"value"'` (inner quotes), booleans `true`/`false`, numbers bare. Wrong quoting silently errors — confirm with `setting get ` after. `setting get --json` works on every value type (wrapping bare-text responses into `{key, value}`). - **search vs. list.** For plain enumeration of cards/dashboards/collections use the dedicated `… list` verbs; reach for `search --models ` only for ranking against a query string or a cross-resource lookup. - **transform.** Iterate with `transform update `, never `delete` + `create` (keeps the row, `entity_id`, materialized table, and YAML filename — avoids `_2` suffixes and noisy git history). `transform run` needs `--wait` (or `--sync`, which also waits for the output table to register and returns `target_table_id`) or you get only `{run_id, final:null}`. (→ `transform`.) diff --git a/skill-data/dashboard/SKILL.md b/packages/cli/skill-data/dashboard/SKILL.md similarity index 87% rename from skill-data/dashboard/SKILL.md rename to packages/cli/skill-data/dashboard/SKILL.md index eb88e8e..d7b5f95 100644 --- a/skill-data/dashboard/SKILL.md +++ b/packages/cli/skill-data/dashboard/SKILL.md @@ -1,6 +1,6 @@ --- name: dashboard -description: Build Metabase dashboards via the `mb` CLI — lay out dashcards on the 24-column grid (`{col,row,size_x,size_y}` math, per-chart default sizes) and turn cards into a filterable, cross-linked app. Covers wiring filters to cards (parameters + parameter_mappings), the field-filter vs. raw-variable target grammar, linked/cascading filters and their foreign-key requirement, cross-filtering, click-through, multi-series overlays, and tabs. The parameter type enum and whole-array replace semantics live in `core`. Triggers — "build a dashboard from these cards", "my dashboard is squished into half the width", "wire a filter to these cards", "make a filter cascade", "click a bar to filter the other charts", "add a dashboard tab", "add a second series", "why isn't my filter showing". +description: Build Metabase dashboards via the `mb` CLI — lay out dashcards on the 24-column grid (`{col,row,size_x,size_y}` math, per-chart default sizes) and turn cards into a filterable, cross-linked app. Covers wiring filters to cards (parameters + parameter_mappings), the field-filter vs. raw-variable target grammar, linked/cascading filters and their foreign-key requirement, cross-filtering, click-through, multi-series overlays, and tabs. The whole-array replace semantics live in `core`. Triggers — "build a dashboard from these cards", "my dashboard is squished into half the width", "wire a filter to these cards", "make a filter cascade", "click a bar to filter the other charts", "add a dashboard tab", "add a second series", "why isn't my filter showing". allowed-tools: Read, Write, Edit, Bash, AskUserQuestion --- @@ -8,7 +8,7 @@ allowed-tools: Read, Write, Edit, Bash, AskUserQuestion A dashboard starts as cards on a grid; it becomes an **app** when filters drive the cards, charts cross-filter each other, and clicks navigate. This skill owns both: the grid layout and the interactive layer. -**`core` owns the transport mechanics** — the whole-array replace semantics (editing `dashcards` or `parameters` replaces the entire set; omitted entries are deleted; new cards use negative ids), `update-dashcard` for a single safe patch vs. `update --body` for a full replace, the closed `parameter.type` enum, and the `parameter-values` verb. Read it first (`mb skills get core`). **`visualization`** owns each card's chart and the full `click_behavior` key catalog. +**`core` owns the transport mechanics** — the whole-array replace semantics (editing `dashcards` or `parameters` replaces the entire set; omitted entries are deleted; new cards use negative ids), `update-dashcard` for a single safe patch vs. `update --body` for a full replace, and the `parameter-values` verb. Read it first (`mb skills get core`). **`visualization`** owns each card's chart and the full `click_behavior` key catalog. Inspect before you wire: `mb dashboard get --json` hydrates `parameters`, `dashcards`, and `tabs`; `mb dashboard cards ` lists just the dashcards. @@ -47,6 +47,10 @@ A dashboard filter is one entry in the dashboard's `parameters` array **plus** a "target": ["dimension", ["field", 1779, null]] } ``` +**`id` is a slug-like string you pick** (e.g. `order_status`), unique within the dashboard — Metabase stores any non-blank string verbatim, so reuse the `slug` rather than guessing an opaque value. If you genuinely need an opaque id, mint one with `mb uuid`; never fabricate one. + +**`type` is a closed enum** — an unlisted value is a hard parse error that echoes the allowed set back: string ops `string/=` `string/!=` `string/contains` `string/does-not-contain` `string/starts-with` `string/ends-with`; number ops `number/=` `number/!=` `number/between` `number/>=` `number/<=`; date `date/single` `date/range` `date/relative` `date/month-year` `date/quarter-year` `date/all-options`; location `location/city` `location/state` `location/zip_code` `location/country`; plus `category`, `id`, `boolean/=`, `temporal-unit`, and bare `number`/`text`/`date`/`boolean`. + The `target` grammar depends on what the card is: | Card's query | `target` | diff --git a/skill-data/data-workflow/SKILL.md b/packages/cli/skill-data/data-workflow/SKILL.md similarity index 100% rename from skill-data/data-workflow/SKILL.md rename to packages/cli/skill-data/data-workflow/SKILL.md diff --git a/skill-data/data-workflow/references/answering-questions.md b/packages/cli/skill-data/data-workflow/references/answering-questions.md similarity index 100% rename from skill-data/data-workflow/references/answering-questions.md rename to packages/cli/skill-data/data-workflow/references/answering-questions.md diff --git a/skill-data/data-workflow/references/building-clean-tables.md b/packages/cli/skill-data/data-workflow/references/building-clean-tables.md similarity index 100% rename from skill-data/data-workflow/references/building-clean-tables.md rename to packages/cli/skill-data/data-workflow/references/building-clean-tables.md diff --git a/skill-data/data-workflow/references/reusable-definitions.md b/packages/cli/skill-data/data-workflow/references/reusable-definitions.md similarity index 100% rename from skill-data/data-workflow/references/reusable-definitions.md rename to packages/cli/skill-data/data-workflow/references/reusable-definitions.md diff --git a/skill-data/document/SKILL.md b/packages/cli/skill-data/document/SKILL.md similarity index 94% rename from skill-data/document/SKILL.md rename to packages/cli/skill-data/document/SKILL.md index ac7ecfd..db1a827 100644 --- a/skill-data/document/SKILL.md +++ b/packages/cli/skill-data/document/SKILL.md @@ -20,13 +20,13 @@ mb document update --file patch.json --profile --json # PATCH sema mb document archive --profile --json # soft-delete (PUT archived:true) ``` -- `list` returns the standard envelope (`{data, returned, total}`). The compact item is `{id, name, collection_id, archived, creator_id, can_write}` and omits the (potentially huge) `document` body — pull the body with `get --full`. +- `list` returns the standard list envelope (see `core`). The compact item is `{id, name, collection_id, archived, creator_id, can_write}` and omits the (potentially huge) `document` body — pull the body with `get --full`. - `archive` is the only delete, mirroring `card` / `dashboard`. **Unarchive** with `mb document update --body '{"archived":false}'`. - `update` is PATCH — send only the keys you want to change: `name`, `document`, `collection_id`, `collection_position`, `archived`, and `cards` (inline card creation works on update too, not just create — see below). Replacing `document` replaces the **whole** body; there is no partial-node patch. ## Node ids (`_id`) -The editor anchors only these node types with an `_id` (a UUID): `paragraph`, `heading`, `codeBlock`, `orderedList`, `bulletList`, `blockquote`, `cardEmbed`, `supportingText`. **`create`/`update` require a non-empty `_id` on every node of those types** — the CLI validates before sending and rejects a body missing any (`every … node needs a non-empty string _id (mint with mb uuid)`). Other node types (`doc`, `text`, `listItem`, `resizeNode`, `flexContainer`, …) take no `_id` and are left alone. Without them the editor backfills ids when the document opens, which makes a freshly-saved document show a spurious "unsaved changes" prompt. +The editor anchors only these node types with an `_id` (a UUID): `paragraph`, `heading`, `codeBlock`, `orderedList`, `bulletList`, `blockquote`, `cardEmbed`, `supportingText`. **`create`/`update` require a non-empty `_id` on every node of those types** — the CLI validates before sending and rejects a body missing any (`every … node needs a non-empty string _id`). Other node types (`doc`, `text`, `listItem`, `resizeNode`, `flexContainer`, …) take no `_id` and are left alone. Without them the editor backfills ids when the document opens, which makes a freshly-saved document show a spurious "unsaved changes" prompt. Mint the ids with `mb uuid --count --json` (→ `["…", …]`), one per id-bearing node, and set each as that node's `attrs._id`. diff --git a/skill-data/git-sync/SKILL.md b/packages/cli/skill-data/git-sync/SKILL.md similarity index 100% rename from skill-data/git-sync/SKILL.md rename to packages/cli/skill-data/git-sync/SKILL.md diff --git a/skill-data/mbql/SKILL.md b/packages/cli/skill-data/mbql/SKILL.md similarity index 100% rename from skill-data/mbql/SKILL.md rename to packages/cli/skill-data/mbql/SKILL.md diff --git a/skill-data/mbql/references/operators.md b/packages/cli/skill-data/mbql/references/operators.md similarity index 100% rename from skill-data/mbql/references/operators.md rename to packages/cli/skill-data/mbql/references/operators.md diff --git a/skill-data/metadata/SKILL.md b/packages/cli/skill-data/metadata/SKILL.md similarity index 100% rename from skill-data/metadata/SKILL.md rename to packages/cli/skill-data/metadata/SKILL.md diff --git a/skill-data/metadata/references/semantic-types.md b/packages/cli/skill-data/metadata/references/semantic-types.md similarity index 100% rename from skill-data/metadata/references/semantic-types.md rename to packages/cli/skill-data/metadata/references/semantic-types.md diff --git a/skill-data/native-sql/SKILL.md b/packages/cli/skill-data/native-sql/SKILL.md similarity index 100% rename from skill-data/native-sql/SKILL.md rename to packages/cli/skill-data/native-sql/SKILL.md diff --git a/skill-data/native-sql/references/template-tags.md b/packages/cli/skill-data/native-sql/references/template-tags.md similarity index 100% rename from skill-data/native-sql/references/template-tags.md rename to packages/cli/skill-data/native-sql/references/template-tags.md diff --git a/skill-data/notification/SKILL.md b/packages/cli/skill-data/notification/SKILL.md similarity index 100% rename from skill-data/notification/SKILL.md rename to packages/cli/skill-data/notification/SKILL.md diff --git a/skill-data/transform/SKILL.md b/packages/cli/skill-data/transform/SKILL.md similarity index 99% rename from skill-data/transform/SKILL.md rename to packages/cli/skill-data/transform/SKILL.md index 1214895..b7fd015 100644 --- a/skill-data/transform/SKILL.md +++ b/packages/cli/skill-data/transform/SKILL.md @@ -74,7 +74,7 @@ Columns and types are inferred from the result set; change the SELECT shape and ## Inspect runs and cancel an in-flight run ```bash -# Recent runs across all transforms (drains all pages by default; cap with --limit): +# Recent runs across all transforms (server-paged; continue with --offset ): mb transform runs --profile --json mb transform runs --transform-id --limit 10 --profile --json diff --git a/skill-data/visualization/SKILL.md b/packages/cli/skill-data/visualization/SKILL.md similarity index 100% rename from skill-data/visualization/SKILL.md rename to packages/cli/skill-data/visualization/SKILL.md diff --git a/skill-data/visualization/references/settings.md b/packages/cli/skill-data/visualization/references/settings.md similarity index 100% rename from skill-data/visualization/references/settings.md rename to packages/cli/skill-data/visualization/references/settings.md diff --git a/skills/metabase-cli/SKILL.md b/packages/cli/skills/metabase-cli/SKILL.md similarity index 100% rename from skills/metabase-cli/SKILL.md rename to packages/cli/skills/metabase-cli/SKILL.md diff --git a/tests/structure.test.ts b/packages/cli/src/architecture.test.ts similarity index 74% rename from tests/structure.test.ts rename to packages/cli/src/architecture.test.ts index c8c9cd2..6d9c966 100644 --- a/tests/structure.test.ts +++ b/packages/cli/src/architecture.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -const SRC_ROOT = resolve(fileURLToPath(import.meta.url), "..", "..", "src"); +const SRC_ROOT = resolve(fileURLToPath(import.meta.url), ".."); interface SourceFile { relPath: string; @@ -39,12 +39,6 @@ const RULES: StructureRule[] = [ allowedIn: ["output/"], scope: "all", }, - { - description: "wait loops (setTimeout(...resolve)) must only appear in src/runtime/poll.ts", - pattern: /setTimeout\([^)]*\bresolve\b/, - allowedIn: ["runtime/poll.ts"], - scope: "all", - }, { description: "child_process import/use must only appear in src/runtime/process.ts", pattern: /child_process\.spawn|from\s+["']node:child_process["']/, @@ -52,21 +46,28 @@ const RULES: StructureRule[] = [ scope: "all", }, { - description: "JSON.parse must only appear in src/runtime/json.ts", + description: "JSON.parse is forbidden in the CLI (the client's json.ts owns it)", pattern: /JSON\.parse\(/, - allowedIn: ["runtime/json.ts"], + allowedIn: [], scope: "all", }, { - description: "direct fetch calls must only appear in src/core/http/", + description: "direct fetch calls must only appear in src/core/npm-registry.ts", pattern: /\bfetch\s*\(|globalThis\.fetch/, - allowedIn: ["core/http/"], + allowedIn: ["core/npm-registry.ts"], scope: "all", }, { - description: "new URL() must only appear in src/core/http/ or src/core/url.ts", + description: "new URL() is forbidden in the CLI (the client's url.ts owns URL handling)", pattern: /\bnew URL\(/, - allowedIn: ["core/http/", "core/url.ts"], + allowedIn: [], + scope: "all", + }, + { + description: + "hand-rolled setTimeout wait loops are forbidden in the CLI (await node:timers/promises)", + pattern: /setTimeout\([^)]*\bresolve\b/, + allowedIn: [], scope: "all", }, { @@ -76,8 +77,8 @@ const RULES: StructureRule[] = [ scope: "commands", }, { - description: "src/output/ must not import from src/core/http/ (use src/core/errors)", - pattern: /from\s+["'][./]+core\/http\b/, + description: "src/output/ must not import the HTTP layer (use @metabase/client/errors)", + pattern: /from\s+["']@metabase\/client\/http\//, allowedIn: [], scope: "output", }, @@ -87,6 +88,18 @@ const RULES: StructureRule[] = [ allowedIn: [], scope: "all", }, + { + description: "commands must not name an /api/ path literal", + pattern: /\/api\//, + allowedIn: [], + scope: "commands", + }, + { + description: "commands must not drive the HTTP transport directly", + pattern: /\b(?:requestParsed|requestRaw|requestStream|paginatePages)\b/, + allowedIn: [], + scope: "commands", + }, ]; function listSourceFiles(): SourceFile[] { @@ -131,6 +144,11 @@ function inScope(relPath: string, scope: RuleScope): boolean { describe("layering policy", () => { const files = listSourceFiles(); + it("walks the CLI source tree", () => { + const roots = files.filter((file) => !file.relPath.includes("/")).map((file) => file.relPath); + expect(roots.toSorted()).toEqual(["cli.ts", "main.ts"]); + }); + for (const rule of RULES) { it(rule.description, () => { const offenders = files diff --git a/src/cli.ts b/packages/cli/src/cli.ts similarity index 86% rename from src/cli.ts rename to packages/cli/src/cli.ts index 1056c2c..ee926f1 100644 --- a/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,17 +2,20 @@ import { runMain } from "citty"; import type { ArgsDef, CommandDef } from "citty"; +import { ConfigError } from "@metabase/client/errors"; + import { hoistGlobalFlags } from "./commands/global-flags"; -import { ConfigError } from "./core/errors"; -import { trustSystemCa } from "./core/http/system-ca"; +import { trustSystemCa } from "./core/system-ca"; import main from "./main"; import { reportError } from "./output/error"; import { findUnknownCommand, resolveBreadcrumb, showUsage, showUsageJson } from "./output/help"; +import { installInterruptHandler } from "./runtime/interrupt"; const HELP_FLAGS: ReadonlySet = new Set(["--help", "-h"]); const JSON_HELP_FLAG = "--json"; async function run(): Promise { + installInterruptHandler((code) => process.exit(code)); trustSystemCa(); const rawArgs = hoistGlobalFlags(process.argv.slice(2)); const wantsJsonHelp = rawArgs.includes(JSON_HELP_FLAG); diff --git a/src/commands/alert/archive.ts b/packages/cli/src/commands/alert/archive.ts similarity index 84% rename from src/commands/alert/archive.ts rename to packages/cli/src/commands/alert/archive.ts index 5790926..fcf05b7 100644 --- a/src/commands/alert/archive.ts +++ b/packages/cli/src/commands/alert/archive.ts @@ -1,10 +1,10 @@ -import { Notification, notificationView } from "../../domain/notification"; +import { Notification } from "@metabase/client/domain/notification"; +import { notificationView } from "../../output/views/notification"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { patchAlert } from "./patch"; import { describeAlert } from "./summary"; export default defineMetabaseCommand({ @@ -26,7 +26,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const archived = await patchAlert(client, id, { active: false }); + const archived = await client.notification.archive(id); renderSummary(archived, notificationView, describeAlert("Archived", archived), ctx); }, }); diff --git a/src/commands/alert/create.ts b/packages/cli/src/commands/alert/create.ts similarity index 89% rename from src/commands/alert/create.ts rename to packages/cli/src/commands/alert/create.ts index 9f33302..3d73298 100644 --- a/src/commands/alert/create.ts +++ b/packages/cli/src/commands/alert/create.ts @@ -1,4 +1,5 @@ -import { Notification, NotificationCreateInput, notificationView } from "../../domain/notification"; +import { Notification, NotificationCreateInput } from "@metabase/client/domain/notification"; +import { notificationView } from "../../output/views/notification"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -33,10 +34,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, NotificationCreateInput); const client = await getClient(); - const created = await client.requestParsed(Notification, "/api/notification", { - method: "POST", - body, - }); + const created = await client.notification.create(body); renderSummary(created, notificationView, describeAlert("Created", created), ctx); }, }); diff --git a/src/commands/alert/get.ts b/packages/cli/src/commands/alert/get.ts similarity index 82% rename from src/commands/alert/get.ts rename to packages/cli/src/commands/alert/get.ts index cd9d8d0..0cdb250 100644 --- a/src/commands/alert/get.ts +++ b/packages/cli/src/commands/alert/get.ts @@ -1,11 +1,10 @@ -import { Notification, notificationView } from "../../domain/notification"; +import { Notification } from "@metabase/client/domain/notification"; +import { notificationView } from "../../output/views/notification"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { fetchAlert } from "./patch"; - export default defineMetabaseCommand({ meta: { name: "get", description: "Get a question alert by id" }, details: @@ -22,7 +21,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const alert = await fetchAlert(client, id); + const alert = await client.notification.get(id); renderItem(alert, notificationView, ctx); }, }); diff --git a/src/commands/alert/index.ts b/packages/cli/src/commands/alert/index.ts similarity index 100% rename from src/commands/alert/index.ts rename to packages/cli/src/commands/alert/index.ts diff --git a/src/commands/alert/list.ts b/packages/cli/src/commands/alert/list.ts similarity index 69% rename from src/commands/alert/list.ts rename to packages/cli/src/commands/alert/list.ts index d8d48be..d9152e3 100644 --- a/src/commands/alert/list.ts +++ b/packages/cli/src/commands/alert/list.ts @@ -1,19 +1,12 @@ -import { z } from "zod"; - -import { - CARD_PAYLOAD_TYPE, - Notification, - NotificationCompact, - notificationView, -} from "../../domain/notification"; +import { NotificationCompact } from "@metabase/client/domain/notification"; +import { notificationView } from "../../output/views/notification"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseOptionalInteger } from "../parse-integer"; import { defineMetabaseCommand } from "../runtime"; -const NotificationApiList = z.array(Notification); - export const AlertListEnvelope = listEnvelopeSchema(NotificationCompact); export default defineMetabaseCommand({ @@ -23,6 +16,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, cardId: { type: "string", description: "Only alerts watching this card id", alias: "card-id" }, @@ -53,15 +47,12 @@ export default defineMetabaseCommand({ const creatorId = parseOptionalInteger(args.creatorId, { name: "creator-id", min: 1 }); const recipientId = parseOptionalInteger(args.recipientId, { name: "recipient-id", min: 1 }); const client = await getClient(); - const items = await client.requestParsed(NotificationApiList, "/api/notification", { - query: { - payload_type: CARD_PAYLOAD_TYPE, - card_id: cardId ?? undefined, - creator_id: creatorId ?? undefined, - recipient_id: recipientId ?? undefined, - include_inactive: args.includeInactive || undefined, - }, + const { data, total } = await client.notification.list({ + card_id: cardId ?? undefined, + creator_id: creatorId ?? undefined, + recipient_id: recipientId ?? undefined, + include_inactive: args.includeInactive || undefined, }); - renderList(wrapList(items), notificationView, ctx); + renderList(windowList(data, ctx.range, total), notificationView, ctx); }, }); diff --git a/src/commands/alert/send.ts b/packages/cli/src/commands/alert/send.ts similarity index 80% rename from src/commands/alert/send.ts rename to packages/cli/src/commands/alert/send.ts index 37e2cf3..c31354f 100644 --- a/src/commands/alert/send.ts +++ b/packages/cli/src/commands/alert/send.ts @@ -1,18 +1,16 @@ import { z } from "zod"; -import type { ResourceView } from "../../domain/view"; +import type { ResourceView } from "../../output/view"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { fetchAlert } from "./patch"; - export const AlertSendResult = z.object({ id: z.number().int(), sent: z.boolean(), }); -export type AlertSendResultJson = z.infer; +type AlertSendResultJson = z.infer; const alertSendResultView: ResourceView = { compactPick: AlertSendResult, @@ -38,11 +36,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - await fetchAlert(client, id); - await client.requestRaw(`/api/notification/${id}/send`, { - method: "POST", - expectContentType: "binary", - }); + await client.notification.send(id); renderSummary({ id, sent: true }, alertSendResultView, `Sent alert ${id}.`, ctx); }, }); diff --git a/src/commands/alert/summary.ts b/packages/cli/src/commands/alert/summary.ts similarity index 75% rename from src/commands/alert/summary.ts rename to packages/cli/src/commands/alert/summary.ts index 2a49164..9ea9d90 100644 --- a/src/commands/alert/summary.ts +++ b/packages/cli/src/commands/alert/summary.ts @@ -1,4 +1,4 @@ -import type { Notification } from "../../domain/notification"; +import type { Notification } from "@metabase/client/domain/notification"; export function describeAlert(verb: string, alert: Notification): string { if (alert.payload === null) { diff --git a/src/commands/alert/update.ts b/packages/cli/src/commands/alert/update.ts similarity index 88% rename from src/commands/alert/update.ts rename to packages/cli/src/commands/alert/update.ts index f7c8dbd..ed36c1e 100644 --- a/src/commands/alert/update.ts +++ b/packages/cli/src/commands/alert/update.ts @@ -1,4 +1,5 @@ -import { Notification, NotificationUpdateInput, notificationView } from "../../domain/notification"; +import { Notification, NotificationUpdateInput } from "@metabase/client/domain/notification"; +import { notificationView } from "../../output/views/notification"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -6,7 +7,6 @@ import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { patchAlert } from "./patch"; import { describeAlert } from "./summary"; export default defineMetabaseCommand({ @@ -36,7 +36,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, NotificationUpdateInput); const client = await getClient(); - const updated = await patchAlert(client, id, body); + const updated = await client.notification.update(id, body); renderSummary(updated, notificationView, describeAlert("Updated", updated), ctx); }, }); diff --git a/src/commands/auth/index.ts b/packages/cli/src/commands/auth/index.ts similarity index 100% rename from src/commands/auth/index.ts rename to packages/cli/src/commands/auth/index.ts diff --git a/src/commands/auth/list.test.ts b/packages/cli/src/commands/auth/list.test.ts similarity index 67% rename from src/commands/auth/list.test.ts rename to packages/cli/src/commands/auth/list.test.ts index 2ba8931..93971cf 100644 --- a/src/commands/auth/list.test.ts +++ b/packages/cli/src/commands/auth/list.test.ts @@ -2,15 +2,18 @@ import { runCommand } from "citty"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ZodType } from "zod"; -import type { Credential } from "../../core/auth/credential"; -import { parseJson } from "../../runtime/json"; +import type { Credential } from "@metabase/client/auth/credential"; +import { parseJson } from "@metabase/client/json"; import type { Verification } from "../../core/auth/verify"; -const hoisted = vi.hoisted(() => ({ - store: new Map(), - controls: { broken: false }, - verify: { results: new Map() }, -})); +const hoisted = vi.hoisted(() => { + const probed: string[] = []; + return { + store: new Map(), + controls: { broken: false }, + verify: { results: new Map(), probed }, + }; +}); vi.mock("@napi-rs/keyring", async () => { const { createKeyringMockModule } = await import("../../core/auth/keyring-mock"); @@ -20,6 +23,7 @@ vi.mock("@napi-rs/keyring", async () => { vi.mock("../../core/auth/verify", () => ({ verifyAndProbe: async (url: string, credential: Credential): Promise => { const key = credential.kind === "apiKey" ? credential.apiKey : credential.accessToken; + hoisted.verify.probed.push(key); const result = hoisted.verify.results.get(key); if (result === undefined) { throw new Error(`no verifyAndProbe result configured for credential "${key}"`); @@ -79,6 +83,7 @@ describe("auth list command", () => { beforeEach(() => { hoisted.store.clear(); hoisted.verify.results.clear(); + hoisted.verify.probed.length = 0; home = setupTempConfigHome(); }); @@ -93,7 +98,10 @@ describe("auth list command", () => { expect(capture.parse(AuthProfileListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -154,6 +162,71 @@ describe("auth list command", () => { ); }); + it("probes only the profiles the window displays", async () => { + hoisted.verify.results.set("k1", successVerify()); + hoisted.verify.results.set("k2", successVerify()); + await writeProfile({ url: "https://staging.example.com", apiKey: "k1" }, "staging"); + await writeProfile({ url: "https://prod.example.com", apiKey: "k2" }, "prod"); + + captureStdout(); + await runCommand(authListCommand, { rawArgs: ["--json", "--limit", "1"] }); + + expect(hoisted.verify.probed).toEqual(["k1"]); + }); + + it("warns only about the profiles the window displays", async () => { + hoisted.verify.results.set("good", successVerify()); + hoisted.verify.results.set("revoked", { + ok: false, + which: "user", + kind: "auth", + status: 401, + message: "Invalid or unauthorized API key", + }); + await writeProfile({ url: "https://shown.example.com", apiKey: "good" }, "shown"); + await writeProfile({ url: "https://unseen.example.com", apiKey: "revoked" }, "unseen"); + + captureStdout(); + const stderr = captureStderr(); + await runCommand(authListCommand, { rawArgs: ["--json", "--limit", "1"] }); + + expect(stderr.join("")).toBe(""); + }); + + it("counts every stored profile in the envelope even though it probed one", async () => { + hoisted.verify.results.set("k1", successVerify()); + hoisted.verify.results.set("k2", successVerify()); + await writeProfile({ url: "https://staging.example.com", apiKey: "k1" }, "staging"); + await writeProfile({ url: "https://prod.example.com", apiKey: "k2" }, "prod"); + + const capture = captureStdout(); + await runCommand(authListCommand, { rawArgs: ["--json", "--limit", "1"] }); + + const envelope = capture.parse(AuthProfileListEnvelope); + expect(envelope).toEqual({ + data: [ + { + profile: "staging", + url: "https://staging.example.com", + method: "apiKey", + authenticated: true, + status: "ok", + user: { id: 1, name: "Tester", isAdmin: true }, + version: { tag: "v0.58.7", major: 58, patch: 7 }, + tokenFeatures: null, + lastProbedAt: envelope.data[0]?.lastProbedAt, + lastFailure: null, + }, + ], + returned: 1, + offset: 0, + limit: 1, + total: 2, + has_more: true, + next_offset: 1, + }); + }); + it("preserves the previous lastProbe and apiKey on a failed refresh", async () => { hoisted.verify.results.set("good", successVerify()); await writeProfile({ url: "https://m.example.com", apiKey: "good" }, "stable"); diff --git a/src/commands/auth/list.ts b/packages/cli/src/commands/auth/list.ts similarity index 81% rename from src/commands/auth/list.ts rename to packages/cli/src/commands/auth/list.ts index ff73c5e..84f55a3 100644 --- a/src/commands/auth/list.ts +++ b/packages/cli/src/commands/auth/list.ts @@ -1,5 +1,10 @@ import { z } from "zod"; +import { TokenFeatures } from "@metabase/client/domain/session-properties"; +import { displayUrl } from "@metabase/client/url"; +import type { ServerInfo } from "@metabase/client/version/probe"; +import { ParsedVersion } from "@metabase/client/version/tag"; + import { listProfileRecords, resolveRecordCredential, @@ -8,23 +13,21 @@ import { } from "../../core/auth/storage"; import { verifyAndProbe, type VerifyFailure } from "../../core/auth/verify"; import { createCredentialRefresher } from "../../core/config"; -import { displayUrl } from "../../core/url"; -import type { ServerInfo } from "../../core/version/probe"; import { ProbedUser, profileAuthMethod, ProfileAuthMethod, ProfileLastFailure, - ProfileLastProbe, + type ProfileLastProbe, type ProfileRecord, } from "../../core/auth/profile-record"; -import { TokenFeatures } from "../../domain/session-properties"; -import type { ResourceView } from "../../domain/view"; import { warn } from "../../output/notice"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { ParsedVersionSchema } from "../../core/version/tag"; -import { outputFlags } from "../flags"; +import { type ListEnvelope, listEnvelopeSchema } from "../../output/types"; +import type { ResourceView } from "../../output/view"; +import { windowList } from "../../output/window"; +import { interruptSignal } from "../../runtime/interrupt"; +import { listFlags, outputFlags } from "../flags"; import { renderAuthMethod, renderTimestamp, renderUserRole, renderVersionTag } from "./render"; import { defineMetabaseCommand } from "../runtime"; @@ -35,21 +38,21 @@ const AuthProfileStatus = z.enum([ "server-error", "not-probed", ]); -export type AuthProfileStatusValue = z.infer; +type AuthProfileStatusValue = z.infer; -export const AuthProfile = z.object({ +const AuthProfile = z.object({ profile: z.string(), url: z.string(), method: ProfileAuthMethod, authenticated: z.boolean(), status: AuthProfileStatus, user: ProbedUser.nullable(), - version: ParsedVersionSchema.nullable(), + version: ParsedVersion.nullable(), tokenFeatures: TokenFeatures.nullable(), lastProbedAt: z.iso.datetime().nullable(), lastFailure: ProfileLastFailure.nullable(), }); -export type AuthProfileJson = z.infer; +type AuthProfileJson = z.infer; export const AuthProfileListEnvelope = listEnvelopeSchema(AuthProfile); @@ -82,18 +85,21 @@ function renderStatus(value: unknown): string { export default defineMetabaseCommand({ meta: { name: "list", description: "List configured authentication profiles" }, capabilities: { minVersion: 58 }, - args: { ...outputFlags }, + args: { ...outputFlags, ...listFlags }, outputSchema: AuthProfileListEnvelope, examples: ["mb auth list", "mb auth list --json"], async run({ ctx }) { - const records = await listProfileRecords(); + // Window the stored records before probing: each probe is a round trip, so `--limit 1` over + // ten profiles should cost one, and the warnings below should name only rows the user saw. + const window = windowList(await listProfileRecords(), ctx.range); // Verify and persist one profile at a time: a verify can refresh+rewrite an expired OAuth // token, so serializing avoids two profiles racing on the shared profiles.json. const items: AuthProfileJson[] = []; - for (const record of records) { + for (const record of window.data) { items.push(await persistAndProject(await verifyOne(record))); } - renderList(wrapList(items), authProfileView, ctx); + const envelope: ListEnvelope = { ...window, data: items }; + renderList(envelope, authProfileView, ctx); for (const item of items) { if (item.status === "auth-failed" && item.lastFailure !== null) { @@ -137,11 +143,10 @@ async function verifyOne(record: ProfileRecord): Promise { } // Pass a refresher so an expired-but-refreshable OAuth profile self-heals on the 401 probe // instead of being reported as auth-failed. - const result = await verifyAndProbe( - resolved.url, - resolved.credential, - createCredentialRefresher(record.name), - ); + const result = await verifyAndProbe(resolved.url, resolved.credential, { + refresh: createCredentialRefresher(record.name), + signal: interruptSignal, + }); if (result.ok) { return { record, diff --git a/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts similarity index 83% rename from src/commands/auth/login.ts rename to packages/cli/src/commands/auth/login.ts index 41cc854..1ad852b 100644 --- a/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,8 +1,13 @@ import { z } from "zod"; -import type { Credential } from "../../core/auth/credential"; -import { oauthLogin } from "../../core/auth/oauth-login"; -import { revokeOAuthCredential } from "../../core/auth/oauth-session"; +import type { Credential } from "@metabase/client/auth/credential"; +import { oauthLogin } from "@metabase/client/auth/oauth-login"; +import { revokeOAuthCredential } from "@metabase/client/auth/oauth-session"; +import { ConfigError, errorMessage } from "@metabase/client/errors"; +import { tryDiscoverMetadata, type OAuthServerMetadata } from "@metabase/client/http/oauth"; +import { normalizeUrl } from "@metabase/client/url"; +import { ParsedVersion } from "@metabase/client/version/tag"; + import { consumeKeychainResidualWarning, consumeKeyringDowngradeWarning, @@ -14,32 +19,37 @@ import { writeProbeResult, writeProfile, } from "../../core/auth/storage"; -import { verifyAndProbe, type VerifyFailure } from "../../core/auth/verify"; +import { verifyAndProbe, type VerifyFailure, type VerifyWhich } from "../../core/auth/verify"; import { explicitProfileName, readEnvCredentials } from "../../core/config"; -import { ConfigError, errorMessage } from "../../core/errors"; -import { normalizeUrl } from "../../core/url"; -import { ParsedVersionSchema } from "../../core/version/tag"; import { ProbedUser } from "../../core/auth/profile-record"; -import type { ResourceView } from "../../domain/view"; -import { tryDiscoverMetadata, type OAuthServerMetadata } from "../../core/http/oauth"; +import { OAUTH_CLIENT_NAME, USER_AGENT } from "../../core/user-agent"; import { warn } from "../../output/notice"; import { promptPassword, promptSelect, promptText } from "../../output/prompt"; import { renderSummary } from "../../output/render"; +import { EMPTY_CELL } from "../../output/table"; +import type { ResourceView } from "../../output/view"; +import { interruptSignal } from "../../runtime/interrupt"; import { openBrowser } from "../../runtime/process"; import { readInput } from "../../runtime/input"; import type { CommonContext } from "../context"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { EMPTY_CELL, renderUserName, renderUserRole, renderVersionTag } from "./render"; +import { renderUserName, renderUserRole, renderVersionTag } from "./render"; export const LoginResult = z.object({ profile: z.string(), url: z.string(), authenticated: z.boolean(), user: ProbedUser.nullable(), - version: ParsedVersionSchema.nullable(), + version: ParsedVersion.nullable(), +}); +type LoginResultJson = z.infer; + +// Which of the two reads `verifyAndProbe` runs came back bad, in the user's terms. +const VERIFY_STEP_LABEL: Readonly> = Object.freeze({ + user: "current user", + server: "server properties", }); -export type LoginResultJson = z.infer; const loginView: ResourceView = { compactPick: LoginResult, @@ -133,6 +143,8 @@ export default defineMetabaseCommand({ const credential = await oauthLogin( { baseUrl: url, + userAgent: USER_AGENT, + clientName: OAUTH_CLIENT_NAME, ...(metadata !== null && { metadata }), ...(args.clientId !== undefined && { clientId: args.clientId }), }, @@ -150,7 +162,7 @@ type LoginMethod = "oauth" | "apiKey"; // prompt; not reaching it at all is an error worth stopping on before any credential is collected. async function probeOAuthSupport(url: string): Promise { try { - return await tryDiscoverMetadata(url); + return await tryDiscoverMetadata(url, USER_AGENT); } catch (error) { if (error instanceof ConfigError) { throw error; @@ -177,7 +189,7 @@ async function chooseLoginMethod( if (clientId !== undefined) { return "oauth"; } - return promptSelect({ + const method = await promptSelect({ message: "How do you want to log in?", choices: [ { @@ -193,6 +205,15 @@ async function chooseLoginMethod( ], initialValue: "oauth", }); + // Browser login needs a client id: one the user pre-registered and passed, or one the server + // mints on demand. A server with dynamic registration disabled offers neither, and the client + // raises the same refusal against its own `clientId` option — a spelling no CLI user can type. + if (method === "oauth" && metadata.registration_endpoint === undefined) { + throw new ConfigError( + "this Metabase has dynamic client registration disabled; pass --client-id with a pre-registered native client", + ); + } + return method; } async function promptForApiKey(): Promise { @@ -224,7 +245,7 @@ async function completeLogin( return; } - const result = await verifyAndProbe(url, credential); + const result = await verifyAndProbe(url, credential, { signal: interruptSignal }); if (!result.ok) { await writeProbeFailure(profileName, { kind: result.kind, reason: result.message }); await revokeUnsavedOAuthCredential(url, credential); @@ -260,7 +281,7 @@ async function revokeUnsavedOAuthCredential(url: string, credential: Credential) return; } try { - const revoked = await revokeOAuthCredential(url, credential); + const revoked = await revokeOAuthCredential(url, credential, USER_AGENT); if (!revoked) { warn( "server does not advertise a revocation endpoint; the unsaved tokens remain valid until they expire", @@ -294,7 +315,8 @@ function announceAuthorizeUrl(url: string, opened: boolean): void { } function formatVerifyFailureMessage(profileName: string, failure: VerifyFailure): string { - const which = failure.which === "user" ? "/api/user/current" : "/api/session/properties"; + const label = VERIFY_STEP_LABEL[failure.which]; + const which = failure.endpoint === undefined ? label : `${label}, ${failure.endpoint}`; return `verification failed (${which}): ${failure.message} — credentials were not saved for profile "${profileName}"`; } diff --git a/src/commands/auth/logout.test.ts b/packages/cli/src/commands/auth/logout.test.ts similarity index 86% rename from src/commands/auth/logout.test.ts rename to packages/cli/src/commands/auth/logout.test.ts index 6c7a0bf..e4d0373 100644 --- a/src/commands/auth/logout.test.ts +++ b/packages/cli/src/commands/auth/logout.test.ts @@ -11,21 +11,23 @@ vi.mock("@napi-rs/keyring", async () => { return createKeyringMockModule(hoisted); }); -import logoutCommand from "./logout"; -import type { OAuthCredential } from "../../core/auth/credential"; +import type { OAuthCredential } from "@metabase/client/auth/credential"; import { captureFetch, jsonResponse, type CapturedFetchCall, type FetchScript, -} from "../../core/http/fetch-capture"; -import type { OAuthServerMetadata } from "../../core/http/oauth"; +} from "@metabase/client/testing/fetch-capture"; +import type { OAuthServerMetadata } from "@metabase/client/http/oauth"; + +import logoutCommand from "./logout"; import { KEYCHAIN_RESIDUAL_NOTICE, readProfileCredential, writeOAuthProfile, } from "../../core/auth/storage"; import { setupTempConfigHome, type TempConfigHome } from "../../core/auth/temp-config-home"; +import { USER_AGENT } from "../../core/user-agent"; const OAUTH_CRED: OAuthCredential = { kind: "oauth", @@ -87,6 +89,24 @@ describe("auth logout command", () => { expect(await readProfileCredential()).toBeNull(); }); + it("names the CLI and its version on every OAuth request it issues", async () => { + await writeOAuthProfile("https://m.example.com", OAUTH_CRED); + const calls = installFetch([ + jsonResponse(METADATA), + new Response("", { status: 200 }), + new Response("", { status: 200 }), + ]); + + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + await runCommand(logoutCommand, { rawArgs: ["--profile", "default", "--yes"] }); + + expect(calls.map((call) => call.headers["user-agent"])).toEqual([ + USER_AGENT, + USER_AGENT, + USER_AGENT, + ]); + }); + it("still clears locally when server-side revocation fails", async () => { await writeOAuthProfile("https://m.example.com", OAUTH_CRED); installFetch([jsonResponse(METADATA), jsonResponse({ error: "server_error" }, 500)]); diff --git a/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts similarity index 89% rename from src/commands/auth/logout.ts rename to packages/cli/src/commands/auth/logout.ts index ba440bf..913c7d8 100644 --- a/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -1,17 +1,19 @@ import { z } from "zod"; -import { revokeOAuthCredential } from "../../core/auth/oauth-session"; +import { revokeOAuthCredential } from "@metabase/client/auth/oauth-session"; +import { errorMessage } from "@metabase/client/errors"; + import { clearProfile, consumeKeychainResidualWarning, readProfileCredential, } from "../../core/auth/storage"; import { resolveProfileName } from "../../core/config"; -import { errorMessage } from "../../core/errors"; -import type { ResourceView } from "../../domain/view"; +import { USER_AGENT } from "../../core/user-agent"; import { warn } from "../../output/notice"; import { promptConfirm } from "../../output/prompt"; import { renderSummary } from "../../output/render"; +import type { ResourceView } from "../../output/view"; import { outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; @@ -20,7 +22,7 @@ export const LogoutResult = z.object({ cleared: z.boolean(), aborted: z.boolean(), }); -export type LogoutResultJson = z.infer; +type LogoutResultJson = z.infer; const logoutView: ResourceView = { compactPick: LogoutResult, @@ -73,7 +75,7 @@ export default defineMetabaseCommand({ // never blocks (or hangs) the logout. A revocation failure only warns. if (resolved !== null && resolved.credential.kind === "oauth") { try { - const revoked = await revokeOAuthCredential(resolved.url, resolved.credential); + const revoked = await revokeOAuthCredential(resolved.url, resolved.credential, USER_AGENT); if (!revoked) { warn( "server does not advertise a revocation endpoint; tokens remain valid until they expire", diff --git a/src/commands/auth/render.ts b/packages/cli/src/commands/auth/render.ts similarity index 86% rename from src/commands/auth/render.ts rename to packages/cli/src/commands/auth/render.ts index 21a495d..9428d57 100644 --- a/src/commands/auth/render.ts +++ b/packages/cli/src/commands/auth/render.ts @@ -1,4 +1,4 @@ -export const EMPTY_CELL = "—"; +import { EMPTY_CELL } from "../../output/table"; function pickProperty(value: unknown, key: string): unknown { if (typeof value !== "object" || value === null || !(key in value)) { @@ -8,12 +8,12 @@ function pickProperty(value: unknown, key: string): unknown { return inner; } -export function pickString(value: unknown, key: string): string | null { +function pickString(value: unknown, key: string): string | null { const inner = pickProperty(value, key); return typeof inner === "string" ? inner : null; } -export function pickBoolean(value: unknown, key: string): boolean | null { +function pickBoolean(value: unknown, key: string): boolean | null { const inner = pickProperty(value, key); return typeof inner === "boolean" ? inner : null; } diff --git a/src/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts similarity index 98% rename from src/commands/auth/status.test.ts rename to packages/cli/src/commands/auth/status.test.ts index a73a570..043c7bf 100644 --- a/src/commands/auth/status.test.ts +++ b/packages/cli/src/commands/auth/status.test.ts @@ -2,7 +2,7 @@ import { runCommand } from "citty"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ZodType } from "zod"; -import { parseJson } from "../../runtime/json"; +import { parseJson } from "@metabase/client/json"; const hoisted = vi.hoisted(() => ({ store: new Map(), diff --git a/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts similarity index 89% rename from src/commands/auth/status.ts rename to packages/cli/src/commands/auth/status.ts index ce395d7..bff0b76 100644 --- a/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -1,18 +1,19 @@ import { z } from "zod"; +import { TokenFeatures } from "@metabase/client/domain/session-properties"; +import { displayUrl } from "@metabase/client/url"; +import { ParsedVersion } from "@metabase/client/version/tag"; + import { readProfileRecord } from "../../core/auth/storage"; import { resolveProfileName } from "../../core/config"; -import { displayUrl } from "../../core/url"; -import { ParsedVersionSchema } from "../../core/version/tag"; import { ProbedUser, profileAuthMethod, ProfileAuthMethod, ProfileLastFailure, } from "../../core/auth/profile-record"; -import { TokenFeatures } from "../../domain/session-properties"; -import type { ResourceView } from "../../domain/view"; import { renderItem } from "../../output/render"; +import type { ResourceView } from "../../output/view"; import { outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; import { @@ -29,12 +30,12 @@ export const AuthStatus = z.object({ url: z.string().nullable(), method: ProfileAuthMethod.nullable(), user: ProbedUser.nullable(), - version: ParsedVersionSchema.nullable(), + version: ParsedVersion.nullable(), tokenFeatures: TokenFeatures.nullable(), lastProbedAt: z.iso.datetime().nullable(), lastFailure: ProfileLastFailure.nullable(), }); -export type AuthStatusJson = z.infer; +type AuthStatusJson = z.infer; const authStatusView: ResourceView = { compactPick: AuthStatus, diff --git a/src/commands/body-flags.ts b/packages/cli/src/commands/body-flags.ts similarity index 100% rename from src/commands/body-flags.ts rename to packages/cli/src/commands/body-flags.ts diff --git a/src/commands/card/alerts.ts b/packages/cli/src/commands/card/alerts.ts similarity index 63% rename from src/commands/card/alerts.ts rename to packages/cli/src/commands/card/alerts.ts index e082362..e8b2f49 100644 --- a/src/commands/card/alerts.ts +++ b/packages/cli/src/commands/card/alerts.ts @@ -1,15 +1,11 @@ -import { z } from "zod"; - -import { CARD_PAYLOAD_TYPE, Notification, notificationView } from "../../domain/notification"; +import { notificationView } from "../../output/views/notification"; import { renderList } from "../../output/render"; -import { wrapList } from "../../output/types"; +import { windowList } from "../../output/window"; import { AlertListEnvelope } from "../alert/list"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -const NotificationApiList = z.array(Notification); - export default defineMetabaseCommand({ meta: { name: "alerts", description: "List alerts watching a card" }, details: @@ -17,6 +13,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, includeInactive: { @@ -31,13 +28,10 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const items = await client.requestParsed(NotificationApiList, "/api/notification", { - query: { - payload_type: CARD_PAYLOAD_TYPE, - card_id: id, - include_inactive: args.includeInactive || undefined, - }, + const { data, total } = await client.notification.list({ + card_id: id, + include_inactive: args.includeInactive || undefined, }); - renderList(wrapList(items), notificationView, ctx); + renderList(windowList(data, ctx.range, total), notificationView, ctx); }, }); diff --git a/src/commands/card/archive.ts b/packages/cli/src/commands/card/archive.ts similarity index 81% rename from src/commands/card/archive.ts rename to packages/cli/src/commands/card/archive.ts index 13b0a52..3b78160 100644 --- a/src/commands/card/archive.ts +++ b/packages/cli/src/commands/card/archive.ts @@ -1,4 +1,5 @@ -import { Card, cardView } from "../../domain/card"; +import { Card } from "@metabase/client/domain/card"; +import { cardView } from "../../output/views/card"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,10 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const updated = await client.requestParsed(Card, `/api/card/${id}`, { - method: "PUT", - body: { archived: true }, - }); + const updated = await client.card.archive(id); renderSummary(updated, cardView, `Archived card ${updated.id} "${updated.name}".`, ctx); }, }); diff --git a/src/commands/card/create.ts b/packages/cli/src/commands/card/create.ts similarity index 92% rename from src/commands/card/create.ts rename to packages/cli/src/commands/card/create.ts index 2017c08..3bef7c8 100644 --- a/src/commands/card/create.ts +++ b/packages/cli/src/commands/card/create.ts @@ -1,4 +1,5 @@ -import { Card, CardCreateInput, cardView } from "../../domain/card"; +import { Card, CardCreateInput } from "@metabase/client/domain/card"; +import { cardView } from "../../output/views/card"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -44,10 +45,7 @@ export default defineMetabaseCommand({ skip: args["skip-validate"] === true, }); const client = await getClient(); - const created = await client.requestParsed(Card, "/api/card", { - method: "POST", - body, - }); + const created = await client.card.create(body); renderSummary(created, cardView, `Created card ${created.id} "${created.name}".`, ctx); }, }); diff --git a/src/commands/card/get.ts b/packages/cli/src/commands/card/get.ts similarity index 83% rename from src/commands/card/get.ts rename to packages/cli/src/commands/card/get.ts index ff135de..b2a10bc 100644 --- a/src/commands/card/get.ts +++ b/packages/cli/src/commands/card/get.ts @@ -1,4 +1,5 @@ -import { Card, cardView } from "../../domain/card"; +import { Card } from "@metabase/client/domain/card"; +import { cardView } from "../../output/views/card"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,7 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const card = await client.requestParsed(Card, `/api/card/${id}`); + const card = await client.card.get(id); renderItem(card, cardView, ctx); }, }); diff --git a/src/commands/card/index.ts b/packages/cli/src/commands/card/index.ts similarity index 100% rename from src/commands/card/index.ts rename to packages/cli/src/commands/card/index.ts diff --git a/src/commands/card/list.ts b/packages/cli/src/commands/card/list.ts similarity index 66% rename from src/commands/card/list.ts rename to packages/cli/src/commands/card/list.ts index 17d00ff..3c6f3ce 100644 --- a/src/commands/card/list.ts +++ b/packages/cli/src/commands/card/list.ts @@ -1,25 +1,12 @@ -import { z } from "zod"; - -import { Card, CardCompact, cardView } from "../../domain/card"; +import { CardCompact, CardListFilter } from "@metabase/client/domain/card"; +import { cardView } from "../../output/views/card"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseEnumFlag } from "../parse-enum"; import { defineMetabaseCommand } from "../runtime"; -const CardApiList = z.array(Card); - -const CardListFilter = z.enum([ - "all", - "mine", - "bookmarked", - "database", - "table", - "archived", - "using_model", - "using_segment", -]); - export const CardListEnvelope = listEnvelopeSchema(CardCompact); export default defineMetabaseCommand({ @@ -27,6 +14,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, filter: { @@ -50,9 +38,7 @@ export default defineMetabaseCommand({ const filter = parseEnumFlag(args.filter, CardListFilter, "filter"); const modelId = args.modelId === undefined || args.modelId === "" ? undefined : args.modelId; const client = await getClient(); - const items = await client.requestParsed(CardApiList, "/api/card", { - query: { f: filter, model_id: modelId }, - }); - renderList(wrapList(items), cardView, ctx); + const { data, total } = await client.card.list({ f: filter, model_id: modelId }); + renderList(windowList(data, ctx.range, total), cardView, ctx); }, }); diff --git a/src/commands/card/query.ts b/packages/cli/src/commands/card/query.ts similarity index 64% rename from src/commands/card/query.ts rename to packages/cli/src/commands/card/query.ts index d7650d0..0f46557 100644 --- a/src/commands/card/query.ts +++ b/packages/cli/src/commands/card/query.ts @@ -1,28 +1,20 @@ import { z } from "zod"; -import { CardQueryResult, cardQueryView } from "../../domain/card"; -import { ConfigError } from "../../core/errors"; -import type { Client } from "../../core/http/client"; +import { CardExportFormat, CardQueryResult } from "@metabase/client/domain/card"; +import { ConfigError } from "@metabase/client/errors"; +import { parseJson } from "@metabase/client/json"; + import { formatQueryResult } from "../../output/query-result"; import { renderSummary } from "../../output/render"; import { pipeToStdout } from "../../output/stream"; -import { parseJson } from "../../runtime/json"; +import { cardQueryView } from "../../output/views/card"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; +import { parseOptionalInteger } from "../parse-integer"; import { defineMetabaseCommand } from "../runtime"; -const ExportFormat = z.enum(["csv", "json", "xlsx"]); -type ExportFormat = z.infer; - const QueryParameters = z.array(z.unknown()); -interface StreamExportOptions { - format: ExportFormat; - parameters: unknown[]; - formatRows: boolean; - pivotResults: boolean; -} - export default defineMetabaseCommand({ meta: { name: "query", @@ -37,7 +29,7 @@ export default defineMetabaseCommand({ id: { type: "positional", description: "Card id", required: true }, "export-format": { type: "string", - description: `Bypass JSON envelope and stream raw export: ${ExportFormat.options.join(" | ")}`, + description: `Bypass JSON envelope and stream raw export: ${CardExportFormat.options.join(" | ")}`, }, parameters: { type: "string", @@ -74,21 +66,17 @@ export default defineMetabaseCommand({ const exportFormatRaw = args["export-format"]; if (exportFormatRaw !== undefined && exportFormatRaw !== "") { - await streamExport(client, id, { - format: parseExportFormat(exportFormatRaw), + const stream = await client.card.exportQuery(id, parseExportFormat(exportFormatRaw), { parameters, - formatRows: args["format-rows"], - pivotResults: args["pivot-results"], + format_rows: args["format-rows"], + pivot_results: args["pivot-results"], }); + await pipeToStdout(stream); return; } - const result = await client.requestParsed(CardQueryResult, `/api/card/${id}/query`, { - method: "POST", - body: { parameters }, - }); - const limit = - args.limit === undefined || args.limit === "" ? null : parseId(args.limit, "limit"); + const result = await client.card.query(id, { parameters }); + const limit = parseOptionalInteger(args.limit, { name: "--limit", min: 1 }); const limited = applyLimit(result, limit); renderSummary(limited, cardQueryView, () => formatQueryResult(limited), ctx); }, @@ -101,11 +89,11 @@ function parseParameters(raw: string | undefined): unknown[] { return parseJson(raw, QueryParameters, { source: "--parameters" }); } -function parseExportFormat(raw: string): ExportFormat { - const result = ExportFormat.safeParse(raw); +function parseExportFormat(raw: string): CardExportFormat { + const result = CardExportFormat.safeParse(raw); if (!result.success) { throw new ConfigError( - `invalid --export-format: "${raw}" (expected: ${ExportFormat.options.join(", ")})`, + `invalid --export-format: "${raw}" (expected: ${CardExportFormat.options.join(", ")})`, ); } return result.data; @@ -117,20 +105,3 @@ function applyLimit(result: CardQueryResult, limit: number | null): CardQueryRes } return { ...result, data: { ...result.data, rows: result.data.rows.slice(0, limit) } }; } - -async function streamExport( - client: Client, - id: number, - options: StreamExportOptions, -): Promise { - const body = new URLSearchParams({ - parameters: JSON.stringify(options.parameters), - format_rows: String(options.formatRows), - pivot_results: String(options.pivotResults), - }); - const stream = await client.requestStream(`/api/card/${id}/query/${options.format}`, { - method: "POST", - body, - }); - await pipeToStdout(stream); -} diff --git a/src/commands/card/update.ts b/packages/cli/src/commands/card/update.ts similarity index 91% rename from src/commands/card/update.ts rename to packages/cli/src/commands/card/update.ts index 63882e2..0f885bd 100644 --- a/src/commands/card/update.ts +++ b/packages/cli/src/commands/card/update.ts @@ -1,4 +1,5 @@ -import { Card, CardUpdateInput, cardView } from "../../domain/card"; +import { Card, CardUpdateInput } from "@metabase/client/domain/card"; +import { cardView } from "../../output/views/card"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -48,10 +49,7 @@ export default defineMetabaseCommand({ skip: args["skip-validate"] === true, }); const client = await getClient(); - const updated = await client.requestParsed(Card, `/api/card/${id}`, { - method: "PUT", - body, - }); + const updated = await client.card.update(id, body); renderSummary(updated, cardView, `Updated card ${updated.id} "${updated.name}".`, ctx); }, }); diff --git a/src/commands/collection/archive.ts b/packages/cli/src/commands/collection/archive.ts similarity index 80% rename from src/commands/collection/archive.ts rename to packages/cli/src/commands/collection/archive.ts index a9e60e8..0615787 100644 --- a/src/commands/collection/archive.ts +++ b/packages/cli/src/commands/collection/archive.ts @@ -1,4 +1,5 @@ -import { Collection, collectionView } from "../../domain/collection"; +import { Collection } from "@metabase/client/domain/collection"; +import { collectionView } from "../../output/views/collection"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,10 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const updated = await client.requestParsed(Collection, `/api/collection/${id}`, { - method: "PUT", - body: { archived: true }, - }); + const updated = await client.collection.archive(id); renderSummary( updated, collectionView, diff --git a/src/commands/collection/create.ts b/packages/cli/src/commands/collection/create.ts similarity index 89% rename from src/commands/collection/create.ts rename to packages/cli/src/commands/collection/create.ts index 7e7e374..6e23e45 100644 --- a/src/commands/collection/create.ts +++ b/packages/cli/src/commands/collection/create.ts @@ -2,8 +2,8 @@ import { Collection, CollectionCreateInput, CollectionNamespace, - collectionView, -} from "../../domain/collection"; +} from "@metabase/client/domain/collection"; +import { collectionView } from "../../output/views/collection"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -11,8 +11,6 @@ import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseEnumFlag } from "../parse-enum"; import { defineMetabaseCommand } from "../runtime"; -import { COLLECTIONS_PATH } from "./listing"; - export default defineMetabaseCommand({ meta: { name: "create", description: "Create a collection from a JSON spec" }, details: @@ -42,10 +40,7 @@ export default defineMetabaseCommand({ body.namespace = parseEnumFlag(args.namespace, CollectionNamespace, "namespace"); } const client = await getClient(); - const created = await client.requestParsed(Collection, COLLECTIONS_PATH, { - method: "POST", - body, - }); + const created = await client.collection.create(body); renderSummary( created, collectionView, diff --git a/src/commands/collection/get.ts b/packages/cli/src/commands/collection/get.ts similarity index 84% rename from src/commands/collection/get.ts rename to packages/cli/src/commands/collection/get.ts index 7ca2092..8804b1e 100644 --- a/src/commands/collection/get.ts +++ b/packages/cli/src/commands/collection/get.ts @@ -1,4 +1,5 @@ -import { Collection, collectionView } from "../../domain/collection"; +import { Collection } from "@metabase/client/domain/collection"; +import { collectionView } from "../../output/views/collection"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; @@ -31,7 +32,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const ref = parseCollectionRef(args.id); const client = await getClient(); - const collection = await client.requestParsed(Collection, `/api/collection/${ref}`); + const collection = await client.collection.get(ref); renderItem(collection, collectionView, ctx); }, }); diff --git a/src/commands/collection/index.ts b/packages/cli/src/commands/collection/index.ts similarity index 100% rename from src/commands/collection/index.ts rename to packages/cli/src/commands/collection/index.ts diff --git a/src/commands/collection/items.ts b/packages/cli/src/commands/collection/items.ts similarity index 65% rename from src/commands/collection/items.ts rename to packages/cli/src/commands/collection/items.ts index 35435b8..4f961f6 100644 --- a/src/commands/collection/items.ts +++ b/packages/cli/src/commands/collection/items.ts @@ -1,20 +1,18 @@ import { COLLECTION_ITEM_FILTER_MODELS, COLLECTION_PINNED_STATES, - CollectionItem, CollectionItemCompact, CollectionItemFilterModel, CollectionPinnedState, - collectionItemView, -} from "../../domain/collection"; +} from "@metabase/client/domain/collection"; + import { renderList } from "../../output/render"; -import { listEnvelopeSchema, type ListEnvelope } from "../../output/types"; +import { listEnvelopeSchema } from "../../output/types"; +import { collectionItemView } from "../../output/views/collection"; +import { collectForOutput } from "../../output/window"; import { parseEnum, parseEnumCsv } from "../../runtime/csv"; -import { collectPaginated } from "../../runtime/paginate"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { parseId } from "../parse-id"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; - import { parseCollectionRef } from "./parse-ref"; export const CollectionItemListEnvelope = listEnvelopeSchema(CollectionItemCompact); @@ -24,6 +22,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, id: { @@ -45,10 +44,6 @@ export default defineMetabaseCommand({ type: "string", description: `Pinned filter: ${COLLECTION_PINNED_STATES.join("|")}`, }, - limit: { - type: "string", - description: "Cap total items returned (default: drain all pages)", - }, }, outputSchema: CollectionItemListEnvelope, examples: [ @@ -61,23 +56,18 @@ export default defineMetabaseCommand({ const ref = parseCollectionRef(args.id); const models = parseEnumCsv(args.models, CollectionItemFilterModel, "--models"); const pinnedState = parseEnum(args["pinned-state"], CollectionPinnedState, "--pinned-state"); - const max = args.limit === undefined ? undefined : parseId(args.limit, "--limit"); const client = await getClient(); - const items = await collectPaginated(client, `/api/collection/${ref}/items`, CollectionItem, { - query: { - models, - archived: args.archived ? true : undefined, - pinned_state: pinnedState, - }, - ...(max !== undefined && { max }), - }); - - const envelope: ListEnvelope = { - data: items, - returned: items.length, - ...(max === undefined ? { total: items.length } : { limit: max }), - }; + const envelope = await collectForOutput( + (request) => + client.collection.itemPages( + ref, + { models, archived: args.archived ? true : undefined, pinned_state: pinnedState }, + request, + ), + collectionItemView, + ctx, + ); renderList(envelope, collectionItemView, ctx); }, }); diff --git a/src/commands/collection/list.ts b/packages/cli/src/commands/collection/list.ts similarity index 55% rename from src/commands/collection/list.ts rename to packages/cli/src/commands/collection/list.ts index a0200ef..c17628f 100644 --- a/src/commands/collection/list.ts +++ b/packages/cli/src/commands/collection/list.ts @@ -1,24 +1,13 @@ -import { z } from "zod"; +import { CollectionCompact, CollectionListFilter } from "@metabase/client/domain/collection"; -import { Collection, CollectionCompact, collectionView } from "../../domain/collection"; +import { collectionView } from "../../output/views/collection"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseEnumFlag } from "../parse-enum"; import { defineMetabaseCommand } from "../runtime"; -import { COLLECTIONS_PATH } from "./listing"; - -const CollectionApiList = z.array(Collection); - -const CollectionListFilter = z.enum(["all", "archived", "personal"]); - -const COLLECTION_LIST_QUERY = { - all: {}, - archived: { archived: true }, - personal: { "personal-only": true }, -} as const; - export const CollectionListEnvelope = listEnvelopeSchema(CollectionCompact); export default defineMetabaseCommand({ @@ -26,6 +15,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, filter: { @@ -43,9 +33,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const filter = parseEnumFlag(args.filter, CollectionListFilter, "filter"); const client = await getClient(); - const items = await client.requestParsed(CollectionApiList, COLLECTIONS_PATH, { - query: COLLECTION_LIST_QUERY[filter], - }); - renderList(wrapList(items), collectionView, ctx); + const collections = await client.collection.list({ filter }); + renderList(windowList(collections.data, ctx.range), collectionView, ctx); }, }); diff --git a/src/commands/collection/parse-ref.test.ts b/packages/cli/src/commands/collection/parse-ref.test.ts similarity index 98% rename from src/commands/collection/parse-ref.test.ts rename to packages/cli/src/commands/collection/parse-ref.test.ts index 992b3be..08a583f 100644 --- a/src/commands/collection/parse-ref.test.ts +++ b/packages/cli/src/commands/collection/parse-ref.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ConfigError } from "../../core/errors"; +import { ConfigError } from "@metabase/client/errors"; import { parseCollectionRef } from "./parse-ref"; diff --git a/src/commands/collection/parse-ref.ts b/packages/cli/src/commands/collection/parse-ref.ts similarity index 92% rename from src/commands/collection/parse-ref.ts rename to packages/cli/src/commands/collection/parse-ref.ts index 43e378c..3e751b7 100644 --- a/src/commands/collection/parse-ref.ts +++ b/packages/cli/src/commands/collection/parse-ref.ts @@ -1,4 +1,4 @@ -import { ConfigError } from "../../core/errors"; +import { ConfigError } from "@metabase/client/errors"; const SPECIAL_TOKENS: ReadonlySet = new Set(["root", "trash"]); const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/; diff --git a/src/commands/collection/tree.ts b/packages/cli/src/commands/collection/tree.ts similarity index 78% rename from src/commands/collection/tree.ts rename to packages/cli/src/commands/collection/tree.ts index def790c..d498731 100644 --- a/src/commands/collection/tree.ts +++ b/packages/cli/src/commands/collection/tree.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import { ConfigError } from "../../core/errors"; -import { CollectionTreeNode } from "../../domain/collection"; +import { ConfigError } from "@metabase/client/errors"; +import { CollectionTreeNode } from "@metabase/client/domain/collection"; import { writeJson } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; @@ -22,7 +22,7 @@ export default defineMetabaseCommand({ throw new ConfigError("collection tree output is JSON-only; --format text is not supported"); } const client = await getClient(); - const tree = await client.requestParsed(CollectionTreeResponse, "/api/collection/tree"); - writeJson(tree); + const tree = await client.collection.tree(); + writeJson(tree.data); }, }); diff --git a/src/commands/context.test.ts b/packages/cli/src/commands/context.test.ts similarity index 97% rename from src/commands/context.test.ts rename to packages/cli/src/commands/context.test.ts index 5744c08..26c75b1 100644 --- a/src/commands/context.test.ts +++ b/packages/cli/src/commands/context.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; +import { ConfigError } from "@metabase/client/errors"; + import { resolveCommonFlags } from "./context"; -import { ConfigError } from "../core/errors"; describe("resolveCommonFlags — format reconciliation", () => { it("forces format=json when --json is passed", () => { @@ -145,6 +146,7 @@ describe("resolveCommonFlags — full result shape", () => { full: false, fields: undefined, maxBytes: 24576, + range: { offset: 0, limit: undefined }, url: undefined, apiKey: undefined, profile: undefined, @@ -161,6 +163,8 @@ describe("resolveCommonFlags — full result shape", () => { format: "text", fields: "id,name", maxBytes: "1024", + limit: "25", + offset: "50", skipPreflight: true, }, { isTty: false }, @@ -170,6 +174,7 @@ describe("resolveCommonFlags — full result shape", () => { full: false, fields: ["id", "name"], maxBytes: 1024, + range: { offset: 50, limit: 25 }, url: "https://m.example.com", apiKey: "secret", profile: "prod", diff --git a/src/commands/context.ts b/packages/cli/src/commands/context.ts similarity index 51% rename from src/commands/context.ts rename to packages/cli/src/commands/context.ts index 2b07f89..57b29ca 100644 --- a/src/commands/context.ts +++ b/packages/cli/src/commands/context.ts @@ -1,10 +1,10 @@ -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; import { resolveFormat } from "../output/format"; -import { DEFAULT_MAX_BYTES, type Format } from "../output/types"; +import { DEFAULT_MAX_BYTES, type Format, type ListRange } from "../output/types"; import { parseCsv } from "../runtime/csv"; -import type { connectionFlags, outputFlags, profileFlag } from "./flags"; -import { parseInteger } from "./parse-integer"; +import type { connectionFlags, listFlags, outputFlags, profileFlag } from "./flags"; +import { parseInteger, parseOptionalInteger } from "./parse-integer"; type FlagValue = T extends { type: "boolean" } ? boolean @@ -12,7 +12,10 @@ type FlagValue = T extends { type: "boolean" } ? string : never; -type AllKnownFlags = typeof outputFlags & typeof profileFlag & typeof connectionFlags; +type AllKnownFlags = typeof outputFlags & + typeof profileFlag & + typeof connectionFlags & + typeof listFlags; export type CommonArgs = { -readonly [K in keyof AllKnownFlags]?: FlagValue; @@ -23,28 +26,40 @@ export interface CommonContext { full: boolean; fields: string[] | undefined; maxBytes: number; + range: ListRange; url: string | undefined; apiKey: string | undefined; profile: string | undefined; skipPreflight: boolean; } -export interface ResolveOptions { +interface ResolveOptions { isTty?: boolean; } +// Resolvable on its own so an error reporter can learn the output format before any other flag +// has been parsed: every other resolver below can throw, and a config error that escapes them +// still has to be serialized in the shape the caller asked for. +export function resolveOutputFormat(args: CommonArgs, options: ResolveOptions = {}): Format { + return resolveFormat({ + json: args.json, + format: args.format, + isTty: options.isTty ?? Boolean(process.stdout.isTTY), + }); +} + export function resolveCommonFlags(args: CommonArgs, options: ResolveOptions = {}): CommonContext { - const isTty = options.isTty ?? Boolean(process.stdout.isTTY); const fields = parseFields(args.fields); const full = args.full === true; if (full && fields !== undefined) { throw new ConfigError("--full conflicts with --fields (use one or neither)"); } return { - format: resolveFormat({ json: args.json, format: args.format, isTty }), + format: resolveOutputFormat(args, options), full, fields, maxBytes: parseMaxBytes(args.maxBytes), + range: parseRange(args.limit, args.offset), url: args.url, apiKey: args.apiKey, profile: args.profile, @@ -63,3 +78,11 @@ function parseFields(value: string | undefined): string[] | undefined { function parseMaxBytes(value: string | undefined): number { return parseInteger(value ?? String(DEFAULT_MAX_BYTES), { name: "--max-bytes", min: 0 }); } + +function parseRange(limit: string | undefined, offset: string | undefined): ListRange { + const parsedLimit = parseOptionalInteger(limit, { name: "--limit", min: 1 }); + return { + offset: parseInteger(offset ?? "0", { name: "--offset", min: 0 }), + limit: parsedLimit === null ? undefined : parsedLimit, + }; +} diff --git a/src/commands/dashboard/archive.ts b/packages/cli/src/commands/dashboard/archive.ts similarity index 80% rename from src/commands/dashboard/archive.ts rename to packages/cli/src/commands/dashboard/archive.ts index b1ffc05..c3e83ce 100644 --- a/src/commands/dashboard/archive.ts +++ b/packages/cli/src/commands/dashboard/archive.ts @@ -1,4 +1,5 @@ -import { Dashboard, dashboardView } from "../../domain/dashboard"; +import { Dashboard } from "@metabase/client/domain/dashboard"; +import { dashboardView } from "../../output/views/dashboard"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,10 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const updated = await client.requestParsed(Dashboard, `/api/dashboard/${id}`, { - method: "PUT", - body: { archived: true }, - }); + const updated = await client.dashboard.archive(id); renderSummary( updated, dashboardView, diff --git a/src/commands/dashboard/cards.ts b/packages/cli/src/commands/dashboard/cards.ts similarity index 61% rename from src/commands/dashboard/cards.ts rename to packages/cli/src/commands/dashboard/cards.ts index db79a37..41dc0b6 100644 --- a/src/commands/dashboard/cards.ts +++ b/packages/cli/src/commands/dashboard/cards.ts @@ -1,7 +1,9 @@ -import { DashboardDetail, DashcardCompact, dashcardView } from "../../domain/dashboard"; +import { DashcardCompact } from "@metabase/client/domain/dashboard"; +import { dashcardView } from "../../output/views/dashboard"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; @@ -12,6 +14,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, id: { type: "positional", description: "Dashboard id", required: true }, @@ -21,7 +24,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const dashboard = await client.requestParsed(DashboardDetail, `/api/dashboard/${id}`); - renderList(wrapList(dashboard.dashcards), dashcardView, ctx); + const dashboard = await client.dashboard.get(id); + renderList(windowList(dashboard.dashcards, ctx.range), dashcardView, ctx); }, }); diff --git a/src/commands/dashboard/create.ts b/packages/cli/src/commands/dashboard/create.ts similarity index 83% rename from src/commands/dashboard/create.ts rename to packages/cli/src/commands/dashboard/create.ts index 7e639f7..eb667f8 100644 --- a/src/commands/dashboard/create.ts +++ b/packages/cli/src/commands/dashboard/create.ts @@ -1,9 +1,5 @@ -import { - Dashboard, - DashboardCreateInput, - DashboardDetail, - dashboardView, -} from "../../domain/dashboard"; +import { Dashboard, DashboardCreateInput } from "@metabase/client/domain/dashboard"; +import { dashboardView } from "../../output/views/dashboard"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -43,10 +39,7 @@ export default defineMetabaseCommand({ const { dashcards, tabs, ...createOnly } = body; const client = await getClient(); await preflightDashcardCardReferences(client, dashcards); - const created = await client.requestParsed(Dashboard, "/api/dashboard", { - method: "POST", - body: createOnly, - }); + const created = await client.dashboard.create(createOnly); if (dashcards === undefined && tabs === undefined) { renderSummary( created, @@ -57,10 +50,7 @@ export default defineMetabaseCommand({ return; } try { - const updated = await client.requestParsed(DashboardDetail, `/api/dashboard/${created.id}`, { - method: "PUT", - body: { dashcards, tabs }, - }); + const updated = await client.dashboard.update(created.id, { dashcards, tabs }); renderSummary( updated, dashboardView, diff --git a/src/commands/dashboard/get.ts b/packages/cli/src/commands/dashboard/get.ts similarity index 81% rename from src/commands/dashboard/get.ts rename to packages/cli/src/commands/dashboard/get.ts index b47bccf..1da2ddf 100644 --- a/src/commands/dashboard/get.ts +++ b/packages/cli/src/commands/dashboard/get.ts @@ -1,4 +1,5 @@ -import { DashboardDetail, dashboardView } from "../../domain/dashboard"; +import { DashboardDetail } from "@metabase/client/domain/dashboard"; +import { dashboardView } from "../../output/views/dashboard"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,7 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const dashboard = await client.requestParsed(DashboardDetail, `/api/dashboard/${id}`); + const dashboard = await client.dashboard.get(id); renderItem(dashboard, dashboardView, ctx); }, }); diff --git a/src/commands/dashboard/index.ts b/packages/cli/src/commands/dashboard/index.ts similarity index 100% rename from src/commands/dashboard/index.ts rename to packages/cli/src/commands/dashboard/index.ts diff --git a/src/commands/dashboard/list.ts b/packages/cli/src/commands/dashboard/list.ts similarity index 63% rename from src/commands/dashboard/list.ts rename to packages/cli/src/commands/dashboard/list.ts index 2d4c372..39de769 100644 --- a/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -1,16 +1,12 @@ -import { z } from "zod"; - -import { Dashboard, DashboardCompact, dashboardView } from "../../domain/dashboard"; +import { DashboardCompact, DashboardListFilter } from "@metabase/client/domain/dashboard"; +import { dashboardView } from "../../output/views/dashboard"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseEnumFlag } from "../parse-enum"; import { defineMetabaseCommand } from "../runtime"; -const DashboardApiList = z.array(Dashboard); - -const DashboardListFilter = z.enum(["all", "mine", "archived"]); - export const DashboardListEnvelope = listEnvelopeSchema(DashboardCompact); export default defineMetabaseCommand({ @@ -18,6 +14,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, filter: { @@ -35,9 +32,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const filter = parseEnumFlag(args.filter, DashboardListFilter, "filter"); const client = await getClient(); - const items = await client.requestParsed(DashboardApiList, "/api/dashboard", { - query: { f: filter }, - }); - renderList(wrapList(items), dashboardView, ctx); + const { data, total } = await client.dashboard.list({ f: filter }); + renderList(windowList(data, ctx.range, total), dashboardView, ctx); }, }); diff --git a/src/commands/dashboard/parameter-values.ts b/packages/cli/src/commands/dashboard/parameter-values.ts similarity index 78% rename from src/commands/dashboard/parameter-values.ts rename to packages/cli/src/commands/dashboard/parameter-values.ts index 4ffec34..8c4c866 100644 --- a/src/commands/dashboard/parameter-values.ts +++ b/packages/cli/src/commands/dashboard/parameter-values.ts @@ -1,5 +1,6 @@ -import { ConfigError } from "../../core/errors"; -import { ParameterValues, parameterValuesView } from "../../domain/parameter"; +import { ConfigError } from "@metabase/client/errors"; +import { ParameterValues } from "@metabase/client/domain/parameter"; +import { parameterValuesView } from "../../output/views/parameter"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -38,12 +39,11 @@ export default defineMetabaseCommand({ throw new ConfigError("parameter-id must not be empty"); } const client = await getClient(); - const base = `/api/dashboard/${dashboardId}/params/${encodeURIComponent(parameterId)}`; - const path = - args.query === undefined || args.query === "" - ? `${base}/values` - : `${base}/search/${encodeURIComponent(args.query)}`; - const result = await client.requestParsed(ParameterValues, path); + const search = args.query === undefined || args.query === "" ? null : args.query; + const result = + search === null + ? await client.dashboard.parameterValues(dashboardId, parameterId) + : await client.dashboard.searchParameterValues(dashboardId, parameterId, search); const more = result.has_more_values ? " (more available — narrow with --query)" : ""; renderSummary(result, parameterValuesView, `${result.values.length} value(s)${more}.`, ctx); }, diff --git a/src/commands/dashboard/preflight.test.ts b/packages/cli/src/commands/dashboard/preflight.test.ts similarity index 58% rename from src/commands/dashboard/preflight.test.ts rename to packages/cli/src/commands/dashboard/preflight.test.ts index 0cfe9a8..79c6270 100644 --- a/src/commands/dashboard/preflight.test.ts +++ b/packages/cli/src/commands/dashboard/preflight.test.ts @@ -1,15 +1,28 @@ import { afterEach, assert, beforeEach, describe, expect, it, vi } from "vitest"; -import { ChainedRequestError, ConfigError, NetworkError } from "../../core/errors"; -import { createFakeClient, type FakeClientCall } from "../../core/http/fake-client"; -import { HttpError } from "../../core/http/errors"; -import { Card } from "../../domain/card"; - +import { createClient } from "@metabase/client/client"; +import { ChainedRequestError, ConfigError, NetworkError } from "@metabase/client/errors"; +import { HttpError } from "@metabase/client/http/errors"; +import type { ClientCredentials } from "@metabase/client/http/transport"; import { - collectDashcardCardReferences, - preflightDashcardCardReferences, - wrapChainedDashboardWriteError, -} from "./preflight"; + captureFetch, + jsonResponse, + TEST_USER_AGENT, +} from "@metabase/client/testing/fetch-capture"; +import { Card } from "@metabase/client/domain/card"; + +import { preflightDashcardCardReferences, wrapChainedDashboardWriteError } from "./preflight"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; function cardFixture(id: number, archived = false): Card { return Card.parse({ @@ -25,62 +38,28 @@ function cardFixture(id: number, archived = false): Card { collection_id: null, entity_id: null, creator_id: 1, - dataset_query: {}, + dataset_query: { type: "query" }, visualization_settings: {}, }); } -function paths(calls: ReadonlyArray): string[] { - return calls.map((call) => call.path); +function cardRequest(id: number) { + return { + url: `https://mb.example.com/api/card/${id}`, + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }; } -describe("collectDashcardCardReferences", () => { - it("returns an empty array when dashcards is undefined", () => { - expect(collectDashcardCardReferences(undefined)).toEqual([]); - }); - - it("returns an empty array when dashcards is empty", () => { - expect(collectDashcardCardReferences([])).toEqual([]); - }); - - it("collects positive card_ids with JSON-pointer paths preserving index order", () => { - const dashcards = [ - { id: -1, card_id: 42, row: 0, col: 0 }, - { id: -2, card_id: 17, row: 0, col: 1 }, - { id: -3, card_id: 42, row: 1, col: 0 }, - ]; - expect(collectDashcardCardReferences(dashcards)).toEqual([ - { cardId: 42, path: "/dashcards/0/card_id" }, - { cardId: 17, path: "/dashcards/1/card_id" }, - { cardId: 42, path: "/dashcards/2/card_id" }, - ]); - }); - - it("skips null, negative, zero, and missing card_id entries", () => { - const dashcards = [ - { id: -1, card_id: null, row: 0, col: 0 }, - { id: -2, card_id: -5, row: 0, col: 1 }, - { id: -3, card_id: 0, row: 1, col: 0 }, - { id: -4, row: 1, col: 1 }, - ]; - expect(collectDashcardCardReferences(dashcards)).toEqual([]); - }); - - it("skips malformed entries silently so the server stays the authority on shape", () => { - const dashcards = [ - { id: -1, card_id: 99 }, - "not an object", - 42, - null, - { id: -2, card_id: "not a number" }, - { id: -3, card_id: 7 }, - ]; - expect(collectDashcardCardReferences(dashcards)).toEqual([ - { cardId: 99, path: "/dashcards/0/card_id" }, - { cardId: 7, path: "/dashcards/5/card_id" }, - ]); +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const client = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, }); -}); + return { client, capture }; +} describe("preflightDashcardCardReferences", () => { let captured: string[]; @@ -99,51 +78,45 @@ describe("preflightDashcardCardReferences", () => { }); it("returns without making any HTTP calls when dashcards is undefined", async () => { - const { client, calls } = createFakeClient(); + const { client, capture } = clientOver([]); await preflightDashcardCardReferences(client, undefined); - expect(calls).toEqual([]); + expect(capture.calls).toEqual([]); }); it("returns without making any HTTP calls when dashcards has no positive card_id", async () => { - const { client, calls } = createFakeClient(); + const { client, capture } = clientOver([]); await preflightDashcardCardReferences(client, [ { id: -1, card_id: null }, { id: -2, card_id: -3 }, ]); - expect(calls).toEqual([]); + expect(capture.calls).toEqual([]); }); it("returns without throwing when all referenced cards exist and are not archived", async () => { - const { client, calls } = createFakeClient({ - responses: new Map([ - ["/api/card/42", cardFixture(42)], - ["/api/card/17", cardFixture(17)], - ]), - }); + const { client, capture } = clientOver([ + jsonResponse(cardFixture(42)), + jsonResponse(cardFixture(17)), + ]); await preflightDashcardCardReferences(client, [ { id: -1, card_id: 42 }, { id: -2, card_id: 17 }, ]); - expect(paths(calls).toSorted()).toEqual(["/api/card/17", "/api/card/42"]); + expect(capture.calls).toEqual([cardRequest(42), cardRequest(17)]); expect(captured).toEqual([]); }); it("deduplicates HTTP calls when the same card_id appears in multiple dashcards", async () => { - const { client, calls } = createFakeClient({ - responses: new Map([["/api/card/42", cardFixture(42)]]), - }); + const { client, capture } = clientOver([jsonResponse(cardFixture(42))]); await preflightDashcardCardReferences(client, [ { id: -1, card_id: 42 }, { id: -2, card_id: 42 }, { id: -3, card_id: 42 }, ]); - expect(paths(calls)).toEqual(["/api/card/42"]); + expect(capture.calls).toEqual([cardRequest(42)]); }); it("throws ConfigError with the archived card listed under its dashcard path", async () => { - const { client } = createFakeClient({ - responses: new Map([["/api/card/134", cardFixture(134, true)]]), - }); + const { client } = clientOver([jsonResponse(cardFixture(134, true))]); const failure = preflightDashcardCardReferences(client, [{ id: -1, card_id: 134 }]); await expect(failure).rejects.toBeInstanceOf(ConfigError); await expect(failure).rejects.toThrow( @@ -158,9 +131,7 @@ describe("preflightDashcardCardReferences", () => { }); it("emits one envelope entry per dashcard reference even when they share an archived card", async () => { - const { client } = createFakeClient({ - responses: new Map([["/api/card/134", cardFixture(134, true)]]), - }); + const { client } = clientOver([jsonResponse(cardFixture(134, true))]); const failure = preflightDashcardCardReferences(client, [ { id: -1, card_id: 134 }, { id: -2, card_id: 134 }, @@ -181,17 +152,7 @@ describe("preflightDashcardCardReferences", () => { }); it("reports a missing card_id as 'card N not found' when /api/card/:id returns 404", async () => { - const notFound = new HttpError({ - status: 404, - statusText: "Not Found", - method: "GET", - url: "https://example.com/api/card/9999", - responseHeaders: { "content-type": "application/json" }, - rawBody: '{"message":"Not found"}', - }); - const { client } = createFakeClient({ - errors: new Map([["/api/card/9999", notFound]]), - }); + const { client } = clientOver([jsonResponse({ message: "Not found." }, 404)]); const failure = preflightDashcardCardReferences(client, [{ id: -1, card_id: 9999 }]); await expect(failure).rejects.toBeInstanceOf(ConfigError); await expect(failure).rejects.toThrow( @@ -206,17 +167,9 @@ describe("preflightDashcardCardReferences", () => { }); it("reports a permission-denied card as not readable with the original message", async () => { - const forbidden = new HttpError({ - status: 403, - statusText: "Forbidden", - method: "GET", - url: "https://example.com/api/card/55", - responseHeaders: { "content-type": "application/json" }, - rawBody: '{"message":"You do not have permissions to do that."}', - }); - const { client } = createFakeClient({ - errors: new Map([["/api/card/55", forbidden]]), - }); + const { client } = clientOver([ + jsonResponse({ message: "You do not have permissions to do that." }, 403), + ]); const failure = preflightDashcardCardReferences(client, [{ id: -1, card_id: 55 }]); await expect(failure).rejects.toBeInstanceOf(ConfigError); await expect(failure).rejects.toThrow( @@ -235,17 +188,11 @@ describe("preflightDashcardCardReferences", () => { ); }); - it("propagates non-HTTP errors so the user sees a 5xx / network failure verbatim", async () => { - const network = new NetworkError("Could not reach Metabase: connect ECONNREFUSED", { - method: "GET", - url: "https://example.com/api/card/1", - cause: "connect ECONNREFUSED", - }); - const { client } = createFakeClient({ - errors: new Map([["/api/card/1", network]]), - }); - await expect(preflightDashcardCardReferences(client, [{ id: -1, card_id: 1 }])).rejects.toBe( - network, + it("prints no envelope when the failure propagates instead of naming a bad card", async () => { + const { client } = clientOver([jsonResponse({ message: "API endpoint does not exist." }, 404)]); + const failure = preflightDashcardCardReferences(client, [{ id: -1, card_id: 1 }]); + await expect(failure).rejects.toThrow( + "This endpoint is not available on the connected Metabase: GET /api/card/1.", ); expect(captured).toEqual([]); }); @@ -272,12 +219,11 @@ describe("wrapChainedDashboardWriteError", () => { expect(wrapped.status).toBe(400); expect(wrapped.developerDetail.body).toBe('{"message":"The object has been archived."}'); expect(wrapped.userMessage).toBe( - "dashboard 7 created but follow-up PUT /api/dashboard/7 failed: The object has been archived.; dashcards not applied", + "dashboard 7 created but the follow-up update to dashboard 7 failed: The object has been archived.; dashcards not applied", ); - expect(wrapped.exitCode).toBe(1); }); - it("wraps a NetworkError into a ChainedRequestError carrying category, exitCode, and developerDetail", () => { + it("wraps a NetworkError into a ChainedRequestError carrying category, retryability, and developerDetail", () => { const original = new NetworkError("Could not reach Metabase: socket hang up", { method: "PUT", url: "https://example.com/api/dashboard/9", @@ -287,10 +233,9 @@ describe("wrapChainedDashboardWriteError", () => { expect(wrapped).toBeInstanceOf(ChainedRequestError); assert(wrapped instanceof ChainedRequestError, "expected ChainedRequestError"); expect(wrapped.userMessage).toBe( - "dashboard 9 created but follow-up PUT /api/dashboard/9 failed: Could not reach Metabase: socket hang up; dashcards not applied", + "dashboard 9 created but the follow-up update to dashboard 9 failed: Could not reach Metabase: socket hang up; dashcards not applied", ); expect(wrapped.category).toBe("network"); - expect(wrapped.exitCode).toBe(1); expect(wrapped.isRetryable).toBe(true); expect(wrapped.developerDetail).toEqual({ method: "PUT", diff --git a/packages/cli/src/commands/dashboard/preflight.ts b/packages/cli/src/commands/dashboard/preflight.ts new file mode 100644 index 0000000..36e62ed --- /dev/null +++ b/packages/cli/src/commands/dashboard/preflight.ts @@ -0,0 +1,45 @@ +import type { MetabaseClient } from "@metabase/client/client"; +import { ConfigError, MetabaseError } from "@metabase/client/errors"; +import { chainRequestFailure } from "@metabase/client/http/errors"; +import type { DashcardCardIssue } from "@metabase/client/resources/dashboard"; + +import { ValidationIssue, ValidationOutcome } from "../../core/schema/validate"; +import { writeJson } from "../../output/render"; + +export async function preflightDashcardCardReferences( + client: MetabaseClient, + dashcards: ReadonlyArray | null | undefined, +): Promise { + const issues = await client.dashboard.checkCardReferences(dashcards); + if (issues.length === 0) { + return; + } + const errors: ValidationIssue[] = issues.map((issue) => ({ + path: issue.path, + message: describeIssue(issue), + })); + const outcome: ValidationOutcome = { ok: false, errors }; + writeJson(outcome); + throw new ConfigError( + `dashboard card-reference pre-flight failed: ${errors.length} error(s) — fix the dashcard card_id values listed above`, + ); +} + +export function wrapChainedDashboardWriteError(error: unknown, dashboardId: number): unknown { + if (!(error instanceof MetabaseError)) { + return error; + } + const prefix = `dashboard ${dashboardId} created but the follow-up update to dashboard ${dashboardId} failed`; + const suffix = "dashcards not applied"; + return chainRequestFailure(error, `${prefix}: ${error.userMessage}; ${suffix}`); +} + +function describeIssue(issue: DashcardCardIssue): string { + if (issue.problem.reason === "missing") { + return `card ${issue.cardId} not found`; + } + if (issue.problem.reason === "archived") { + return `card ${issue.cardId} is archived`; + } + return `card ${issue.cardId} is not readable: ${issue.problem.detail}`; +} diff --git a/src/commands/dashboard/subscriptions.ts b/packages/cli/src/commands/dashboard/subscriptions.ts similarity index 70% rename from src/commands/dashboard/subscriptions.ts rename to packages/cli/src/commands/dashboard/subscriptions.ts index c1f29ec..21cdf9e 100644 --- a/src/commands/dashboard/subscriptions.ts +++ b/packages/cli/src/commands/dashboard/subscriptions.ts @@ -1,15 +1,11 @@ -import { z } from "zod"; - -import { Pulse, pulseView } from "../../domain/pulse"; +import { pulseView } from "../../output/views/pulse"; import { renderList } from "../../output/render"; -import { wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; import { SubscriptionListEnvelope } from "../subscription/list"; -const PulseApiList = z.array(Pulse); - export default defineMetabaseCommand({ meta: { name: "subscriptions", description: "List subscriptions on a dashboard" }, details: @@ -17,6 +13,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, archived: { @@ -30,9 +27,10 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const items = await client.requestParsed(PulseApiList, "/api/pulse", { - query: { dashboard_id: id, archived: args.archived || undefined }, + const { data, total } = await client.pulse.list({ + dashboard_id: id, + archived: args.archived || undefined, }); - renderList(wrapList(items), pulseView, ctx); + renderList(windowList(data, ctx.range, total), pulseView, ctx); }, }); diff --git a/src/commands/dashboard/update-dashcard.ts b/packages/cli/src/commands/dashboard/update-dashcard.ts similarity index 56% rename from src/commands/dashboard/update-dashcard.ts rename to packages/cli/src/commands/dashboard/update-dashcard.ts index c5c822f..52aaeee 100644 --- a/src/commands/dashboard/update-dashcard.ts +++ b/packages/cli/src/commands/dashboard/update-dashcard.ts @@ -1,10 +1,5 @@ -import { ConfigError } from "../../core/errors"; -import { - Dashcard, - DashboardDetail, - DashcardPatchInput, - dashcardView, -} from "../../domain/dashboard"; +import { Dashcard, DashcardPatchInput } from "@metabase/client/domain/dashboard"; +import { dashcardView } from "../../output/views/dashboard"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -38,27 +33,7 @@ export default defineMetabaseCommand({ const dashcardId = parseId(args["dashcard-id"], "dashcard-id"); const patch = await readBody({ flag: args.body, file: args.file }, DashcardPatchInput); const client = await getClient(); - - const dashboard = await client.requestParsed(DashboardDetail, `/api/dashboard/${dashboardId}`); - const target = dashboard.dashcards.find((dashcard) => dashcard.id === dashcardId); - if (target === undefined) { - throw new ConfigError(`dashcard ${dashcardId} not found on dashboard ${dashboardId}`); - } - const patched = Dashcard.parse({ ...target, ...patch }); - const updatedDashcards = dashboard.dashcards.map((dashcard) => - stripEntityId(dashcard.id === dashcardId ? patched : dashcard), - ); - - const result = await client.requestParsed(DashboardDetail, `/api/dashboard/${dashboardId}`, { - method: "PUT", - body: { dashcards: updatedDashcards }, - }); - const refreshed = result.dashcards.find((dashcard) => dashcard.id === dashcardId); - if (refreshed === undefined) { - throw new Error( - `PUT /api/dashboard/${dashboardId}: dashcard ${dashcardId} missing from response`, - ); - } + const refreshed = await client.dashboard.updateDashcard(dashboardId, dashcardId, patch); renderSummary( refreshed, dashcardView, @@ -67,8 +42,3 @@ export default defineMetabaseCommand({ ); }, }); - -function stripEntityId(dashcard: Dashcard): Omit { - const { entity_id: _entity_id, ...rest } = dashcard; - return rest; -} diff --git a/src/commands/dashboard/update.ts b/packages/cli/src/commands/dashboard/update.ts similarity index 89% rename from src/commands/dashboard/update.ts rename to packages/cli/src/commands/dashboard/update.ts index acf370c..a4d70d3 100644 --- a/src/commands/dashboard/update.ts +++ b/packages/cli/src/commands/dashboard/update.ts @@ -1,4 +1,5 @@ -import { DashboardDetail, DashboardUpdateInput, dashboardView } from "../../domain/dashboard"; +import { DashboardDetail, DashboardUpdateInput } from "@metabase/client/domain/dashboard"; +import { dashboardView } from "../../output/views/dashboard"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -40,10 +41,7 @@ export default defineMetabaseCommand({ const body = await readBody({ flag: args.body, file: args.file }, DashboardUpdateInput); const client = await getClient(); await preflightDashcardCardReferences(client, body.dashcards); - const updated = await client.requestParsed(DashboardDetail, `/api/dashboard/${id}`, { - method: "PUT", - body, - }); + const updated = await client.dashboard.update(id, body); renderSummary( updated, dashboardView, diff --git a/src/commands/db/get.ts b/packages/cli/src/commands/db/get.ts similarity index 85% rename from src/commands/db/get.ts rename to packages/cli/src/commands/db/get.ts index 94c1a3d..9579c4a 100644 --- a/src/commands/db/get.ts +++ b/packages/cli/src/commands/db/get.ts @@ -1,6 +1,5 @@ -import { z } from "zod"; - -import { Database, databaseView } from "../../domain/database"; +import { Database, DatabaseGetInclude } from "@metabase/client/domain/database"; +import { databaseView } from "../../output/views/database"; import { renderItem } from "../../output/render"; import { parseEnum } from "../../runtime/csv"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; @@ -9,10 +8,8 @@ import { defineMetabaseCommand } from "../runtime"; import { fullRollupOversizeHint, tableMapOversizeHint } from "./hints"; -const DatabaseGetInclude = z.enum(["tables", "tables.fields"]); - function includeOversizeHint( - include: z.infer | undefined, + include: DatabaseGetInclude | undefined, id: number, ): string | undefined { if (include === "tables.fields") { @@ -50,9 +47,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const include = parseEnum(args.include, DatabaseGetInclude, "--include"); const client = await getClient(); - const database = await client.requestParsed(Database, `/api/database/${id}`, { - query: { include }, - }); + const database = await client.database.get(id, { include }); renderItem(database, databaseView, { ...ctx, oversizeHint: includeOversizeHint(include, id) }); }, }); diff --git a/src/commands/db/hints.ts b/packages/cli/src/commands/db/hints.ts similarity index 100% rename from src/commands/db/hints.ts rename to packages/cli/src/commands/db/hints.ts diff --git a/src/commands/db/index.ts b/packages/cli/src/commands/db/index.ts similarity index 100% rename from src/commands/db/index.ts rename to packages/cli/src/commands/db/index.ts diff --git a/src/commands/db/list.ts b/packages/cli/src/commands/db/list.ts similarity index 58% rename from src/commands/db/list.ts rename to packages/cli/src/commands/db/list.ts index 16013b7..9bf5a4a 100644 --- a/src/commands/db/list.ts +++ b/packages/cli/src/commands/db/list.ts @@ -1,21 +1,12 @@ -import { z } from "zod"; - -import { Database, DatabaseCompact, databaseView } from "../../domain/database"; +import { DatabaseCompact, DatabaseListInclude } from "@metabase/client/domain/database"; +import { databaseView } from "../../output/views/database"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, type ListEnvelope } from "../../output/types"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; import { parseEnum } from "../../runtime/csv"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -const DatabaseListInclude = z.enum(["tables"]); - -const DatabaseApiList = z - .object({ - data: z.array(Database), - total: z.number().int().nonnegative(), - }) - .loose(); - export const DatabaseListEnvelope = listEnvelopeSchema(DatabaseCompact); export default defineMetabaseCommand({ @@ -23,6 +14,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, include: { @@ -45,15 +37,8 @@ export default defineMetabaseCommand({ const include = parseEnum(args.include, DatabaseListInclude, "--include"); const saved = args.saved ? true : undefined; const client = await getClient(); - const response = await client.requestParsed(DatabaseApiList, "/api/database", { - query: { include, saved }, - }); + const { data, total } = await client.database.list({ include, saved }); - const envelope: ListEnvelope = { - data: response.data, - returned: response.data.length, - total: response.total, - }; - renderList(envelope, databaseView, ctx); + renderList(windowList(data, ctx.range, total), databaseView, ctx); }, }); diff --git a/src/commands/db/rescan-values.ts b/packages/cli/src/commands/db/rescan-values.ts similarity index 76% rename from src/commands/db/rescan-values.ts rename to packages/cli/src/commands/db/rescan-values.ts index 2becde5..1cd2c72 100644 --- a/src/commands/db/rescan-values.ts +++ b/packages/cli/src/commands/db/rescan-values.ts @@ -1,4 +1,5 @@ -import { databaseSyncResultView, DatabaseSyncResult, DatabaseTaskAck } from "../../domain/database"; +import { DatabaseSyncResult } from "@metabase/client/domain/database"; +import { databaseSyncResultView } from "../../output/views/database"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -21,13 +22,9 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const response = await client.requestParsed( - DatabaseTaskAck, - `/api/database/${id}/rescan_values`, - { method: "POST" }, - ); + const result = await client.database.rescanValues(id); renderSummary( - { id, status: response.status }, + result, databaseSyncResultView, `Field-values rescan queued for database ${id}.`, ctx, diff --git a/src/commands/db/schema-tables.ts b/packages/cli/src/commands/db/schema-tables.ts similarity index 64% rename from src/commands/db/schema-tables.ts rename to packages/cli/src/commands/db/schema-tables.ts index e6250ea..50bf1be 100644 --- a/src/commands/db/schema-tables.ts +++ b/packages/cli/src/commands/db/schema-tables.ts @@ -1,14 +1,12 @@ -import { z } from "zod"; - -import { Table, TableCompact, tableView } from "../../domain/table"; +import { TableCompact } from "@metabase/client/domain/table"; +import { tableView } from "../../output/views/table"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -const SchemaTablesApiResponse = z.array(Table); - export const DatabaseSchemaTablesEnvelope = listEnvelopeSchema(TableCompact); export default defineMetabaseCommand({ @@ -19,6 +17,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, id: { type: "positional", description: "Database id", required: true }, @@ -29,10 +28,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const tables = await client.requestParsed( - SchemaTablesApiResponse, - `/api/database/${id}/schema/${encodeURIComponent(args.schema)}`, - ); - renderList(wrapList(tables), tableView, ctx); + const { data } = await client.database.schemaTables(id, args.schema); + renderList(windowList(data, ctx.range), tableView, ctx); }, }); diff --git a/src/commands/db/schemas.ts b/packages/cli/src/commands/db/schemas.ts similarity index 68% rename from src/commands/db/schemas.ts rename to packages/cli/src/commands/db/schemas.ts index b2f18f5..4c0fd4a 100644 --- a/src/commands/db/schemas.ts +++ b/packages/cli/src/commands/db/schemas.ts @@ -1,9 +1,10 @@ import { z } from "zod"; -import type { ResourceView } from "../../domain/view"; +import type { ResourceView } from "../../output/view"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; @@ -15,8 +16,6 @@ const schemaNameView: ResourceView = { tableColumns: [{ key: "name", label: "Schema" }], }; -const SchemasApiResponse = z.array(z.string()); - export const DatabaseSchemaListEnvelope = listEnvelopeSchema(SchemaName); export default defineMetabaseCommand({ @@ -24,6 +23,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, id: { type: "positional", description: "Database id", required: true }, @@ -33,8 +33,8 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const names = await client.requestParsed(SchemasApiResponse, `/api/database/${id}/schemas`); - const rows: SchemaName[] = names.map((name) => ({ name })); - renderList(wrapList(rows), schemaNameView, ctx); + const { data } = await client.database.schemas(id); + const rows: SchemaName[] = data.map((name) => ({ name })); + renderList(windowList(rows, ctx.range), schemaNameView, ctx); }, }); diff --git a/src/commands/db/sync-schema.ts b/packages/cli/src/commands/db/sync-schema.ts similarity index 56% rename from src/commands/db/sync-schema.ts rename to packages/cli/src/commands/db/sync-schema.ts index 6926b99..01c0727 100644 --- a/src/commands/db/sync-schema.ts +++ b/packages/cli/src/commands/db/sync-schema.ts @@ -1,11 +1,7 @@ -import { - Database, - databaseSyncResultView, - DatabaseSyncResult, - DatabaseTaskAck, -} from "../../domain/database"; +import { DatabaseSyncResult } from "@metabase/client/domain/database"; + import { renderSummary } from "../../output/render"; -import { pollUntil } from "../../runtime/poll"; +import { databaseSyncResultView } from "../../output/views/database"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; @@ -32,33 +28,13 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const wait = parseWaitFlags(args); const client = await getClient(); - const response = await client.requestParsed( - DatabaseTaskAck, - `/api/database/${id}/sync_schema`, - { method: "POST" }, - ); - - if (!wait.enabled) { - renderSummary( - { id, status: response.status }, - databaseSyncResultView, - `Schema sync queued for database ${id}.`, - ctx, - ); - return; - } - - const database = await pollUntil( - async () => client.requestParsed(Database, `/api/database/${id}`), - (db) => db.initial_sync_status === "complete", - wait.schedule, - ); + const result = await client.database.syncSchema(id, { + wait: wait.enabled ? wait.schedule : undefined, + }); - renderSummary( - { id, status: response.status, initial_sync_status: database.initial_sync_status ?? null }, - databaseSyncResultView, - `Schema sync for database ${id} complete.`, - ctx, - ); + const summary = wait.enabled + ? `Schema sync for database ${id} complete.` + : `Schema sync queued for database ${id}.`; + renderSummary(result, databaseSyncResultView, summary, ctx); }, }); diff --git a/src/commands/delete-runtime.test.ts b/packages/cli/src/commands/delete-runtime.test.ts similarity index 68% rename from src/commands/delete-runtime.test.ts rename to packages/cli/src/commands/delete-runtime.test.ts index 22bdbed..0606b59 100644 --- a/src/commands/delete-runtime.test.ts +++ b/packages/cli/src/commands/delete-runtime.test.ts @@ -1,28 +1,21 @@ import { assert, describe, expect, it } from "vitest"; -import { ConfigError } from "../core/errors"; -import type { Client } from "../core/http/client"; +import { ConfigError } from "@metabase/client/errors"; +import { FULL_RANGE } from "../output/types"; import type { CommonContext } from "./context"; import { confirmAndDelete } from "./delete-runtime"; -const NEVER_CALL: Client = { - async requestParsed() { - throw new Error("requestParsed must not be called"); - }, - async requestRaw() { - throw new Error("requestRaw must not be called"); - }, - async requestStream() { - throw new Error("requestStream must not be called"); - }, -}; +function neverDelete(): Promise { + throw new Error("the resource must not be deleted"); +} const CTX: CommonContext = { format: "json", full: false, fields: undefined, maxBytes: 0, + range: FULL_RANGE, url: undefined, apiKey: undefined, profile: undefined, @@ -33,12 +26,11 @@ describe("confirmAndDelete", () => { it("refuses without --yes when stdin is not a TTY and never reaches the server", async () => { const error = await confirmAndDelete({ id: 99999999, - path: "/api/transform/99999999", yes: false, promptMessage: "Delete transform 99999999?", successMessage: "Deleted transform 99999999.", abortMessage: "Aborted; transform 99999999 was not deleted.", - client: NEVER_CALL, + deleteResource: neverDelete, ctx: CTX, }).catch((caught: unknown) => caught); @@ -47,6 +39,5 @@ describe("confirmAndDelete", () => { expect(error.message).toBe( "refusing to delete 99999999 without confirmation — pass --yes to proceed non-interactively", ); - expect(error.exitCode).toBe(2); }); }); diff --git a/src/commands/delete-runtime.ts b/packages/cli/src/commands/delete-runtime.ts similarity index 70% rename from src/commands/delete-runtime.ts rename to packages/cli/src/commands/delete-runtime.ts index 901961b..93dbec3 100644 --- a/src/commands/delete-runtime.ts +++ b/packages/cli/src/commands/delete-runtime.ts @@ -1,8 +1,7 @@ import { z } from "zod"; -import { ConfigError } from "../core/errors"; -import type { Client } from "../core/http/client"; -import type { ResourceView } from "../domain/view"; +import { ConfigError } from "@metabase/client/errors"; +import type { ResourceView } from "../output/view"; import { promptConfirm } from "../output/prompt"; import { renderSummary } from "../output/render"; @@ -13,9 +12,9 @@ export const DeleteResult = z.object({ aborted: z.boolean(), id: z.number().int(), }); -export type DeleteResultJson = z.infer; +type DeleteResultJson = z.infer; -export const deleteResultView: ResourceView = { +const deleteResultView: ResourceView = { compactPick: DeleteResult, tableColumns: [ { key: "id", label: "ID" }, @@ -24,16 +23,14 @@ export const deleteResultView: ResourceView = { ], }; -export interface ConfirmAndDeleteArgs { +interface ConfirmAndDeleteArgs { id: number; - path: string; yes: boolean; promptMessage: string; successMessage: string; abortMessage: string; - client: Client; + deleteResource: () => Promise; ctx: CommonContext; - afterDelete?: () => Promise; } export async function confirmAndDelete(args: ConfirmAndDeleteArgs): Promise { @@ -57,13 +54,7 @@ export async function confirmAndDelete(args: ConfirmAndDeleteArgs): Promise { const resolved = entry.status === "ok" && entry.id !== undefined; const target = resolved ? String(entry.id) : "not found"; diff --git a/src/commands/field/get.ts b/packages/cli/src/commands/field/get.ts similarity index 82% rename from src/commands/field/get.ts rename to packages/cli/src/commands/field/get.ts index f2b4265..9df3d6a 100644 --- a/src/commands/field/get.ts +++ b/packages/cli/src/commands/field/get.ts @@ -1,4 +1,5 @@ -import { Field, fieldView } from "../../domain/field"; +import { Field } from "@metabase/client/domain/field"; +import { fieldView } from "../../output/views/field"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,7 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const field = await client.requestParsed(Field, `/api/field/${id}`); + const field = await client.field.get(id); renderItem(field, fieldView, ctx); }, }); diff --git a/src/commands/field/index.ts b/packages/cli/src/commands/field/index.ts similarity index 100% rename from src/commands/field/index.ts rename to packages/cli/src/commands/field/index.ts diff --git a/src/commands/field/summary.ts b/packages/cli/src/commands/field/summary.ts similarity index 73% rename from src/commands/field/summary.ts rename to packages/cli/src/commands/field/summary.ts index a5d0bca..20ba3a6 100644 --- a/src/commands/field/summary.ts +++ b/packages/cli/src/commands/field/summary.ts @@ -1,4 +1,5 @@ -import { FieldSummary, FieldSummaryRaw, fieldSummaryView } from "../../domain/field"; +import { FieldSummary } from "@metabase/client/domain/field"; +import { fieldSummaryView } from "../../output/views/field"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -21,11 +22,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const [[, count], [, distincts]] = await client.requestParsed( - FieldSummaryRaw, - `/api/field/${id}/summary`, - ); - const summary: FieldSummary = { field_id: id, count, distincts }; + const summary = await client.field.summary(id); renderItem(summary, fieldSummaryView, ctx); }, }); diff --git a/src/commands/field/update.ts b/packages/cli/src/commands/field/update.ts similarity index 87% rename from src/commands/field/update.ts rename to packages/cli/src/commands/field/update.ts index 0004fde..8c004fd 100644 --- a/src/commands/field/update.ts +++ b/packages/cli/src/commands/field/update.ts @@ -1,4 +1,5 @@ -import { Field, FieldUpdateInput, fieldView } from "../../domain/field"; +import { Field, FieldUpdateInput } from "@metabase/client/domain/field"; +import { fieldView } from "../../output/views/field"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -30,10 +31,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, FieldUpdateInput); const client = await getClient(); - const updated = await client.requestParsed(Field, `/api/field/${id}`, { - method: "PUT", - body, - }); + const updated = await client.field.update(id, body); renderSummary( updated, fieldView, diff --git a/src/commands/field/values.ts b/packages/cli/src/commands/field/values.ts similarity index 89% rename from src/commands/field/values.ts rename to packages/cli/src/commands/field/values.ts index 1cc0716..b3cbe50 100644 --- a/src/commands/field/values.ts +++ b/packages/cli/src/commands/field/values.ts @@ -1,4 +1,5 @@ -import { FieldValues, fieldValuesView } from "../../domain/field"; +import { FieldValues } from "@metabase/client/domain/field"; +import { fieldValuesView } from "../../output/views/field"; import { formatScalar, renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -21,7 +22,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const values = await client.requestParsed(FieldValues, `/api/field/${id}/values`); + const values = await client.field.values(id); const fieldId = values.field_id ?? id; const count = values.values.length; renderSummary( diff --git a/src/commands/flag-pair.test.ts b/packages/cli/src/commands/flag-pair.test.ts similarity index 96% rename from src/commands/flag-pair.test.ts rename to packages/cli/src/commands/flag-pair.test.ts index 192b1d9..b6c94eb 100644 --- a/src/commands/flag-pair.test.ts +++ b/packages/cli/src/commands/flag-pair.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; import { requireBothOrNeither } from "./flag-pair"; diff --git a/src/commands/flag-pair.ts b/packages/cli/src/commands/flag-pair.ts similarity index 86% rename from src/commands/flag-pair.ts rename to packages/cli/src/commands/flag-pair.ts index dfa965f..00c0247 100644 --- a/src/commands/flag-pair.ts +++ b/packages/cli/src/commands/flag-pair.ts @@ -1,11 +1,11 @@ -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; -export interface NamedFlag { +interface NamedFlag { readonly name: string; readonly value: string | undefined; } -export interface FlagPair { +interface FlagPair { readonly first: string; readonly second: string; } diff --git a/src/commands/flags.ts b/packages/cli/src/commands/flags.ts similarity index 56% rename from src/commands/flags.ts rename to packages/cli/src/commands/flags.ts index ec93c59..d43cd86 100644 --- a/src/commands/flags.ts +++ b/packages/cli/src/commands/flags.ts @@ -19,6 +19,32 @@ export const outputFlags = { }, } as const; +export const listFlags = { + limit: { + type: "string", + description: "Max items to return (default: as many as fit the output cap)", + }, + offset: { + type: "string", + description: "Start at this item index; pass the previous run's next_offset to continue", + default: "0", + }, +} as const; + +// An endpoint whose unbounded read is expensive caps itself, so its `--limit` carries a real +// default. The shared description promises the output cap is the only bound, which would be a +// lie for such a command, and the `default` key is what `--help --json` shows an agent. +export function listFlagsWithDefaultLimit(defaultLimit: number) { + return { + ...listFlags, + limit: { + type: "string", + description: "Max items to return", + default: String(defaultLimit), + }, + } as const; +} + export const profileFlag = { profile: { type: "string", description: "Named profile (default: 'default')", alias: "p" }, } as const; diff --git a/src/commands/git-sync/add-collection.ts b/packages/cli/src/commands/git-sync/add-collection.ts similarity index 53% rename from src/commands/git-sync/add-collection.ts rename to packages/cli/src/commands/git-sync/add-collection.ts index dbc4095..677e11b 100644 --- a/src/commands/git-sync/add-collection.ts +++ b/packages/cli/src/commands/git-sync/add-collection.ts @@ -1,38 +1,12 @@ -import { z } from "zod"; +import { SyncSettingsUpdateResult } from "@metabase/client/domain/git-sync"; -import type { Client } from "../../core/http/client"; -import type { ResourceView } from "../../domain/view"; import { renderSummary } from "../../output/render"; +import { syncSettingsUpdateView } from "../../output/views/git-sync"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { REMOTE_SYNC_PATHS } from "./poll-task"; - -export const SyncSettingsUpdateResult = z.object({ - success: z.boolean(), - task_id: z.number().int().positive().optional(), -}); -export type SyncSettingsUpdateResult = z.infer; - -export const syncSettingsUpdateView: ResourceView = { - compactPick: SyncSettingsUpdateResult, - tableColumns: [ - { key: "success", label: "Success" }, - { key: "task_id", label: "Task ID" }, - ], -}; - -export async function setCollectionRemoteSynced( - client: Client, - collectionId: number, - synced: boolean, -): Promise { - return await client.requestParsed(SyncSettingsUpdateResult, REMOTE_SYNC_PATHS.settings, { - method: "PUT", - body: { collections: { [collectionId]: synced } }, - }); -} +export { SyncSettingsUpdateResult }; export default defineMetabaseCommand({ meta: { @@ -53,8 +27,8 @@ export default defineMetabaseCommand({ ], async run({ args, ctx, getClient }) { const collectionId = parseId(args.id, "id"); - const client = await getClient(); - const result = await setCollectionRemoteSynced(client, collectionId, true); + const mb = await getClient(); + const result = await mb.gitSync.setCollectionSynced(collectionId, true); const taskPart = result.task_id !== undefined ? ` (task #${result.task_id})` : ""; const message = result.success ? `Collection ${collectionId} is now git-synced${taskPart}.` diff --git a/packages/cli/src/commands/git-sync/branches.ts b/packages/cli/src/commands/git-sync/branches.ts new file mode 100644 index 0000000..f270dce --- /dev/null +++ b/packages/cli/src/commands/git-sync/branches.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +import type { ResourceView } from "../../output/view"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +const BranchRow = z.object({ name: z.string() }); +type BranchRow = z.infer; + +const branchView: ResourceView = { + compactPick: BranchRow, + tableColumns: [{ key: "name", label: "Branch" }], +}; + +const SyncBranchListEnvelope = listEnvelopeSchema(BranchRow); + +export default defineMetabaseCommand({ + meta: { name: "branches", description: "List branches on the configured git remote" }, + capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, + outputSchema: SyncBranchListEnvelope, + examples: ["mb git-sync branches", "mb git-sync branches --json"], + async run({ ctx, getClient }) { + const mb = await getClient(); + const { data } = await mb.gitSync.branches(); + const rows: BranchRow[] = data.map((name) => ({ name })); + renderList(windowList(rows, ctx.range), branchView, ctx); + }, +}); diff --git a/src/commands/git-sync/cancel-task.ts b/packages/cli/src/commands/git-sync/cancel-task.ts similarity index 72% rename from src/commands/git-sync/cancel-task.ts rename to packages/cli/src/commands/git-sync/cancel-task.ts index 6040953..6894f8b 100644 --- a/src/commands/git-sync/cancel-task.ts +++ b/packages/cli/src/commands/git-sync/cancel-task.ts @@ -1,10 +1,10 @@ -import { SyncTask, syncTaskView } from "../../domain/git-sync"; +import { SyncTask } from "@metabase/client/domain/git-sync"; + +import { syncTaskView } from "../../output/views/git-sync"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { REMOTE_SYNC_PATHS } from "./poll-task"; - export default defineMetabaseCommand({ meta: { name: "cancel-task", description: "Cancel the running git-sync task" }, capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, @@ -12,10 +12,8 @@ export default defineMetabaseCommand({ outputSchema: SyncTask, examples: ["mb git-sync cancel-task", "mb git-sync cancel-task --json"], async run({ ctx, getClient }) { - const client = await getClient(); - const task = await client.requestParsed(SyncTask, REMOTE_SYNC_PATHS.cancelTask, { - method: "POST", - }); + const mb = await getClient(); + const task = await mb.gitSync.cancelTask(); renderSummary( task, syncTaskView, diff --git a/src/commands/git-sync/create-branch.ts b/packages/cli/src/commands/git-sync/create-branch.ts similarity index 55% rename from src/commands/git-sync/create-branch.ts rename to packages/cli/src/commands/git-sync/create-branch.ts index de8bc7e..78a2435 100644 --- a/src/commands/git-sync/create-branch.ts +++ b/packages/cli/src/commands/git-sync/create-branch.ts @@ -1,27 +1,11 @@ -import { z } from "zod"; +import { SyncBranchCreated } from "@metabase/client/domain/git-sync"; +import { ConfigError } from "@metabase/client/errors"; -import { ConfigError } from "../../core/errors"; -import type { ResourceView } from "../../domain/view"; import { renderSummary } from "../../output/render"; +import { syncBranchCreatedView } from "../../output/views/git-sync"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { REMOTE_SYNC_PATHS } from "./poll-task"; - -export const CreateBranchResult = z.object({ - status: z.literal("success"), - message: z.string(), -}); -type CreateBranchResult = z.infer; - -const createBranchView: ResourceView = { - compactPick: CreateBranchResult, - tableColumns: [ - { key: "status", label: "Status" }, - { key: "message", label: "Message" }, - ], -}; - export default defineMetabaseCommand({ meta: { name: "create-branch", @@ -34,7 +18,7 @@ export default defineMetabaseCommand({ ...connectionFlags, name: { type: "positional", description: "Branch name", required: true }, }, - outputSchema: CreateBranchResult, + outputSchema: SyncBranchCreated, examples: [ "mb git-sync create-branch feat/dashboards", "mb git-sync create-branch feat/x --json", @@ -44,14 +28,11 @@ export default defineMetabaseCommand({ if (name === "") { throw new ConfigError("invalid name: branch name must not be blank"); } - const client = await getClient(); - const result = await client.requestParsed(CreateBranchResult, REMOTE_SYNC_PATHS.createBranch, { - method: "POST", - body: { name }, - }); + const mb = await getClient(); + const result = await mb.gitSync.createBranch({ name }); renderSummary( result, - createBranchView, + syncBranchCreatedView, `Created branch "${name}" and switched git-sync to it.`, ctx, ); diff --git a/src/commands/git-sync/current-task.ts b/packages/cli/src/commands/git-sync/current-task.ts similarity index 77% rename from src/commands/git-sync/current-task.ts rename to packages/cli/src/commands/git-sync/current-task.ts index c71b28a..d25996b 100644 --- a/src/commands/git-sync/current-task.ts +++ b/packages/cli/src/commands/git-sync/current-task.ts @@ -1,15 +1,9 @@ -import { syncTaskView } from "../../domain/git-sync"; +import { syncTaskView } from "../../output/views/git-sync"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { - fetchCurrentTask, - formatSyncTask, - syncTaskIdleView, - SyncTaskIdle, - SyncTaskOrIdle, -} from "./poll-task"; +import { formatSyncTask, syncTaskIdleView, SyncTaskIdle, SyncTaskOrIdle } from "./sync-task"; export const CurrentTaskResult = SyncTaskOrIdle; @@ -23,8 +17,8 @@ export default defineMetabaseCommand({ outputSchema: CurrentTaskResult, examples: ["mb git-sync current-task", "mb git-sync current-task --json"], async run({ ctx, getClient }) { - const client = await getClient(); - const task = await fetchCurrentTask(client); + const mb = await getClient(); + const task = await mb.gitSync.currentTask(); if (task === null) { const idle: SyncTaskIdle = { status: "idle" }; renderSummary(idle, syncTaskIdleView, "No git-sync task is running.", ctx); diff --git a/packages/cli/src/commands/git-sync/dirty.ts b/packages/cli/src/commands/git-sync/dirty.ts new file mode 100644 index 0000000..f408eca --- /dev/null +++ b/packages/cli/src/commands/git-sync/dirty.ts @@ -0,0 +1,23 @@ +import { SyncDirtyItemCompact } from "@metabase/client/domain/git-sync"; + +import { syncDirtyItemView } from "../../output/views/git-sync"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const SyncDirtyListEnvelope = listEnvelopeSchema(SyncDirtyItemCompact); + +export default defineMetabaseCommand({ + meta: { name: "dirty", description: "List objects with unsynced local changes" }, + capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, + outputSchema: SyncDirtyListEnvelope, + examples: ["mb git-sync dirty", "mb git-sync dirty --json"], + async run({ ctx, getClient }) { + const mb = await getClient(); + const { data } = await mb.gitSync.dirty(); + renderList(windowList(data, ctx.range), syncDirtyItemView, ctx); + }, +}); diff --git a/src/commands/git-sync/export.ts b/packages/cli/src/commands/git-sync/export.ts similarity index 55% rename from src/commands/git-sync/export.ts rename to packages/cli/src/commands/git-sync/export.ts index 612a054..e3a3952 100644 --- a/src/commands/git-sync/export.ts +++ b/packages/cli/src/commands/git-sync/export.ts @@ -1,41 +1,15 @@ -import { z } from "zod"; +import { SyncExportResult } from "@metabase/client/domain/git-sync"; +import type { SyncExportParams } from "@metabase/client/resources/git-sync"; -import { SyncTask } from "../../domain/git-sync"; -import type { ResourceView } from "../../domain/view"; import { warn } from "../../output/notice"; import { renderSummary } from "../../output/render"; +import { syncExportView } from "../../output/views/git-sync"; import type { CommonContext } from "../context"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; import { gitSyncWaitFlags, parseWaitFlags } from "../wait-flags"; -import { formatSyncTask, pollSyncTask, REMOTE_SYNC_PATHS, throwIfFailedTask } from "./poll-task"; - -const SyncExportKickoff = z.object({ - message: z.string(), - task_id: z.number().int().positive(), -}); - -export const SyncExportResult = z.object({ - message: z.string(), - task_id: z.number().int().positive(), - final: SyncTask.nullable().optional(), -}); -type SyncExportResult = z.infer; - -const syncExportView: ResourceView = { - compactPick: SyncExportResult, - tableColumns: [ - { key: "task_id", label: "Task ID" }, - { key: "message", label: "Message" }, - ], -}; - -interface ExportRequestBody { - branch?: string; - message?: string; - force?: boolean; -} +import { formatSyncTask, taskPollOptions, throwIfFailedTask } from "./sync-task"; export default defineMetabaseCommand({ meta: { @@ -72,35 +46,29 @@ export default defineMetabaseCommand({ ], async run({ args, ctx, getClient }) { const wait = parseWaitFlags(args); - const body: ExportRequestBody = {}; + const params: SyncExportParams = {}; if (args.branch !== undefined && args.branch !== "") { - body.branch = args.branch; + params.branch = args.branch; } if (args.message !== undefined && args.message !== "") { - body.message = args.message; + params.message = args.message; } if (args.force) { - body.force = true; + params.force = true; + } + if (wait.enabled) { + params.wait = taskPollOptions(wait.schedule); } - const client = await getClient(); - const kickoff = await client.requestParsed(SyncExportKickoff, REMOTE_SYNC_PATHS.export, { - method: "POST", - body, - }); + const mb = await getClient(); + const result = await mb.gitSync.export(params); if (!wait.enabled) { - const result: SyncExportResult = { message: kickoff.message, task_id: kickoff.task_id }; - renderSummary(result, syncExportView, `Started export task #${kickoff.task_id}.`, ctx); + renderSummary(result, syncExportView, `Started export task #${result.task_id}.`, ctx); } else { - const final = await pollSyncTask(client, wait.schedule); - const result: SyncExportResult = { - message: kickoff.message, - task_id: kickoff.task_id, - final, - }; + const final = result.final ?? null; const text = - final === null ? `Export task #${kickoff.task_id} finished.` : formatSyncTask(final); + final === null ? `Export task #${result.task_id} finished.` : formatSyncTask(final); renderSummary(result, syncExportView, text, ctx); throwIfFailedTask(final, "export"); } diff --git a/packages/cli/src/commands/git-sync/has-remote-changes.ts b/packages/cli/src/commands/git-sync/has-remote-changes.ts new file mode 100644 index 0000000..2a4b0d8 --- /dev/null +++ b/packages/cli/src/commands/git-sync/has-remote-changes.ts @@ -0,0 +1,38 @@ +import { SyncRemoteChanges } from "@metabase/client/domain/git-sync"; + +import { renderSummary } from "../../output/render"; +import { syncRemoteChangesView } from "../../output/views/git-sync"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "has-remote-changes", + description: "Check whether the remote branch has unimported changes", + }, + capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + forceRefresh: { + type: "boolean", + description: "Bypass the in-memory cache and re-check the remote", + default: false, + alias: "force-refresh", + }, + }, + outputSchema: SyncRemoteChanges, + examples: [ + "mb git-sync has-remote-changes", + "mb git-sync has-remote-changes --force-refresh --json", + ], + async run({ args, ctx, getClient }) { + const mb = await getClient(); + const result = await mb.gitSync.hasRemoteChanges({ "force-refresh": args.forceRefresh }); + const base = result.has_changes + ? `The remote branch has changes not yet imported (remote ${result.remote_version ?? "?"}, local ${result.local_version ?? "?"}).` + : "Up to date with the remote branch."; + renderSummary(result, syncRemoteChangesView, result.cached ? `${base} (cached)` : base, ctx); + }, +}); diff --git a/packages/cli/src/commands/git-sync/import.ts b/packages/cli/src/commands/git-sync/import.ts new file mode 100644 index 0000000..763e827 --- /dev/null +++ b/packages/cli/src/commands/git-sync/import.ts @@ -0,0 +1,71 @@ +import { SyncImportResult } from "@metabase/client/domain/git-sync"; +import type { SyncImportParams } from "@metabase/client/resources/git-sync"; + +import { renderSummary } from "../../output/render"; +import { syncImportView } from "../../output/views/git-sync"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; +import { gitSyncWaitFlags, parseWaitFlags } from "../wait-flags"; + +import { formatSyncTask, taskPollOptions, throwIfFailedTask } from "./sync-task"; + +export default defineMetabaseCommand({ + meta: { + name: "import", + description: "Import content from the configured git remote into Metabase", + }, + capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + branch: { + type: "string", + description: "Branch to import from (defaults to remote-sync-branch setting)", + alias: "b", + }, + force: { + type: "boolean", + description: "Discard local Metabase-side dirty changes (LOSSY)", + default: false, + }, + ...gitSyncWaitFlags, + }, + outputSchema: SyncImportResult, + examples: [ + "mb git-sync import", + "mb git-sync import --branch main --json", + "mb git-sync import --force --no-wait", + ], + async run({ args, ctx, getClient }) { + const wait = parseWaitFlags(args); + const params: SyncImportParams = {}; + if (args.branch !== undefined && args.branch !== "") { + params.branch = args.branch; + } + if (args.force) { + params.force = true; + } + if (wait.enabled) { + params.wait = taskPollOptions(wait.schedule); + } + + const mb = await getClient(); + const result = await mb.gitSync.import(params); + + if (!wait.enabled || result.task_id === null) { + const text = + result.task_id === null + ? (result.message ?? "Already up to date; nothing to import.") + : `Started import task #${result.task_id}.`; + renderSummary(result, syncImportView, text, ctx); + return; + } + + const final = result.final ?? null; + const text = + final === null ? `Import task #${result.task_id} finished.` : formatSyncTask(final); + renderSummary(result, syncImportView, text, ctx); + throwIfFailedTask(final, "import"); + }, +}); diff --git a/src/commands/git-sync/index.ts b/packages/cli/src/commands/git-sync/index.ts similarity index 100% rename from src/commands/git-sync/index.ts rename to packages/cli/src/commands/git-sync/index.ts diff --git a/src/commands/git-sync/is-dirty.ts b/packages/cli/src/commands/git-sync/is-dirty.ts similarity index 80% rename from src/commands/git-sync/is-dirty.ts rename to packages/cli/src/commands/git-sync/is-dirty.ts index c5f4d40..cf92086 100644 --- a/src/commands/git-sync/is-dirty.ts +++ b/packages/cli/src/commands/git-sync/is-dirty.ts @@ -1,12 +1,10 @@ import { z } from "zod"; -import type { ResourceView } from "../../domain/view"; +import type { ResourceView } from "../../output/view"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { REMOTE_SYNC_PATHS } from "./poll-task"; - export const IsDirtyResult = z.object({ is_dirty: z.boolean(), }); @@ -27,8 +25,8 @@ export default defineMetabaseCommand({ outputSchema: IsDirtyResult, examples: ["mb git-sync is-dirty", "mb git-sync is-dirty --json"], async run({ ctx, getClient }) { - const client = await getClient(); - const result = await client.requestParsed(IsDirtyResult, REMOTE_SYNC_PATHS.isDirty); + const mb = await getClient(); + const result: IsDirtyResult = { is_dirty: await mb.gitSync.isDirty() }; renderSummary(result, isDirtyView, result.is_dirty ? "dirty" : "clean", ctx); }, }); diff --git a/src/commands/git-sync/remove-collection.ts b/packages/cli/src/commands/git-sync/remove-collection.ts similarity index 82% rename from src/commands/git-sync/remove-collection.ts rename to packages/cli/src/commands/git-sync/remove-collection.ts index 95fbcbd..d77d1cf 100644 --- a/src/commands/git-sync/remove-collection.ts +++ b/packages/cli/src/commands/git-sync/remove-collection.ts @@ -1,14 +1,11 @@ +import { SyncSettingsUpdateResult } from "@metabase/client/domain/git-sync"; + import { renderSummary } from "../../output/render"; +import { syncSettingsUpdateView } from "../../output/views/git-sync"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { - setCollectionRemoteSynced, - SyncSettingsUpdateResult, - syncSettingsUpdateView, -} from "./add-collection"; - export default defineMetabaseCommand({ meta: { name: "remove-collection", @@ -28,8 +25,8 @@ export default defineMetabaseCommand({ ], async run({ args, ctx, getClient }) { const collectionId = parseId(args.id, "id"); - const client = await getClient(); - const result = await setCollectionRemoteSynced(client, collectionId, false); + const mb = await getClient(); + const result = await mb.gitSync.setCollectionSynced(collectionId, false); const taskPart = result.task_id !== undefined ? ` (task #${result.task_id})` : ""; const message = result.success ? `Collection ${collectionId} is no longer git-synced${taskPart}.` diff --git a/src/commands/git-sync/stash.ts b/packages/cli/src/commands/git-sync/stash.ts similarity index 52% rename from src/commands/git-sync/stash.ts rename to packages/cli/src/commands/git-sync/stash.ts index 539ccf9..5107f21 100644 --- a/src/commands/git-sync/stash.ts +++ b/packages/cli/src/commands/git-sync/stash.ts @@ -1,42 +1,14 @@ -import { z } from "zod"; +import { SyncStashResult } from "@metabase/client/domain/git-sync"; +import { ConfigError } from "@metabase/client/errors"; +import type { SyncStashParams } from "@metabase/client/resources/git-sync"; -import { ConfigError } from "../../core/errors"; -import { SyncTask } from "../../domain/git-sync"; -import type { ResourceView } from "../../domain/view"; import { renderSummary } from "../../output/render"; +import { syncStashView } from "../../output/views/git-sync"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; import { gitSyncWaitFlags, parseWaitFlags } from "../wait-flags"; -import { formatSyncTask, pollSyncTask, REMOTE_SYNC_PATHS, throwIfFailedTask } from "./poll-task"; - -const SyncStashKickoff = z.object({ - status: z.literal("success"), - message: z.string(), - task_id: z.number().int().positive(), -}); - -export const SyncStashResult = z.object({ - status: z.literal("success"), - message: z.string(), - task_id: z.number().int().positive(), - final: SyncTask.nullable().optional(), -}); -type SyncStashResult = z.infer; - -const syncStashView: ResourceView = { - compactPick: SyncStashResult, - tableColumns: [ - { key: "task_id", label: "Task ID" }, - { key: "status", label: "Status" }, - { key: "message", label: "Message" }, - ], -}; - -interface StashRequestBody { - new_branch: string; - message: string; -} +import { formatSyncTask, taskPollOptions, throwIfFailedTask } from "./sync-task"; const DEFAULT_STASH_MESSAGE = "Stashed from mb CLI"; @@ -80,38 +52,28 @@ export default defineMetabaseCommand({ } const wait = parseWaitFlags(args); - const body: StashRequestBody = { new_branch: newBranch, message }; - const client = await getClient(); - const kickoff = await client.requestParsed(SyncStashKickoff, REMOTE_SYNC_PATHS.stash, { - method: "POST", - body, - }); + const params: SyncStashParams = { new_branch: newBranch, message }; + if (wait.enabled) { + params.wait = taskPollOptions(wait.schedule); + } + + const mb = await getClient(); + const result = await mb.gitSync.stash(params); if (!wait.enabled) { - const result: SyncStashResult = { - status: kickoff.status, - message: kickoff.message, - task_id: kickoff.task_id, - }; renderSummary( result, syncStashView, - `Started stash to branch "${newBranch}" (task #${kickoff.task_id}).`, + `Started stash to branch "${newBranch}" (task #${result.task_id}).`, ctx, ); return; } - const final = await pollSyncTask(client, wait.schedule); - const result: SyncStashResult = { - status: kickoff.status, - message: kickoff.message, - task_id: kickoff.task_id, - final, - }; + const final = result.final ?? null; const succeeded = final === null || final.status === "successful"; const text = succeeded - ? `Stashed Metabase state to branch "${newBranch}" (task #${kickoff.task_id}).` + ? `Stashed Metabase state to branch "${newBranch}" (task #${result.task_id}).` : formatSyncTask(final); renderSummary(result, syncStashView, text, ctx); throwIfFailedTask(final, "stash"); diff --git a/src/commands/git-sync/status.ts b/packages/cli/src/commands/git-sync/status.ts similarity index 69% rename from src/commands/git-sync/status.ts rename to packages/cli/src/commands/git-sync/status.ts index bd5fced..88a5bda 100644 --- a/src/commands/git-sync/status.ts +++ b/packages/cli/src/commands/git-sync/status.ts @@ -1,21 +1,17 @@ import { z } from "zod"; -import { SyncTask } from "../../domain/git-sync"; -import type { ResourceView } from "../../domain/view"; +import { Collection } from "@metabase/client/domain/collection"; +import { SyncTask } from "@metabase/client/domain/git-sync"; + +import type { ResourceView } from "../../output/view"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { IsDirtyResult } from "./is-dirty"; -import { - fetchCurrentTask, - fetchOptionalParsed, - formatSyncTask, - REMOTE_SYNC_PATHS, -} from "./poll-task"; -import { fetchSyncedCollections, SyncedCollection } from "./sync-scope"; +import { formatSyncTask } from "./sync-task"; -const RemoteSyncBranch = z.string().nullable(); +const SyncedCollection = Collection.pick({ id: true, name: true }).strip(); +type SyncedCollection = z.infer; export const SyncStatus = z.object({ branch: z.string().nullable(), @@ -45,24 +41,26 @@ export default defineMetabaseCommand({ outputSchema: SyncStatus, examples: ["mb git-sync status", "mb git-sync status --json"], async run({ ctx, getClient }) { - const client = await getClient(); - const [branch, isDirty, currentTask, syncedCollections] = await Promise.all([ - fetchOptionalParsed(client, "/api/setting/remote-sync-branch", RemoteSyncBranch), - client.requestParsed(IsDirtyResult, REMOTE_SYNC_PATHS.isDirty), - fetchCurrentTask(client), - fetchSyncedCollections(client), + const mb = await getClient(); + const [branch, isDirty, currentTask, collections] = await Promise.all([ + mb.gitSync.branch(), + mb.gitSync.isDirty(), + mb.gitSync.currentTask(), + mb.gitSync.syncedCollections(), ]); + const syncedCollections: SyncedCollection[] = collections.data.map((collection) => ({ + id: collection.id, + name: collection.name, + })); const summary: SyncStatus = { branch, - is_dirty: isDirty.is_dirty, + is_dirty: isDirty, current_task: currentTask, synced_collections: syncedCollections, }; const branchPart = branch === null ? "git-sync branch not set" : `Branch ${branch}`; - const dirtyPart = isDirty.is_dirty - ? "Metabase has unsynced local changes" - : "in sync with the remote"; + const dirtyPart = isDirty ? "Metabase has unsynced local changes" : "in sync with the remote"; const taskPart = currentTask === null ? "No task running." : formatSyncTask(currentTask); const scopePart = formatSyncedCollections(syncedCollections); renderSummary( diff --git a/packages/cli/src/commands/git-sync/sync-scope.test.ts b/packages/cli/src/commands/git-sync/sync-scope.test.ts new file mode 100644 index 0000000..129c8f1 --- /dev/null +++ b/packages/cli/src/commands/git-sync/sync-scope.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { Collection } from "@metabase/client/domain/collection"; + +import { syncScopeHint } from "./sync-scope"; + +const REMOTE_URL = "https://github.com/acme/metabase-sync.git"; + +function collection(overrides: Partial): Collection { + return Collection.parse({ id: 51, name: "Data", ...overrides }); +} + +describe("syncScopeHint", () => { + it("returns the add-collection recipe when the collection is unsynced and a remote is configured", () => { + const hint = syncScopeHint(collection({ is_remote_synced: false }), REMOTE_URL); + expect(hint).toBe( + 'Note: collection 51 "Data" is not marked for git-sync, ' + + `so \`mb git-sync export\` will not carry it (or its published tables' metadata) to ${REMOTE_URL}. ` + + "Add it with: mb git-sync add-collection 51", + ); + }); + + it("returns null when the collection is already synced", () => { + expect(syncScopeHint(collection({ is_remote_synced: true }), REMOTE_URL)).toBeNull(); + }); + + it("returns null when the server does not report the flag", () => { + expect(syncScopeHint(collection({}), REMOTE_URL)).toBeNull(); + }); + + it("returns null when no remote sync url is configured", () => { + expect(syncScopeHint(collection({ is_remote_synced: false }), null)).toBeNull(); + }); + + it("returns null without a collection", () => { + expect(syncScopeHint(null, REMOTE_URL)).toBeNull(); + }); +}); diff --git a/packages/cli/src/commands/git-sync/sync-scope.ts b/packages/cli/src/commands/git-sync/sync-scope.ts new file mode 100644 index 0000000..ab2c53b --- /dev/null +++ b/packages/cli/src/commands/git-sync/sync-scope.ts @@ -0,0 +1,42 @@ +import type { MetabaseClient } from "@metabase/client/client"; +import { Collection } from "@metabase/client/domain/collection"; +import { errorMessage } from "@metabase/client/errors"; + +import { warn } from "../../output/notice"; + +function isOutsideSyncScope(collection: Collection | null): collection is Collection { + return collection !== null && collection.is_remote_synced === false; +} + +export function syncScopeHint( + collection: Collection | null, + remoteSyncUrl: string | null, +): string | null { + if (!isOutsideSyncScope(collection) || remoteSyncUrl === null) { + return null; + } + return ( + `Note: collection ${collection.id} "${collection.name}" is not marked for git-sync, ` + + `so \`mb git-sync export\` will not carry it (or its published tables' metadata) to ${remoteSyncUrl}. ` + + `Add it with: mb git-sync add-collection ${collection.id}` + ); +} + +// Advisory only: the command's mutation has already succeeded by the time this runs, so a +// failed scope lookup is reported on stderr instead of failing the command. +export async function warnIfOutsideSyncScope( + mb: MetabaseClient, + collection: Collection | null, +): Promise { + if (!isOutsideSyncScope(collection)) { + return; + } + try { + const hint = syncScopeHint(collection, await mb.gitSync.remoteUrl()); + if (hint !== null) { + warn(hint); + } + } catch (error) { + warn(`Could not check git-sync scope for collection ${collection.id}: ${errorMessage(error)}`); + } +} diff --git a/packages/cli/src/commands/git-sync/sync-task.test.ts b/packages/cli/src/commands/git-sync/sync-task.test.ts new file mode 100644 index 0000000..c7b3ef5 --- /dev/null +++ b/packages/cli/src/commands/git-sync/sync-task.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { SyncTask } from "@metabase/client/domain/git-sync"; + +import { formatSyncTask, throwIfFailedTask } from "./sync-task"; + +function task(overrides: Partial): SyncTask { + return SyncTask.parse({ + id: 3, + sync_task_type: "export", + status: "running", + progress: null, + started_at: "2026-05-21T00:00:00Z", + ...overrides, + }); +} + +describe("formatSyncTask", () => { + it("renders a running export with its progress as a rounded percent", () => { + expect(formatSyncTask(task({ status: "running", progress: 0.45 }))).toBe( + "Export task #3 is running (45%).", + ); + }); + + it("omits the percent when a running task reports no progress", () => { + expect(formatSyncTask(task({ sync_task_type: "import", id: 7, progress: null }))).toBe( + "Import task #7 is running.", + ); + }); + + it("renders a succeeded task", () => { + expect(formatSyncTask(task({ status: "successful" }))).toBe("Export task #3 succeeded."); + }); + + it("appends the error message for an errored task", () => { + expect(formatSyncTask(task({ status: "errored", error_message: "remote rejected" }))).toBe( + "Export task #3 errored: remote rejected.", + ); + }); + + it("renders a cancelled task without an error suffix", () => { + expect(formatSyncTask(task({ sync_task_type: "import", id: 5, status: "cancelled" }))).toBe( + "Import task #5 was cancelled.", + ); + }); +}); + +describe("throwIfFailedTask", () => { + it("throws with the verb, the status and the server's message for a failed task", () => { + expect(() => + throwIfFailedTask(task({ status: "errored", error_message: "remote rejected" }), "export"), + ).toThrow("git-sync export errored: remote rejected"); + }); + + it("throws without a detail suffix when the server reported no message", () => { + expect(() => throwIfFailedTask(task({ status: "conflict" }), "import")).toThrow( + "git-sync import conflict", + ); + }); + + it("does not throw for a cancelled task, which is not a failure", () => { + expect(() => throwIfFailedTask(task({ status: "cancelled" }), "task")).not.toThrow(); + }); + + it("does not throw when the server has no task at all", () => { + expect(() => throwIfFailedTask(null, "task")).not.toThrow(); + }); +}); diff --git a/packages/cli/src/commands/git-sync/sync-task.ts b/packages/cli/src/commands/git-sync/sync-task.ts new file mode 100644 index 0000000..f56b65a --- /dev/null +++ b/packages/cli/src/commands/git-sync/sync-task.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +import { isSyncTaskFailed, SyncTask } from "@metabase/client/domain/git-sync"; +import type { PollOptions } from "@metabase/client/poll"; + +import type { ResourceView } from "../../output/view"; +import type { WaitSchedule } from "../wait-flags"; + +export const SyncTaskIdle = z.object({ status: z.literal("idle") }); +export type SyncTaskIdle = z.infer; + +export const SyncTaskOrIdle = z.union([SyncTask, SyncTaskIdle]); +export type SyncTaskOrIdle = z.infer; + +export const syncTaskIdleView: ResourceView = { + compactPick: SyncTaskIdle, + tableColumns: [{ key: "status", label: "Status" }], +}; + +// A sync of a large instance runs for minutes and reports the same status for most of them, so the +// wait backs off rather than spending a request per interval on an answer that will not have moved. +export function taskPollOptions(schedule: WaitSchedule): PollOptions { + return { ...schedule, backoff: "exponential" }; +} + +export function throwIfFailedTask(final: SyncTask | null, verb: string): void { + if (final === null || !isSyncTaskFailed(final.status)) { + return; + } + const detail = final.error_message ? `: ${final.error_message}` : ""; + throw new Error(`git-sync ${verb} ${final.status}${detail}`); +} + +export function formatSyncTask(task: SyncTask): string { + const kind = task.sync_task_type === "export" ? "Export" : "Import"; + const label = `${kind} task #${task.id}`; + const detail = task.error_message ? `: ${task.error_message}` : ""; + switch (task.status) { + case "running": { + const percent = task.progress === null ? "" : ` (${Math.round(task.progress * 100)}%)`; + return `${label} is running${percent}.`; + } + case "successful": { + return `${label} succeeded.`; + } + case "errored": { + return `${label} errored${detail}.`; + } + case "timed-out": { + return `${label} timed out${detail}.`; + } + case "conflict": { + return `${label} hit conflicts${detail}.`; + } + case "cancelled": { + return `${label} was cancelled.`; + } + } +} diff --git a/src/commands/git-sync/wait.ts b/packages/cli/src/commands/git-sync/wait.ts similarity index 75% rename from src/commands/git-sync/wait.ts rename to packages/cli/src/commands/git-sync/wait.ts index f58d2ed..abece2e 100644 --- a/src/commands/git-sync/wait.ts +++ b/packages/cli/src/commands/git-sync/wait.ts @@ -1,18 +1,19 @@ -import { syncTaskView } from "../../domain/git-sync"; +import { DEFAULT_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from "@metabase/client/poll"; + import { renderSummary } from "../../output/render"; -import { DEFAULT_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from "../../runtime/poll"; +import { syncTaskView } from "../../output/views/git-sync"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { parseWaitSchedule } from "../wait-flags"; import { formatSyncTask, - pollSyncTask, syncTaskIdleView, SyncTaskIdle, SyncTaskOrIdle, + taskPollOptions, throwIfFailedTask, -} from "./poll-task"; +} from "./sync-task"; export const WaitResult = SyncTaskOrIdle; @@ -40,10 +41,9 @@ export default defineMetabaseCommand({ outputSchema: WaitResult, examples: ["mb git-sync wait", "mb git-sync wait --timeout 300000 --json"], async run({ args, ctx, getClient }) { - const timeoutMs = parseId(args.timeout, "timeout"); - const intervalMs = parseId(args.interval, "interval"); - const client = await getClient(); - const final = await pollSyncTask(client, { timeoutMs, intervalMs }); + const schedule = parseWaitSchedule(args); + const mb = await getClient(); + const final = await mb.gitSync.waitForTask(taskPollOptions(schedule)); if (final === null) { const idle: SyncTaskIdle = { status: "idle" }; diff --git a/src/commands/global-flags.test.ts b/packages/cli/src/commands/global-flags.test.ts similarity index 82% rename from src/commands/global-flags.test.ts rename to packages/cli/src/commands/global-flags.test.ts index 12f4c52..2737ef6 100644 --- a/src/commands/global-flags.test.ts +++ b/packages/cli/src/commands/global-flags.test.ts @@ -26,6 +26,26 @@ describe("hoistGlobalFlags", () => { ]); }); + it("moves a leading list-window flag past the verb chain with its value", () => { + expect(hoistGlobalFlags(["--limit", "5", "card", "list"])).toEqual([ + "card", + "list", + "--limit", + "5", + ]); + }); + + it("hoists --offset alongside --limit as one run", () => { + expect(hoistGlobalFlags(["--limit", "5", "--offset", "10", "card", "list"])).toEqual([ + "card", + "list", + "--limit", + "5", + "--offset", + "10", + ]); + }); + it("hoists global flags addressed by their kebab alias", () => { expect(hoistGlobalFlags(["--api-key", "secret", "db", "list"])).toEqual([ "db", diff --git a/src/commands/global-flags.ts b/packages/cli/src/commands/global-flags.ts similarity index 91% rename from src/commands/global-flags.ts rename to packages/cli/src/commands/global-flags.ts index 2d79bb5..4cce28f 100644 --- a/src/commands/global-flags.ts +++ b/packages/cli/src/commands/global-flags.ts @@ -2,12 +2,17 @@ import type { ArgsDef } from "citty"; import { flagConsumesValue, normalizeFlag, toAliasArray } from "../runtime/citty"; -import { connectionFlags, outputFlags, profileFlag } from "./flags"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "./flags"; const ARGUMENT_SEPARATOR = "--"; const NEGATION_PREFIX = "no-"; -export const GLOBAL_FLAG_ARGS: ArgsDef = { ...outputFlags, ...profileFlag, ...connectionFlags }; +const GLOBAL_FLAG_ARGS: ArgsDef = { + ...outputFlags, + ...listFlags, + ...profileFlag, + ...connectionFlags, +}; const GLOBAL_FLAG_NAMES: ReadonlySet = buildGlobalFlagNames(); diff --git a/src/commands/group.ts b/packages/cli/src/commands/group.ts similarity index 96% rename from src/commands/group.ts rename to packages/cli/src/commands/group.ts index 61cd4d1..adade15 100644 --- a/src/commands/group.ts +++ b/packages/cli/src/commands/group.ts @@ -3,7 +3,7 @@ import type { CommandDef, CommandMeta, SubCommandsDef } from "citty"; import { setMetabaseAugment, type SkillPointer } from "../runtime/command-augment"; -export interface CommandGroupDef { +interface CommandGroupDef { name: string; description: string; alias?: string; diff --git a/src/commands/known-flags.test.ts b/packages/cli/src/commands/known-flags.test.ts similarity index 97% rename from src/commands/known-flags.test.ts rename to packages/cli/src/commands/known-flags.test.ts index e77744e..3ba037c 100644 --- a/src/commands/known-flags.test.ts +++ b/packages/cli/src/commands/known-flags.test.ts @@ -1,7 +1,7 @@ import type { ArgsDef } from "citty"; import { assert, describe, expect, it } from "vitest"; -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; import { assertKnownFlags } from "./known-flags"; @@ -29,7 +29,6 @@ function expectUnknownFlag(rawArgs: readonly string[], display: string): void { expect(error).toBeInstanceOf(ConfigError); assert(error instanceof ConfigError, "expected ConfigError"); expect(error.message).toBe(`unknown flag: ${display}`); - expect(error.exitCode).toBe(2); } describe("assertKnownFlags", () => { diff --git a/src/commands/known-flags.ts b/packages/cli/src/commands/known-flags.ts similarity index 97% rename from src/commands/known-flags.ts rename to packages/cli/src/commands/known-flags.ts index 08441c8..c6ab95d 100644 --- a/src/commands/known-flags.ts +++ b/packages/cli/src/commands/known-flags.ts @@ -1,6 +1,6 @@ import type { ArgsDef } from "citty"; -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; import { flagConsumesValue, normalizeFlag, toAliasArray } from "../runtime/citty"; const ARGUMENT_SEPARATOR = "--"; diff --git a/src/commands/library/create.ts b/packages/cli/src/commands/library/create.ts similarity index 84% rename from src/commands/library/create.ts rename to packages/cli/src/commands/library/create.ts index d3cecc3..45a2432 100644 --- a/src/commands/library/create.ts +++ b/packages/cli/src/commands/library/create.ts @@ -1,10 +1,9 @@ -import { Library, libraryView } from "../../domain/library"; +import { Library } from "@metabase/client/domain/library"; +import { libraryView } from "../../output/views/library"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { createLibrary } from "./resolve"; - export default defineMetabaseCommand({ meta: { name: "create", @@ -22,7 +21,7 @@ export default defineMetabaseCommand({ examples: ["mb library create", "mb library create --json"], async run({ ctx, getClient }) { const client = await getClient(); - const library = await createLibrary(client); + const library = await client.library.create(); renderSummary(library, libraryView, `Library ready (collection ${library.id}).`, ctx); }, }); diff --git a/src/commands/library/get.ts b/packages/cli/src/commands/library/get.ts similarity index 86% rename from src/commands/library/get.ts rename to packages/cli/src/commands/library/get.ts index 5d3b4b8..4f2b831 100644 --- a/src/commands/library/get.ts +++ b/packages/cli/src/commands/library/get.ts @@ -1,10 +1,9 @@ -import { Library, libraryView } from "../../domain/library"; +import { Library } from "@metabase/client/domain/library"; +import { libraryView } from "../../output/views/library"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { fetchLibrary } from "./resolve"; - export default defineMetabaseCommand({ meta: { name: "get", @@ -22,7 +21,7 @@ export default defineMetabaseCommand({ examples: ["mb library get", "mb library get --json"], async run({ ctx, getClient }) { const client = await getClient(); - const library = await fetchLibrary(client); + const library = await client.library.get(); if (library === null) { throw new Error( "The Library has not been created yet — run `mb library create` (or publish a table with `mb library publish`).", diff --git a/src/commands/library/index.ts b/packages/cli/src/commands/library/index.ts similarity index 100% rename from src/commands/library/index.ts rename to packages/cli/src/commands/library/index.ts diff --git a/src/commands/library/publish.ts b/packages/cli/src/commands/library/publish.ts similarity index 75% rename from src/commands/library/publish.ts rename to packages/cli/src/commands/library/publish.ts index 7c1f889..e7a709b 100644 --- a/src/commands/library/publish.ts +++ b/packages/cli/src/commands/library/publish.ts @@ -1,16 +1,13 @@ import { z } from "zod"; -import { Collection, CollectionCompact } from "../../domain/collection"; -import type { ResourceView } from "../../domain/view"; +import { Collection, CollectionCompact } from "@metabase/client/domain/collection"; +import type { ResourceView } from "../../output/view"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { warnIfOutsideSyncScope } from "../git-sync/sync-scope"; import { defineMetabaseCommand } from "../runtime"; -import { ensureLibraryDataCollectionId } from "./resolve"; -import { parseTableSelectors, tableSelectorFlags, type TableSelectors } from "./selectors"; - -const PUBLISH_TABLES_PATH = "/api/ee/data-studio/table/publish-tables"; +import { parseTableSelectors, tableSelectorFlags } from "./selectors"; export const LibraryPublishResult = z.object({ target_collection: Collection.nullable(), @@ -26,10 +23,6 @@ const libraryPublishResultView: ResourceView = { tableColumns: [{ key: "target_collection", label: "Target collection" }], }; -interface PublishRequestBody extends TableSelectors { - collection_id: number; -} - export default defineMetabaseCommand({ meta: { name: "publish", @@ -53,16 +46,15 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const selectors = parseTableSelectors(args); const client = await getClient(); - const collectionId = await ensureLibraryDataCollectionId(client); - const body: PublishRequestBody = { collection_id: collectionId, ...selectors }; - - const result = await client.requestParsed(LibraryPublishResult, PUBLISH_TABLES_PATH, { - method: "POST", - body, + const collectionId = await client.library.ensureDataCollectionId(); + const target = await client.library.publishTables({ + collection_id: collectionId, + ...selectors, }); + const result: LibraryPublishResultJson = { target_collection: target }; renderSummary(result, libraryPublishResultView, summaryLine(result), ctx); - await warnIfOutsideSyncScope(client, result.target_collection); + await warnIfOutsideSyncScope(client, target); }, }); diff --git a/src/commands/library/selectors.test.ts b/packages/cli/src/commands/library/selectors.test.ts similarity index 97% rename from src/commands/library/selectors.test.ts rename to packages/cli/src/commands/library/selectors.test.ts index eb07140..db95b3b 100644 --- a/src/commands/library/selectors.test.ts +++ b/packages/cli/src/commands/library/selectors.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ConfigError } from "../../core/errors"; +import { ConfigError } from "@metabase/client/errors"; import { parseTableSelectors } from "./selectors"; diff --git a/src/commands/library/selectors.ts b/packages/cli/src/commands/library/selectors.ts similarity index 80% rename from src/commands/library/selectors.ts rename to packages/cli/src/commands/library/selectors.ts index 5a11ae6..44c517c 100644 --- a/src/commands/library/selectors.ts +++ b/packages/cli/src/commands/library/selectors.ts @@ -1,4 +1,5 @@ -import { ConfigError } from "../../core/errors"; +import { ConfigError } from "@metabase/client/errors"; +import type { LibraryTableSelectors } from "@metabase/client/resources/library"; import { parseCsv } from "../../runtime/csv"; import { parseId } from "../parse-id"; @@ -11,13 +12,7 @@ export const tableSelectorFlags = { }, } as const; -export interface TableSelectors { - database_ids?: number[]; - schema_ids?: string[]; - table_ids?: number[]; -} - -export interface TableSelectorArgs { +interface TableSelectorArgs { "table-ids"?: string | undefined; "db-ids"?: string | undefined; schemas?: string | undefined; @@ -30,11 +25,11 @@ function parseIdList(value: string | undefined, name: string): number[] { return parseCsv(value).map((part) => parseId(part, name)); } -export function parseTableSelectors(args: TableSelectorArgs): TableSelectors { +export function parseTableSelectors(args: TableSelectorArgs): LibraryTableSelectors { const tableIds = parseIdList(args["table-ids"], "table id"); const databaseIds = parseIdList(args["db-ids"], "database id"); const schemaNames = args.schemas === undefined ? [] : parseCsv(args.schemas); - const selectors: TableSelectors = {}; + const selectors: LibraryTableSelectors = {}; if (tableIds.length > 0) { selectors.table_ids = tableIds; } diff --git a/src/commands/library/unpublish.ts b/packages/cli/src/commands/library/unpublish.ts similarity index 88% rename from src/commands/library/unpublish.ts rename to packages/cli/src/commands/library/unpublish.ts index e06d613..46d027e 100644 --- a/src/commands/library/unpublish.ts +++ b/packages/cli/src/commands/library/unpublish.ts @@ -1,14 +1,12 @@ import { z } from "zod"; -import type { ResourceView } from "../../domain/view"; +import type { ResourceView } from "../../output/view"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; import { parseTableSelectors, tableSelectorFlags } from "./selectors"; -const UNPUBLISH_TABLES_PATH = "/api/ee/data-studio/table/unpublish-tables"; - export const LibraryUnpublishResult = z.object({ unpublished: z.literal(true), table_ids: z.array(z.number().int()).optional(), @@ -51,11 +49,7 @@ export default defineMetabaseCommand({ const selectors = parseTableSelectors(args); const client = await getClient(); - await client.requestRaw(UNPUBLISH_TABLES_PATH, { - method: "POST", - body: selectors, - expectContentType: "binary", - }); + await client.library.unpublishTables(selectors); const result: LibraryUnpublishResultJson = { unpublished: true, ...selectors }; renderSummary(result, libraryUnpublishResultView, "Unpublished tables from the Library.", ctx); diff --git a/src/commands/measure/archive.ts b/packages/cli/src/commands/measure/archive.ts similarity index 79% rename from src/commands/measure/archive.ts rename to packages/cli/src/commands/measure/archive.ts index 5ec2bf2..0045be3 100644 --- a/src/commands/measure/archive.ts +++ b/packages/cli/src/commands/measure/archive.ts @@ -1,4 +1,5 @@ -import { Measure, measureView } from "../../domain/measure"; +import { Measure } from "@metabase/client/domain/measure"; +import { measureView } from "../../output/views/measure"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -20,10 +21,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const updated = await client.requestParsed(Measure, `/api/measure/${id}`, { - method: "PUT", - body: { archived: true, revision_message: args.revisionMessage }, - }); + const updated = await client.measure.archive(id, { revision_message: args.revisionMessage }); renderSummary(updated, measureView, `Archived measure ${updated.id} "${updated.name}".`, ctx); }, }); diff --git a/src/commands/measure/create.ts b/packages/cli/src/commands/measure/create.ts similarity index 89% rename from src/commands/measure/create.ts rename to packages/cli/src/commands/measure/create.ts index 6fbc591..4f28aef 100644 --- a/src/commands/measure/create.ts +++ b/packages/cli/src/commands/measure/create.ts @@ -1,4 +1,5 @@ -import { Measure, MeasureCreateInput, measureView } from "../../domain/measure"; +import { Measure, MeasureCreateInput } from "@metabase/client/domain/measure"; +import { measureView } from "../../output/views/measure"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -39,10 +40,7 @@ export default defineMetabaseCommand({ skip: args["skip-validate"] === true, }); const client = await getClient(); - const created = await client.requestParsed(Measure, "/api/measure", { - method: "POST", - body, - }); + const created = await client.measure.create(body); renderSummary(created, measureView, `Created measure ${created.id} "${created.name}".`, ctx); }, }); diff --git a/src/commands/measure/get.ts b/packages/cli/src/commands/measure/get.ts similarity index 81% rename from src/commands/measure/get.ts rename to packages/cli/src/commands/measure/get.ts index f0baa4c..403ba03 100644 --- a/src/commands/measure/get.ts +++ b/packages/cli/src/commands/measure/get.ts @@ -1,4 +1,5 @@ -import { Measure, measureView } from "../../domain/measure"; +import { Measure } from "@metabase/client/domain/measure"; +import { measureView } from "../../output/views/measure"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,7 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const measure = await client.requestParsed(Measure, `/api/measure/${id}`); + const measure = await client.measure.get(id); renderItem(measure, measureView, ctx); }, }); diff --git a/src/commands/measure/index.ts b/packages/cli/src/commands/measure/index.ts similarity index 100% rename from src/commands/measure/index.ts rename to packages/cli/src/commands/measure/index.ts diff --git a/packages/cli/src/commands/measure/list.ts b/packages/cli/src/commands/measure/list.ts new file mode 100644 index 0000000..a8cb635 --- /dev/null +++ b/packages/cli/src/commands/measure/list.ts @@ -0,0 +1,22 @@ +import { MeasureCompact } from "@metabase/client/domain/measure"; +import { measureView } from "../../output/views/measure"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const MeasureListEnvelope = listEnvelopeSchema(MeasureCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List measures" }, + capabilities: { minVersion: 59 }, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, + outputSchema: MeasureListEnvelope, + examples: ["mb measure list", "mb measure list --json"], + async run({ ctx, getClient }) { + const client = await getClient(); + const { data, total } = await client.measure.list(); + renderList(windowList(data, ctx.range, total), measureView, ctx); + }, +}); diff --git a/src/commands/measure/update.ts b/packages/cli/src/commands/measure/update.ts similarity index 90% rename from src/commands/measure/update.ts rename to packages/cli/src/commands/measure/update.ts index b6d94db..526b868 100644 --- a/src/commands/measure/update.ts +++ b/packages/cli/src/commands/measure/update.ts @@ -1,4 +1,5 @@ -import { Measure, MeasureUpdateInput, measureView } from "../../domain/measure"; +import { Measure, MeasureUpdateInput } from "@metabase/client/domain/measure"; +import { measureView } from "../../output/views/measure"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -43,10 +44,7 @@ export default defineMetabaseCommand({ skip: args["skip-validate"] === true, }); const client = await getClient(); - const updated = await client.requestParsed(Measure, `/api/measure/${id}`, { - method: "PUT", - body, - }); + const updated = await client.measure.update(id, body); renderSummary(updated, measureView, `Updated measure ${updated.id} "${updated.name}".`, ctx); }, }); diff --git a/src/commands/parse-enum.test.ts b/packages/cli/src/commands/parse-enum.test.ts similarity index 93% rename from src/commands/parse-enum.test.ts rename to packages/cli/src/commands/parse-enum.test.ts index df1ed12..3f648c2 100644 --- a/src/commands/parse-enum.test.ts +++ b/packages/cli/src/commands/parse-enum.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; import { parseEnumFlag } from "./parse-enum"; diff --git a/src/commands/parse-enum.ts b/packages/cli/src/commands/parse-enum.ts similarity index 89% rename from src/commands/parse-enum.ts rename to packages/cli/src/commands/parse-enum.ts index 0655294..597c3e3 100644 --- a/src/commands/parse-enum.ts +++ b/packages/cli/src/commands/parse-enum.ts @@ -1,6 +1,6 @@ import type { z } from "zod"; -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; interface EnumSchema extends z.ZodType { options: readonly T[]; diff --git a/src/commands/parse-id.ts b/packages/cli/src/commands/parse-id.ts similarity index 100% rename from src/commands/parse-id.ts rename to packages/cli/src/commands/parse-id.ts diff --git a/src/commands/parse-integer.test.ts b/packages/cli/src/commands/parse-integer.test.ts similarity index 85% rename from src/commands/parse-integer.test.ts rename to packages/cli/src/commands/parse-integer.test.ts index 91edb83..5b7df47 100644 --- a/src/commands/parse-integer.test.ts +++ b/packages/cli/src/commands/parse-integer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; import { parseInteger, parseOptionalInteger } from "./parse-integer"; @@ -41,8 +41,10 @@ describe("parseOptionalInteger", () => { expect(parseOptionalInteger(undefined, { name: "--port", min: 1 })).toBeNull(); }); - it("returns null for an empty string (omitted flag treated like absent)", () => { - expect(parseOptionalInteger("", { name: "--port", min: 1 })).toBeNull(); + it("rejects an empty string rather than reading a collapsed shell expansion as absent", () => { + expect(() => parseOptionalInteger("", { name: "--port", min: 1 })).toThrow( + new ConfigError('invalid --port: "" (expected integer)'), + ); }); it("delegates to parseInteger for non-empty values", () => { diff --git a/src/commands/parse-integer.ts b/packages/cli/src/commands/parse-integer.ts similarity index 65% rename from src/commands/parse-integer.ts rename to packages/cli/src/commands/parse-integer.ts index e2e2ee7..a22e981 100644 --- a/src/commands/parse-integer.ts +++ b/packages/cli/src/commands/parse-integer.ts @@ -1,8 +1,8 @@ -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; const INTEGER_PATTERN = /^-?\d+$/; -export interface ParseIntegerOptions { +interface ParseIntegerOptions { name: string; min: number; } @@ -19,11 +19,14 @@ export function parseInteger(value: string, options: ParseIntegerOptions): numbe return parsed; } +// Only an absent flag is unset. An empty string is what a shell hands over for an expansion that +// resolved to nothing (`--limit "$N"`), and reading it as "no bound" turns a typo into a full +// unfiltered listing, so it fails like any other non-integer. export function parseOptionalInteger( value: string | undefined, options: ParseIntegerOptions, ): number | null { - if (value === undefined || value === "") { + if (value === undefined) { return null; } return parseInteger(value, options); diff --git a/src/commands/query.ts b/packages/cli/src/commands/query.ts similarity index 92% rename from src/commands/query.ts rename to packages/cli/src/commands/query.ts index 0773af0..17b4adb 100644 --- a/src/commands/query.ts +++ b/packages/cli/src/commands/query.ts @@ -1,17 +1,18 @@ import { z } from "zod"; -import { ConfigError } from "../core/errors"; +import { CardQueryResult } from "@metabase/client/domain/card"; +import { ConfigError } from "@metabase/client/errors"; + import { assertNotLegacyEnvelopeWrappingMbql5, getQuerySchemaBundle, isMbql5Query, validateQuery, } from "../core/schema/validate"; -import { CardQueryResult, cardQueryView } from "../domain/card"; import { formatQueryResult } from "../output/query-result"; import { renderSummary, writeJson } from "../output/render"; +import { cardQueryView } from "../output/views/card"; import { readBody } from "../runtime/body"; - import { bodyInputFlags } from "./body-flags"; import { connectionFlags, outputFlags, profileFlag } from "./flags"; import { defineMetabaseCommand } from "./runtime"; @@ -23,8 +24,6 @@ const QueryBody = z "MBQL 5, legacy MBQL, or native query body — full MBQL 5 schema: mb query --print-schema", ); -const QUERY_ENDPOINT = "/api/dataset"; - export default defineMetabaseCommand({ meta: { name: "query", @@ -94,10 +93,7 @@ export default defineMetabaseCommand({ } const client = await getClient(); - const queryResult = await client.requestParsed(CardQueryResult, QUERY_ENDPOINT, { - method: "POST", - body, - }); + const queryResult = await client.dataset.query(body); renderSummary(queryResult, cardQueryView, () => formatQueryResult(queryResult), ctx); }, }); diff --git a/src/commands/revision-message-flag.ts b/packages/cli/src/commands/revision-message-flag.ts similarity index 76% rename from src/commands/revision-message-flag.ts rename to packages/cli/src/commands/revision-message-flag.ts index bdf602d..2a091dc 100644 --- a/src/commands/revision-message-flag.ts +++ b/packages/cli/src/commands/revision-message-flag.ts @@ -1,4 +1,4 @@ -export const DEFAULT_ARCHIVE_REVISION_MESSAGE = "Archived via mb CLI"; +const DEFAULT_ARCHIVE_REVISION_MESSAGE = "Archived via mb CLI"; export const revisionMessageFlag = { revisionMessage: { diff --git a/src/commands/runtime.test.ts b/packages/cli/src/commands/runtime.test.ts similarity index 79% rename from src/commands/runtime.test.ts rename to packages/cli/src/commands/runtime.test.ts index 14528c9..fa64ef6 100644 --- a/src/commands/runtime.test.ts +++ b/packages/cli/src/commands/runtime.test.ts @@ -1,8 +1,9 @@ import { runCommand } from "citty"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ServerInfo } from "@metabase/client/version/probe"; + import { setupTempConfigHome, type TempConfigHome } from "../core/auth/temp-config-home"; -import type { ServerInfo } from "../core/version/probe"; const hoisted = vi.hoisted(() => ({ store: new Map(), @@ -15,7 +16,7 @@ vi.mock("@napi-rs/keyring", async () => { }); const { defineMetabaseCommand, SKIP_PREFLIGHT_ENV } = await import("./runtime"); -const { connectionFlags, outputFlags, profileFlag } = await import("./flags"); +const { connectionFlags, listFlags, outputFlags, profileFlag } = await import("./flags"); const { writeProbeResult, writeProfile } = await import("../core/auth/storage"); async function seedProbedProfile(name: string, info: ServerInfo): Promise { @@ -70,6 +71,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo" }, + capabilities: {}, args: { ...outputFlags, custom: { type: "string", description: "custom flag" } }, run({ args, ctx }) { observed(ctx.format, args.custom); @@ -84,6 +86,7 @@ describe("defineMetabaseCommand", () => { const observed = vi.fn<(profile: string | undefined, url: string | undefined) => void>(); const cmd = defineMetabaseCommand({ meta: { name: "bare", description: "no opt-ins" }, + capabilities: {}, args: {}, run({ ctx }) { observed(ctx.profile, ctx.url); @@ -100,6 +103,7 @@ describe("defineMetabaseCommand", () => { const observed = vi.fn<(client: unknown) => void>(); const cmd = defineMetabaseCommand({ meta: { name: "uses-client", description: "uses the client" }, + capabilities: {}, args: { ...profileFlag }, async run({ getClient }) { const client = await getClient(); @@ -114,6 +118,7 @@ describe("defineMetabaseCommand", () => { it("does not call resolveConfig when the run handler never calls getClient", async () => { const cmd = defineMetabaseCommand({ meta: { name: "no-client", description: "does not need the client" }, + capabilities: {}, args: {}, run() { return; @@ -129,6 +134,7 @@ describe("defineMetabaseCommand", () => { let second: unknown; const cmd = defineMetabaseCommand({ meta: { name: "cached", description: "client is cached" }, + capabilities: {}, args: {}, async run({ getClient }) { first = await getClient(); @@ -142,6 +148,7 @@ describe("defineMetabaseCommand", () => { it("reports ConfigError as a JSON error envelope to stderr (non-TTY format) and sets exitCode 2", async () => { const cmd = defineMetabaseCommand({ meta: { name: "needs-creds", description: "needs creds" }, + capabilities: {}, args: {}, async run({ getClient }) { await getClient(); @@ -164,6 +171,75 @@ describe("defineMetabaseCommand", () => { expect(process.exitCode).toBe(2); }); + it("reports a rejected --limit as a JSON error envelope and never runs the command", async () => { + const ran = vi.fn(); + const cmd = defineMetabaseCommand({ + meta: { name: "ranged", description: "takes a range" }, + capabilities: {}, + args: { ...outputFlags, ...listFlags }, + run() { + ran(); + }, + }); + const stderr = captureStderr(); + + await runCommand(cmd, { rawArgs: ["--json", "--limit", "0"] }); + + const parsed: unknown = JSON.parse(stderr.join("")); + expect(parsed).toEqual({ + ok: false, + error: { + category: "config", + message: "invalid --limit: 0 (must be ≥ 1)", + exitCode: 2, + }, + }); + expect(process.exitCode).toBe(2); + expect(ran).not.toHaveBeenCalled(); + }); + + it("reports a rejected --max-bytes as a JSON error envelope", async () => { + const cmd = defineMetabaseCommand({ + meta: { name: "capped", description: "takes a cap" }, + capabilities: {}, + args: { ...outputFlags }, + run() { + return; + }, + }); + const stderr = captureStderr(); + + await runCommand(cmd, { rawArgs: ["--json", "--max-bytes", "abc"] }); + + const parsed: unknown = JSON.parse(stderr.join("")); + expect(parsed).toEqual({ + ok: false, + error: { + category: "config", + message: 'invalid --max-bytes: "abc" (expected integer)', + exitCode: 2, + }, + }); + expect(process.exitCode).toBe(2); + }); + + it("reports an unresolvable --format as plain text, there being no format to serialize into", async () => { + const cmd = defineMetabaseCommand({ + meta: { name: "misformatted", description: "bad format" }, + capabilities: {}, + args: { ...outputFlags }, + run() { + return; + }, + }); + const stderr = captureStderr(); + + await runCommand(cmd, { rawArgs: ["--format", "bogus"] }); + + expect(stderr.join("")).toBe('invalid --format value: "bogus" (expected: auto, json, text)\n'); + expect(process.exitCode).toBe(2); + }); + it("refuses with CapabilityError exit code 2 when the cached server major is below required minVersion", async () => { await seedProbedProfile("default", fakeServerInfo(58)); @@ -182,7 +258,7 @@ describe("defineMetabaseCommand", () => { await runCommand(cmd, { rawArgs: [] }); expect(stderr.join("")).toContain( - "This command requires Metabase v60+ (this server is v0.58.0). Upgrade Metabase or pin mb-cli to an older release.", + "This operation requires Metabase v60+ (this server is v0.58.0). Upgrade Metabase to use it.", ); expect(process.exitCode).toBe(2); expect(ran).not.toHaveBeenCalled(); @@ -206,7 +282,7 @@ describe("defineMetabaseCommand", () => { await runCommand(cmd, { rawArgs: [] }); expect(stderr.join("")).toContain( - "This command requires the 'transforms' premium feature (not enabled on this server).", + "This operation requires the 'transforms' premium feature (not enabled on this server).", ); expect(process.exitCode).toBe(2); expect(ran).not.toHaveBeenCalled(); @@ -218,6 +294,7 @@ describe("defineMetabaseCommand", () => { const ran = vi.fn(); const cmd = defineMetabaseCommand({ meta: { name: "no-caps", description: "no caps" }, + capabilities: {}, args: {}, async run({ getClient }) { await getClient(); diff --git a/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts similarity index 80% rename from src/commands/runtime.ts rename to packages/cli/src/commands/runtime.ts index 25bcb2c..63e5faa 100644 --- a/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -2,6 +2,16 @@ import { defineCommand } from "citty"; import type { ArgsDef, CommandDef, CommandMeta, ParsedArgs } from "citty"; import type { ZodType } from "zod"; +import type { MetabaseClient } from "@metabase/client/client"; +import { + BASELINE_CAPABILITIES, + checkCapabilities, + mergeCapabilities, + type Capabilities, +} from "@metabase/client/version/capabilities"; +import { CapabilityError } from "@metabase/client/version/preflight-error"; +import { type ServerInfo } from "@metabase/client/version/probe"; + import { consumeKeyringDowngradeWarning, consumeLegacyStorageWarning, @@ -16,33 +26,30 @@ import { type ResolvedConfig, } from "../core/config"; import { consumeLegacyEnvWarnings } from "../core/env"; -import { createClient, type Client } from "../core/http/client"; -import { - BASELINE_CAPABILITIES, - checkCapabilities, - mergeCapabilities, - type Capabilities, -} from "../core/version/capabilities"; -import { CapabilityError } from "../core/version/preflight-error"; -import { type ServerInfo } from "../core/version/probe"; +import { USER_AGENT } from "../core/user-agent"; import { reportError } from "../output/error"; import { warn } from "../output/notice"; import { setMetabaseAugment, type SkillPointer } from "../runtime/command-augment"; - -import { resolveCommonFlags, type CommonArgs, type CommonContext } from "./context"; +import { interruptSignal } from "../runtime/interrupt"; +import { + resolveCommonFlags, + resolveOutputFormat, + type CommonArgs, + type CommonContext, +} from "./context"; import { assertKnownFlags } from "./known-flags"; export { SKIP_PREFLIGHT_ENV }; -export interface MetabaseCommandContext { +interface MetabaseCommandContext { args: ParsedArgs; ctx: CommonContext; - getClient: () => Promise; + getClient: () => Promise; getResolvedConfig: () => Promise; getServerInfo: () => Promise; } -export interface MetabaseCommandDef { +interface MetabaseCommandDef { meta: CommandMeta; args: A; examples?: readonly string[]; @@ -50,7 +57,7 @@ export interface MetabaseCommandDef { skills?: readonly SkillPointer[]; inputSchema?: ZodType; outputSchema?: ZodType; - capabilities?: Partial | null; + capabilities: Partial | null; run: (context: MetabaseCommandContext) => Promise | void; } @@ -63,13 +70,14 @@ export function defineMetabaseCommand( meta: def.meta, args: def.args, async run({ args, rawArgs }) { + const commonArgs = pickCommonArgs(args); let reportFormat: CommonContext["format"] | undefined; try { - const ctx = resolveCommonFlags(pickCommonArgs(args)); - reportFormat = ctx.format; + reportFormat = resolveOutputFormat(commonArgs); + const ctx = resolveCommonFlags(commonArgs); assertKnownFlags(rawArgs, def.args); let cachedConfig: ResolvedConfig | null = null; - let cachedClient: Client | null = null; + let cachedClient: MetabaseClient | null = null; let cachedServerInfo: Promise | null = null; const getResolvedConfig = async (): Promise => { if (cachedConfig === null) { @@ -83,14 +91,20 @@ export function defineMetabaseCommand( } return cachedServerInfo; }; - const rawGetClient = async (): Promise => { + // Imported here rather than at the top of the file so the resource namespaces `createClient` + // composes — and the whole `domain/` layer behind them — stay off the chunk every command + // loads, including `--help`, a flag error, and the commands that open no socket at all. + const rawGetClient = async (): Promise => { if (cachedClient === null) { const resolved = await getResolvedConfig(); + const { createClient } = await import("@metabase/client/client"); cachedClient = createClient( { url: resolved.url, credential: resolved.credential }, { + userAgent: USER_AGENT, getServerTag: async () => (await getServerInfo())?.version?.tag ?? null, refreshCredential: createCredentialRefresher(resolved.profile), + signal: interruptSignal, }, ); } @@ -101,7 +115,7 @@ export function defineMetabaseCommand( getServerInfo, ctx.skipPreflight, ); - const getClient = async (): Promise => { + const getClient = async (): Promise => { const client = await rawGetClient(); await enforcePreflight(); return client; @@ -214,6 +228,12 @@ function pickCommonArgs(args: ParsedArgs): CommonArgs { if (typeof args["maxBytes"] === "string") { out.maxBytes = args["maxBytes"]; } + if (typeof args["limit"] === "string") { + out.limit = args["limit"]; + } + if (typeof args["offset"] === "string") { + out.offset = args["offset"]; + } if (typeof args["profile"] === "string") { out.profile = args["profile"]; } diff --git a/src/commands/search.ts b/packages/cli/src/commands/search.ts similarity index 63% rename from src/commands/search.ts rename to packages/cli/src/commands/search.ts index 40e9420..f4cde24 100644 --- a/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -1,31 +1,20 @@ -import { z } from "zod"; - -import { - SEARCH_MODELS, - SearchModel, - SearchResult, - SearchResultCompact, - searchResultView, -} from "../domain/search"; +import { SEARCH_MODELS, SearchModel, SearchResultCompact } from "@metabase/client/domain/search"; +import { searchResultView } from "../output/views/search"; import { renderList } from "../output/render"; -import { listEnvelopeSchema, type ListEnvelope } from "../output/types"; +import { listEnvelopeSchema } from "../output/types"; +import { windowServerPage } from "../output/window"; import { parseEnumCsv } from "../runtime/csv"; -import { connectionFlags, outputFlags, profileFlag } from "./flags"; +import { connectionFlags, listFlagsWithDefaultLimit, outputFlags, profileFlag } from "./flags"; import { parseId } from "./parse-id"; import { defineMetabaseCommand } from "./runtime"; +// Unbounded, the server ranks and then hydrates up to `max-filtered-results` (1000) rows, running +// the per-row `can_write` permission check on every one — a cost the output cap would then throw +// away. The window is the request, so it has to be sized before the request is made. const DEFAULT_LIMIT = 20; const SEARCH_MODELS_DESCRIPTION = `Comma-separated model filter: ${SEARCH_MODELS.join(",")}`; -const SearchApiResponse = z - .object({ - data: z.array(SearchResult), - total: z.number().int().nonnegative(), - limit: z.number().int().nullable(), - }) - .loose(); - export const SearchListEnvelope = listEnvelopeSchema(SearchResultCompact); export default defineMetabaseCommand({ @@ -39,6 +28,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlagsWithDefaultLimit(DEFAULT_LIMIT), ...profileFlag, ...connectionFlags, query: { @@ -56,11 +46,6 @@ export default defineMetabaseCommand({ description: "Search only archived items (instead of only active ones)", default: false, }, - limit: { - type: "string", - description: "Max results to return", - default: String(DEFAULT_LIMIT), - }, "db-id": { type: "string", description: "Restrict to items on a given database id", @@ -77,30 +62,22 @@ export default defineMetabaseCommand({ "mb search products --archived", ], async run({ args, ctx, getClient }) { - const limit = parseId(args.limit, "--limit"); const tableDbIdRaw = args["db-id"]; const tableDbId = tableDbIdRaw ? parseId(tableDbIdRaw, "--db-id") : undefined; const models = parseEnumCsv(args.models, SearchModel, "--models"); const client = await getClient(); - const response = await client.requestParsed(SearchApiResponse, "/api/search", { - query: { - q: nonEmpty(args.query), - models, - archived: args.archived ? true : undefined, - limit, - table_db_id: tableDbId, - verified: args.verified ? true : undefined, - }, + const { data, total } = await client.search.query({ + q: nonEmpty(args.query), + models, + archived: args.archived ? true : undefined, + limit: ctx.range.limit, + offset: ctx.range.offset, + table_db_id: tableDbId, + verified: args.verified ? true : undefined, }); - const envelope: ListEnvelope = { - data: response.data, - returned: response.data.length, - total: response.total, - limit: response.limit ?? undefined, - }; - renderList(envelope, searchResultView, ctx); + renderList(windowServerPage(data, total, ctx.range), searchResultView, ctx); }, }); diff --git a/src/commands/segment/archive.ts b/packages/cli/src/commands/segment/archive.ts similarity index 79% rename from src/commands/segment/archive.ts rename to packages/cli/src/commands/segment/archive.ts index 71c4332..2ba5bc0 100644 --- a/src/commands/segment/archive.ts +++ b/packages/cli/src/commands/segment/archive.ts @@ -1,4 +1,5 @@ -import { Segment, segmentView } from "../../domain/segment"; +import { Segment } from "@metabase/client/domain/segment"; +import { segmentView } from "../../output/views/segment"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -20,10 +21,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const updated = await client.requestParsed(Segment, `/api/segment/${id}`, { - method: "PUT", - body: { archived: true, revision_message: args.revisionMessage }, - }); + const updated = await client.segment.archive(id, { revision_message: args.revisionMessage }); renderSummary(updated, segmentView, `Archived segment ${updated.id} "${updated.name}".`, ctx); }, }); diff --git a/src/commands/segment/create.ts b/packages/cli/src/commands/segment/create.ts similarity index 89% rename from src/commands/segment/create.ts rename to packages/cli/src/commands/segment/create.ts index b74a14e..5b6cf24 100644 --- a/src/commands/segment/create.ts +++ b/packages/cli/src/commands/segment/create.ts @@ -1,4 +1,5 @@ -import { Segment, SegmentCreateInput, segmentView } from "../../domain/segment"; +import { Segment, SegmentCreateInput } from "@metabase/client/domain/segment"; +import { segmentView } from "../../output/views/segment"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -39,10 +40,7 @@ export default defineMetabaseCommand({ skip: args["skip-validate"] === true, }); const client = await getClient(); - const created = await client.requestParsed(Segment, "/api/segment", { - method: "POST", - body, - }); + const created = await client.segment.create(body); renderSummary(created, segmentView, `Created segment ${created.id} "${created.name}".`, ctx); }, }); diff --git a/src/commands/segment/get.ts b/packages/cli/src/commands/segment/get.ts similarity index 81% rename from src/commands/segment/get.ts rename to packages/cli/src/commands/segment/get.ts index 06adc22..a429843 100644 --- a/src/commands/segment/get.ts +++ b/packages/cli/src/commands/segment/get.ts @@ -1,4 +1,5 @@ -import { Segment, segmentView } from "../../domain/segment"; +import { Segment } from "@metabase/client/domain/segment"; +import { segmentView } from "../../output/views/segment"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,7 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const segment = await client.requestParsed(Segment, `/api/segment/${id}`); + const segment = await client.segment.get(id); renderItem(segment, segmentView, ctx); }, }); diff --git a/src/commands/segment/index.ts b/packages/cli/src/commands/segment/index.ts similarity index 100% rename from src/commands/segment/index.ts rename to packages/cli/src/commands/segment/index.ts diff --git a/packages/cli/src/commands/segment/list.ts b/packages/cli/src/commands/segment/list.ts new file mode 100644 index 0000000..52075ab --- /dev/null +++ b/packages/cli/src/commands/segment/list.ts @@ -0,0 +1,22 @@ +import { SegmentCompact } from "@metabase/client/domain/segment"; +import { segmentView } from "../../output/views/segment"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const SegmentListEnvelope = listEnvelopeSchema(SegmentCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List segments" }, + capabilities: { minVersion: 58 }, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, + outputSchema: SegmentListEnvelope, + examples: ["mb segment list", "mb segment list --json"], + async run({ ctx, getClient }) { + const client = await getClient(); + const { data, total } = await client.segment.list(); + renderList(windowList(data, ctx.range, total), segmentView, ctx); + }, +}); diff --git a/src/commands/segment/update.ts b/packages/cli/src/commands/segment/update.ts similarity index 90% rename from src/commands/segment/update.ts rename to packages/cli/src/commands/segment/update.ts index 6367872..a185f9b 100644 --- a/src/commands/segment/update.ts +++ b/packages/cli/src/commands/segment/update.ts @@ -1,4 +1,5 @@ -import { Segment, SegmentUpdateInput, segmentView } from "../../domain/segment"; +import { Segment, SegmentUpdateInput } from "@metabase/client/domain/segment"; +import { segmentView } from "../../output/views/segment"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -43,10 +44,7 @@ export default defineMetabaseCommand({ skip: args["skip-validate"] === true, }); const client = await getClient(); - const updated = await client.requestParsed(Segment, `/api/segment/${id}`, { - method: "PUT", - body, - }); + const updated = await client.segment.update(id, body); renderSummary(updated, segmentView, `Updated segment ${updated.id} "${updated.name}".`, ctx); }, }); diff --git a/src/commands/setting/get.ts b/packages/cli/src/commands/setting/get.ts similarity index 50% rename from src/commands/setting/get.ts rename to packages/cli/src/commands/setting/get.ts index b46c5ff..a8e8954 100644 --- a/src/commands/setting/get.ts +++ b/packages/cli/src/commands/setting/get.ts @@ -1,13 +1,10 @@ -import { z } from "zod"; +import { SettingValue } from "@metabase/client/domain/setting"; -import type { Client } from "../../core/http/client"; -import { SettingValue, settingValueView } from "../../domain/setting"; import { formatScalar, renderSummary } from "../../output/render"; -import { parseJsonOrPlain } from "../../runtime/json"; +import { settingValueView } from "../../output/views/setting"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; - -import { parseSettingKey, rethrowSettingError } from "./key"; +import { parseSettingKey } from "./key"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a setting value by key" }, @@ -23,24 +20,8 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const key = parseSettingKey(args.key); const client = await getClient(); - const value = await fetchSettingValue(client, key).catch((error: unknown) => - rethrowSettingError(error, key), - ); + const value = await client.setting.get(key); const item: SettingValue = { key, value }; renderSummary(item, settingValueView, formatScalar(value), ctx); }, }); - -async function fetchSettingValue(client: Client, key: string): Promise { - const response = await client.requestRaw(`/api/setting/${encodeURIComponent(key)}`, { - method: "GET", - expectContentType: "binary", - }); - if (response.status === 204) { - return null; - } - const text = await response.text(); - return parseJsonOrPlain(text, response.headers.get("content-type"), z.unknown(), { - source: response.url, - }); -} diff --git a/src/commands/setting/index.ts b/packages/cli/src/commands/setting/index.ts similarity index 100% rename from src/commands/setting/index.ts rename to packages/cli/src/commands/setting/index.ts diff --git a/packages/cli/src/commands/setting/key.test.ts b/packages/cli/src/commands/setting/key.test.ts new file mode 100644 index 0000000..81ce92f --- /dev/null +++ b/packages/cli/src/commands/setting/key.test.ts @@ -0,0 +1,29 @@ +import { assert, describe, expect, it } from "vitest"; + +import { ConfigError } from "@metabase/client/errors"; + +import { parseSettingKey } from "./key"; + +function thrownBy(run: () => void): unknown { + try { + run(); + } catch (error: unknown) { + return error; + } + throw new Error("expected the call to throw"); +} + +describe("parseSettingKey", () => { + it("trims and returns a valid kebab-case key", () => { + expect(parseSettingKey(" remote-sync-branch ")).toBe("remote-sync-branch"); + }); + + it("rejects a key that is not a kebab-case identifier", () => { + const error = thrownBy(() => parseSettingKey("not a key!")); + expect(error).toBeInstanceOf(ConfigError); + assert(error instanceof ConfigError, "expected ConfigError"); + expect(error.message).toBe( + 'invalid setting key: "not a key!" (expected kebab-case identifier)', + ); + }); +}); diff --git a/packages/cli/src/commands/setting/key.ts b/packages/cli/src/commands/setting/key.ts new file mode 100644 index 0000000..1d0164e --- /dev/null +++ b/packages/cli/src/commands/setting/key.ts @@ -0,0 +1,11 @@ +import { ConfigError } from "@metabase/client/errors"; + +const SETTING_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; + +export function parseSettingKey(value: string): string { + const trimmed = value.trim(); + if (!SETTING_KEY_PATTERN.test(trimmed)) { + throw new ConfigError(`invalid setting key: "${value}" (expected kebab-case identifier)`); + } + return trimmed; +} diff --git a/packages/cli/src/commands/setting/list.ts b/packages/cli/src/commands/setting/list.ts new file mode 100644 index 0000000..f08b907 --- /dev/null +++ b/packages/cli/src/commands/setting/list.ts @@ -0,0 +1,22 @@ +import { SettingCompact } from "@metabase/client/domain/setting"; +import { settingView } from "../../output/views/setting"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const SettingListEnvelope = listEnvelopeSchema(SettingCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List all settings (admin)" }, + capabilities: { minVersion: 58 }, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, + outputSchema: SettingListEnvelope, + examples: ["mb setting list", "mb setting list --json"], + async run({ ctx, getClient }) { + const client = await getClient(); + const { data, total } = await client.setting.list(); + renderList(windowList(data, ctx.range, total), settingView, ctx); + }, +}); diff --git a/src/commands/setting/set.ts b/packages/cli/src/commands/setting/set.ts similarity index 82% rename from src/commands/setting/set.ts rename to packages/cli/src/commands/setting/set.ts index f3021e6..690f70e 100644 --- a/src/commands/setting/set.ts +++ b/packages/cli/src/commands/setting/set.ts @@ -1,12 +1,13 @@ import { z } from "zod"; -import { SettingValue, settingValueView } from "../../domain/setting"; +import { SettingValue } from "@metabase/client/domain/setting"; +import { settingValueView } from "../../output/views/setting"; import { formatScalar, renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { parseSettingKey, rethrowSettingError } from "./key"; +import { parseSettingKey } from "./key"; const SettingValueInput = z .unknown() @@ -39,13 +40,7 @@ export default defineMetabaseCommand({ SettingValueInput, ); const client = await getClient(); - await client - .requestRaw(`/api/setting/${encodeURIComponent(key)}`, { - method: "PUT", - body: { value }, - expectContentType: "binary", - }) - .catch((error: unknown) => rethrowSettingError(error, key)); + await client.setting.set(key, value); const item: SettingValue = { key, value }; const message = value === null ? `Cleared "${key}".` : `Set "${key}" to ${formatScalar(value)}.`; diff --git a/src/commands/setup.ts b/packages/cli/src/commands/setup.ts similarity index 84% rename from src/commands/setup.ts rename to packages/cli/src/commands/setup.ts index 2c1da05..2e6cd39 100644 --- a/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -1,4 +1,5 @@ -import { SetupInput, SetupResult, setupResultView } from "../domain/setup"; +import { SetupInput, SetupResult } from "@metabase/client/domain/setup"; +import { setupResultView } from "../output/views/setup"; import { renderSummary } from "../output/render"; import { readBody } from "../runtime/body"; @@ -28,10 +29,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, SetupInput); const client = await getClient(); - const result = await client.requestParsed(SetupResult, "/api/setup", { - method: "POST", - body, - }); + const result = await client.setup.create(body); renderSummary(result, setupResultView, "Metabase setup complete.", ctx); }, }); diff --git a/packages/cli/src/commands/skills/get.test.ts b/packages/cli/src/commands/skills/get.test.ts new file mode 100644 index 0000000..d750779 --- /dev/null +++ b/packages/cli/src/commands/skills/get.test.ts @@ -0,0 +1,62 @@ +import { runCommand } from "citty"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ENV_SKILLS_DIR } from "../../core/env"; +import { findSkillByName, loadAllSkills, readSkillContent } from "../../core/skills"; +import { createTempSkillsDir, type TempSkillsDir } from "../../core/temp-skills-dir"; +import { fitWithinCap } from "../../output/cap"; +import { DEFAULT_MAX_BYTES, FULL_RANGE } from "../../output/types"; +import { windowList } from "../../output/window"; +import skillsGetCommand from "./get"; + +function captureStdout(): string[] { + const chunks: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + chunks.push(String(chunk)); + return true; + }); + return chunks; +} + +describe("skills get command", () => { + let skills: TempSkillsDir; + + beforeEach(() => { + skills = createTempSkillsDir(); + vi.stubEnv(ENV_SKILLS_DIR, skills.path); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + skills.cleanup(); + }); + + it("windows the text output rather than accepting --limit and ignoring it", async () => { + const chunks = captureStdout(); + + await runCommand(skillsGetCommand, { + rawArgs: ["alpha,beta", "--format", "text", "--limit", "1"], + }); + + expect(chunks.join("")).toBe( + "---\nname: alpha\ndescription: The first skill.\n---\n\nAlpha instructions.\n", + ); + }); +}); + +// `core` is the skill every agent is told to load first, and it is a single indivisible row: once +// its body outgrows the default cap the command has nothing left to drop and refuses outright. +// The ceiling belongs at commit time, not in an agent session. +describe("the shipped core skill", () => { + it("fits the default output cap as a one-row json envelope", () => { + const info = findSkillByName(loadAllSkills(), "core"); + const envelope = windowList([readSkillContent(info, { includeExtras: false })], FULL_RANGE); + + expect(fitWithinCap(envelope, DEFAULT_MAX_BYTES)).toEqual({ + count: 1, + fullBytes: Buffer.byteLength(JSON.stringify(envelope), "utf8"), + cut: false, + }); + }); +}); diff --git a/src/commands/skills/get.ts b/packages/cli/src/commands/skills/get.ts similarity index 77% rename from src/commands/skills/get.ts rename to packages/cli/src/commands/skills/get.ts index 0cd049f..2a6fce2 100644 --- a/src/commands/skills/get.ts +++ b/packages/cli/src/commands/skills/get.ts @@ -1,4 +1,5 @@ -import { ConfigError } from "../../core/errors"; +import { ConfigError } from "@metabase/client/errors"; + import { loadAllSkills, loadVisibleSkills, @@ -7,15 +8,21 @@ import { SkillContent, type SkillInfo, } from "../../core/skills"; -import type { ResourceView } from "../../domain/view"; import { renderList, writeText } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { listEnvelopeSchema } from "../../output/types"; +import type { ResourceView } from "../../output/view"; +import { windowList } from "../../output/window"; import { parseCsv } from "../../runtime/csv"; -import { outputFlags } from "../flags"; +import { listFlags, outputFlags } from "../flags"; import { defineMetabaseCommand } from "../runtime"; export const SkillGetEnvelope = listEnvelopeSchema(SkillContent); +// A skill is one indivisible document, so the generic advice to narrow the fields or filter the +// list buys nothing: the whole body is what was asked for. +const SKILL_OVERSIZE_HINT = + "a skill body is indivisible — pass --max-bytes 0 to print it whole, or `mb skills path ` to read it from disk"; + const skillContentView: ResourceView = { compactPick: SkillContent, tableColumns: [ @@ -30,8 +37,10 @@ export default defineMetabaseCommand({ description: "Print one or more skills' SKILL.md content. Pass comma-separated names, or --all for every non-hidden skill. --full includes references and templates.", }, + capabilities: null, args: { ...outputFlags, + ...listFlags, names: { type: "positional", description: "Skill name (or comma-separated list). Omit when using --all.", @@ -53,11 +62,12 @@ export default defineMetabaseCommand({ const selected = pickSkills({ names: args.names, all: args.all === true }); const payloads = selected.map((info) => readSkillContent(info, { includeExtras: ctx.full })); + const envelope = windowList(payloads, ctx.range); if (ctx.format === "json") { - renderList(wrapList(payloads), skillContentView, ctx); + renderList(envelope, skillContentView, { ...ctx, oversizeHint: SKILL_OVERSIZE_HINT }); return; } - writeText(renderText(payloads, ctx.full)); + writeText(renderText(envelope.data, ctx.full)); }, }); diff --git a/src/commands/skills/index.ts b/packages/cli/src/commands/skills/index.ts similarity index 100% rename from src/commands/skills/index.ts rename to packages/cli/src/commands/skills/index.ts diff --git a/packages/cli/src/commands/skills/list.test.ts b/packages/cli/src/commands/skills/list.test.ts new file mode 100644 index 0000000..bba9f4d --- /dev/null +++ b/packages/cli/src/commands/skills/list.test.ts @@ -0,0 +1,38 @@ +import { runCommand } from "citty"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ENV_SKILLS_DIR } from "../../core/env"; +import { createTempSkillsDir, type TempSkillsDir } from "../../core/temp-skills-dir"; +import skillsListCommand from "./list"; + +function captureStdout(): string[] { + const chunks: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + chunks.push(String(chunk)); + return true; + }); + return chunks; +} + +describe("skills list command", () => { + let skills: TempSkillsDir; + + beforeEach(() => { + skills = createTempSkillsDir(); + vi.stubEnv(ENV_SKILLS_DIR, skills.path); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + skills.cleanup(); + }); + + it("windows the text listing rather than accepting --limit and ignoring it", async () => { + const chunks = captureStdout(); + + await runCommand(skillsListCommand, { rawArgs: ["--format", "text", "--limit", "1"] }); + + expect(chunks.join("")).toBe("alpha\n The first skill.\n\n"); + }); +}); diff --git a/src/commands/skills/list.ts b/packages/cli/src/commands/skills/list.ts similarity index 68% rename from src/commands/skills/list.ts rename to packages/cli/src/commands/skills/list.ts index 5981476..6c59b5b 100644 --- a/src/commands/skills/list.ts +++ b/packages/cli/src/commands/skills/list.ts @@ -1,18 +1,20 @@ import { z } from "zod"; +import type { ResourceView } from "../../output/view"; + import { loadVisibleSkills } from "../../core/skills"; -import type { ResourceView } from "../../domain/view"; import { renderList } from "../../output/render"; import { renderSkillList } from "../../output/skill-list"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { outputFlags } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { listFlags, outputFlags } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -export const SkillSummary = z.object({ +const SkillSummary = z.object({ name: z.string(), description: z.string(), }); -export type SkillSummaryJson = z.infer; +type SkillSummaryJson = z.infer; export const SkillListEnvelope = listEnvelopeSchema(SkillSummary); @@ -30,7 +32,8 @@ export default defineMetabaseCommand({ description: "List CLI-bundled skills — always consult the matching skill before acting on a task; they are the source of truth for every workflow.", }, - args: { ...outputFlags }, + capabilities: null, + args: { ...outputFlags, ...listFlags }, outputSchema: SkillListEnvelope, examples: ["mb skills list", "mb skills list --json"], run({ ctx }) { @@ -38,10 +41,11 @@ export default defineMetabaseCommand({ name: s.name, description: s.description, })); + const envelope = windowList(items, ctx.range); if (ctx.format === "json" || ctx.fields !== undefined || ctx.full) { - renderList(wrapList(items), skillSummaryView, ctx); + renderList(envelope, skillSummaryView, ctx); return; } - renderSkillList(items, ctx.maxBytes); + renderSkillList(envelope.data, ctx.maxBytes); }, }); diff --git a/src/commands/skills/path.ts b/packages/cli/src/commands/skills/path.ts similarity index 77% rename from src/commands/skills/path.ts rename to packages/cli/src/commands/skills/path.ts index 32cad03..33c8e42 100644 --- a/src/commands/skills/path.ts +++ b/packages/cli/src/commands/skills/path.ts @@ -1,17 +1,19 @@ import { z } from "zod"; +import type { ResourceView } from "../../output/view"; + import { findSkillByName, loadAllSkills, loadVisibleSkills } from "../../core/skills"; -import type { ResourceView } from "../../domain/view"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { outputFlags } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { listFlags, outputFlags } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -export const SkillPath = z.object({ +const SkillPath = z.object({ name: z.string(), dir: z.string(), }); -export type SkillPathJson = z.infer; +type SkillPathJson = z.infer; export const SkillPathListEnvelope = listEnvelopeSchema(SkillPath); @@ -29,8 +31,10 @@ export default defineMetabaseCommand({ description: "Print the absolute path to a skill (or all skills). Useful when an agent needs to read the SKILL.md or its references with the Read tool directly.", }, + capabilities: null, args: { ...outputFlags, + ...listFlags, name: { type: "positional", description: "Skill name (omit to list every non-hidden skill)", @@ -44,7 +48,7 @@ export default defineMetabaseCommand({ args.name === undefined ? loadVisibleSkills().map(toSkillPath) : [toSkillPath(findSkillByName(loadAllSkills(), args.name))]; - renderList(wrapList(items), skillPathView, ctx); + renderList(windowList(items, ctx.range), skillPathView, ctx); }, }); diff --git a/src/commands/snippet/archive.ts b/packages/cli/src/commands/snippet/archive.ts similarity index 79% rename from src/commands/snippet/archive.ts rename to packages/cli/src/commands/snippet/archive.ts index 7e6e7ef..436d028 100644 --- a/src/commands/snippet/archive.ts +++ b/packages/cli/src/commands/snippet/archive.ts @@ -1,4 +1,5 @@ -import { Snippet, snippetView } from "../../domain/snippet"; +import { Snippet } from "@metabase/client/domain/snippet"; +import { snippetView } from "../../output/views/snippet"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,10 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const updated = await client.requestParsed(Snippet, `/api/native-query-snippet/${id}`, { - method: "PUT", - body: { archived: true }, - }); + const updated = await client.snippet.archive(id); renderSummary(updated, snippetView, `Archived snippet ${updated.id} "${updated.name}".`, ctx); }, }); diff --git a/src/commands/snippet/create.ts b/packages/cli/src/commands/snippet/create.ts similarity index 83% rename from src/commands/snippet/create.ts rename to packages/cli/src/commands/snippet/create.ts index b717023..d7e6e99 100644 --- a/src/commands/snippet/create.ts +++ b/packages/cli/src/commands/snippet/create.ts @@ -1,4 +1,5 @@ -import { Snippet, SnippetCreateInput, snippetView } from "../../domain/snippet"; +import { Snippet, SnippetCreateInput } from "@metabase/client/domain/snippet"; +import { snippetView } from "../../output/views/snippet"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -24,10 +25,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, SnippetCreateInput); const client = await getClient(); - const created = await client.requestParsed(Snippet, "/api/native-query-snippet", { - method: "POST", - body, - }); + const created = await client.snippet.create(body); renderSummary(created, snippetView, `Created snippet ${created.id} "${created.name}".`, ctx); }, }); diff --git a/src/commands/snippet/get.ts b/packages/cli/src/commands/snippet/get.ts similarity index 82% rename from src/commands/snippet/get.ts rename to packages/cli/src/commands/snippet/get.ts index 59cd2e9..056886f 100644 --- a/src/commands/snippet/get.ts +++ b/packages/cli/src/commands/snippet/get.ts @@ -1,4 +1,5 @@ -import { Snippet, snippetView } from "../../domain/snippet"; +import { Snippet } from "@metabase/client/domain/snippet"; +import { snippetView } from "../../output/views/snippet"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,7 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const snippet = await client.requestParsed(Snippet, `/api/native-query-snippet/${id}`); + const snippet = await client.snippet.get(id); renderItem(snippet, snippetView, ctx); }, }); diff --git a/src/commands/snippet/index.ts b/packages/cli/src/commands/snippet/index.ts similarity index 100% rename from src/commands/snippet/index.ts rename to packages/cli/src/commands/snippet/index.ts diff --git a/src/commands/snippet/list.ts b/packages/cli/src/commands/snippet/list.ts similarity index 58% rename from src/commands/snippet/list.ts rename to packages/cli/src/commands/snippet/list.ts index f8604a3..c8b176c 100644 --- a/src/commands/snippet/list.ts +++ b/packages/cli/src/commands/snippet/list.ts @@ -1,13 +1,11 @@ -import { z } from "zod"; - -import { Snippet, SnippetCompact, snippetView } from "../../domain/snippet"; +import { SnippetCompact } from "@metabase/client/domain/snippet"; +import { snippetView } from "../../output/views/snippet"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -const SnippetApiList = z.array(Snippet); - export const SnippetListEnvelope = listEnvelopeSchema(SnippetCompact); export default defineMetabaseCommand({ @@ -15,6 +13,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, archived: { type: "boolean", description: "Show archived snippets instead of active ones" }, @@ -23,9 +22,7 @@ export default defineMetabaseCommand({ examples: ["mb snippet list", "mb snippet list --json", "mb snippet list --archived --json"], async run({ args, ctx, getClient }) { const client = await getClient(); - const items = await client.requestParsed(SnippetApiList, "/api/native-query-snippet", { - query: { archived: args.archived || undefined }, - }); - renderList(wrapList(items), snippetView, ctx); + const { data, total } = await client.snippet.list({ archived: args.archived || undefined }); + renderList(windowList(data, ctx.range, total), snippetView, ctx); }, }); diff --git a/src/commands/snippet/update.ts b/packages/cli/src/commands/snippet/update.ts similarity index 85% rename from src/commands/snippet/update.ts rename to packages/cli/src/commands/snippet/update.ts index 9c3a7ea..953cff7 100644 --- a/src/commands/snippet/update.ts +++ b/packages/cli/src/commands/snippet/update.ts @@ -1,4 +1,5 @@ -import { Snippet, SnippetUpdateInput, snippetView } from "../../domain/snippet"; +import { Snippet, SnippetUpdateInput } from "@metabase/client/domain/snippet"; +import { snippetView } from "../../output/views/snippet"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -28,10 +29,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, SnippetUpdateInput); const client = await getClient(); - const updated = await client.requestParsed(Snippet, `/api/native-query-snippet/${id}`, { - method: "PUT", - body, - }); + const updated = await client.snippet.update(id, body); renderSummary(updated, snippetView, `Updated snippet ${updated.id} "${updated.name}".`, ctx); }, }); diff --git a/src/commands/subscription/archive.ts b/packages/cli/src/commands/subscription/archive.ts similarity index 85% rename from src/commands/subscription/archive.ts rename to packages/cli/src/commands/subscription/archive.ts index c4b4ba0..0c096b5 100644 --- a/src/commands/subscription/archive.ts +++ b/packages/cli/src/commands/subscription/archive.ts @@ -1,11 +1,10 @@ -import { Pulse, pulseView } from "../../domain/pulse"; +import { Pulse } from "@metabase/client/domain/pulse"; +import { pulseView } from "../../output/views/pulse"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { patchSubscription } from "./patch"; - export default defineMetabaseCommand({ meta: { name: "archive", @@ -25,7 +24,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const archived = await patchSubscription(client, id, { archived: true }); + const archived = await client.pulse.archive(id); renderSummary(archived, pulseView, `Archived subscription ${archived.id}.`, ctx); }, }); diff --git a/src/commands/subscription/create.ts b/packages/cli/src/commands/subscription/create.ts similarity index 91% rename from src/commands/subscription/create.ts rename to packages/cli/src/commands/subscription/create.ts index d47691a..cacf6c0 100644 --- a/src/commands/subscription/create.ts +++ b/packages/cli/src/commands/subscription/create.ts @@ -1,4 +1,5 @@ -import { Pulse, PulseCreateInput, pulseView } from "../../domain/pulse"; +import { Pulse, PulseCreateInput } from "@metabase/client/domain/pulse"; +import { pulseView } from "../../output/views/pulse"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -31,10 +32,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, PulseCreateInput); const client = await getClient(); - const created = await client.requestParsed(Pulse, "/api/pulse", { - method: "POST", - body, - }); + const created = await client.pulse.create(body); renderSummary( created, pulseView, diff --git a/src/commands/subscription/get.ts b/packages/cli/src/commands/subscription/get.ts similarity index 82% rename from src/commands/subscription/get.ts rename to packages/cli/src/commands/subscription/get.ts index ac6535c..0deb35b 100644 --- a/src/commands/subscription/get.ts +++ b/packages/cli/src/commands/subscription/get.ts @@ -1,4 +1,5 @@ -import { Pulse, pulseView } from "../../domain/pulse"; +import { Pulse } from "@metabase/client/domain/pulse"; +import { pulseView } from "../../output/views/pulse"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,7 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const subscription = await client.requestParsed(Pulse, `/api/pulse/${id}`); + const subscription = await client.pulse.get(id); renderItem(subscription, pulseView, ctx); }, }); diff --git a/src/commands/subscription/index.ts b/packages/cli/src/commands/subscription/index.ts similarity index 100% rename from src/commands/subscription/index.ts rename to packages/cli/src/commands/subscription/index.ts diff --git a/src/commands/subscription/list.ts b/packages/cli/src/commands/subscription/list.ts similarity index 67% rename from src/commands/subscription/list.ts rename to packages/cli/src/commands/subscription/list.ts index 7950617..bfdf346 100644 --- a/src/commands/subscription/list.ts +++ b/packages/cli/src/commands/subscription/list.ts @@ -1,14 +1,12 @@ -import { z } from "zod"; - -import { Pulse, PulseCompact, pulseView } from "../../domain/pulse"; +import { PulseCompact } from "@metabase/client/domain/pulse"; +import { pulseView } from "../../output/views/pulse"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseOptionalInteger } from "../parse-integer"; import { defineMetabaseCommand } from "../runtime"; -const PulseApiList = z.array(Pulse); - export const SubscriptionListEnvelope = listEnvelopeSchema(PulseCompact); export default defineMetabaseCommand({ @@ -16,6 +14,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, dashboardId: { @@ -37,12 +36,10 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const dashboardId = parseOptionalInteger(args.dashboardId, { name: "dashboard-id", min: 1 }); const client = await getClient(); - const items = await client.requestParsed(PulseApiList, "/api/pulse", { - query: { - dashboard_id: dashboardId ?? undefined, - archived: args.archived || undefined, - }, + const { data, total } = await client.pulse.list({ + dashboard_id: dashboardId ?? undefined, + archived: args.archived || undefined, }); - renderList(wrapList(items), pulseView, ctx); + renderList(windowList(data, ctx.range, total), pulseView, ctx); }, }); diff --git a/src/commands/subscription/update.ts b/packages/cli/src/commands/subscription/update.ts similarity index 90% rename from src/commands/subscription/update.ts rename to packages/cli/src/commands/subscription/update.ts index 07f13a1..f39d6fd 100644 --- a/src/commands/subscription/update.ts +++ b/packages/cli/src/commands/subscription/update.ts @@ -1,4 +1,5 @@ -import { Pulse, PulseUpdateInput, pulseView } from "../../domain/pulse"; +import { Pulse, PulseUpdateInput } from "@metabase/client/domain/pulse"; +import { pulseView } from "../../output/views/pulse"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -6,8 +7,6 @@ import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { patchSubscription } from "./patch"; - export default defineMetabaseCommand({ meta: { name: "update", description: "Update a dashboard subscription by id" }, details: [ @@ -35,7 +34,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, PulseUpdateInput); const client = await getClient(); - const updated = await patchSubscription(client, id, body); + const updated = await client.pulse.update(id, body); renderSummary(updated, pulseView, `Updated subscription ${updated.id}.`, ctx); }, }); diff --git a/src/commands/table/fields.ts b/packages/cli/src/commands/table/fields.ts similarity index 63% rename from src/commands/table/fields.ts rename to packages/cli/src/commands/table/fields.ts index 3918d7d..d347b0f 100644 --- a/src/commands/table/fields.ts +++ b/packages/cli/src/commands/table/fields.ts @@ -1,8 +1,10 @@ -import { FieldCompact, fieldView } from "../../domain/field"; -import { TableQueryMetadata } from "../../domain/table"; +import { FieldCompact } from "@metabase/client/domain/field"; + import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { fieldView } from "../../output/views/field"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; @@ -16,6 +18,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, id: { type: "positional", description: "Table id", required: true }, @@ -25,7 +28,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const table = await client.requestParsed(TableQueryMetadata, `/api/table/${id}/query_metadata`); - renderList(wrapList(table.fields), fieldView, ctx); + const table = await client.table.queryMetadata(id); + renderList(windowList(table.fields, ctx.range), fieldView, ctx); }, }); diff --git a/src/commands/table/get.ts b/packages/cli/src/commands/table/get.ts similarity index 78% rename from src/commands/table/get.ts rename to packages/cli/src/commands/table/get.ts index edec2a8..9504f16 100644 --- a/src/commands/table/get.ts +++ b/packages/cli/src/commands/table/get.ts @@ -1,6 +1,5 @@ -import { z } from "zod"; - -import { Table, TableQueryMetadata, tableView } from "../../domain/table"; +import { Table, TableGetInclude } from "@metabase/client/domain/table"; +import { tableView } from "../../output/views/table"; import { renderItem } from "../../output/render"; import { parseEnum } from "../../runtime/csv"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; @@ -9,8 +8,6 @@ import { defineMetabaseCommand } from "../runtime"; import { tableFieldsOversizeHint } from "./hints"; -const TableGetInclude = z.enum(["fields"]); - export default defineMetabaseCommand({ meta: { name: "get", @@ -38,14 +35,11 @@ export default defineMetabaseCommand({ const include = parseEnum(args.include, TableGetInclude, "--include"); const client = await getClient(); if (include === "fields") { - const table = await client.requestParsed( - TableQueryMetadata, - `/api/table/${id}/query_metadata`, - ); + const table = await client.table.queryMetadata(id); renderItem(table, tableView, { ...ctx, oversizeHint: tableFieldsOversizeHint(id) }); return; } - const table = await client.requestParsed(Table, `/api/table/${id}`); + const table = await client.table.get(id); renderItem(table, tableView, ctx); }, }); diff --git a/src/commands/table/hints.ts b/packages/cli/src/commands/table/hints.ts similarity index 100% rename from src/commands/table/hints.ts rename to packages/cli/src/commands/table/hints.ts diff --git a/src/commands/table/index.ts b/packages/cli/src/commands/table/index.ts similarity index 100% rename from src/commands/table/index.ts rename to packages/cli/src/commands/table/index.ts diff --git a/src/commands/table/list.ts b/packages/cli/src/commands/table/list.ts similarity index 59% rename from src/commands/table/list.ts rename to packages/cli/src/commands/table/list.ts index 42847e1..05534db 100644 --- a/src/commands/table/list.ts +++ b/packages/cli/src/commands/table/list.ts @@ -1,14 +1,12 @@ -import { z } from "zod"; - -import { Table, TableCompact, tableView } from "../../domain/table"; +import { TableCompact } from "@metabase/client/domain/table"; +import { tableView } from "../../output/views/table"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -const TableApiList = z.array(Table); - export const TableListEnvelope = listEnvelopeSchema(TableCompact); export default defineMetabaseCommand({ @@ -16,6 +14,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, "db-id": { type: "string", description: "Filter by database id" }, @@ -25,8 +24,9 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const dbIdFilter = args["db-id"] === undefined ? undefined : parseId(args["db-id"], "db-id"); const client = await getClient(); - const all = await client.requestParsed(TableApiList, "/api/table"); - const filtered = dbIdFilter === undefined ? all : all.filter((row) => row.db_id === dbIdFilter); - renderList(wrapList(filtered), tableView, ctx); + const { data } = await client.table.list(); + const filtered = + dbIdFilter === undefined ? data : data.filter((row) => row.db_id === dbIdFilter); + renderList(windowList(filtered, ctx.range), tableView, ctx); }, }); diff --git a/src/commands/table/update.ts b/packages/cli/src/commands/table/update.ts similarity index 87% rename from src/commands/table/update.ts rename to packages/cli/src/commands/table/update.ts index 2182455..f6e10cf 100644 --- a/src/commands/table/update.ts +++ b/packages/cli/src/commands/table/update.ts @@ -1,4 +1,5 @@ -import { Table, TableUpdateInput, tableView } from "../../domain/table"; +import { Table, TableUpdateInput } from "@metabase/client/domain/table"; +import { tableView } from "../../output/views/table"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -30,10 +31,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, TableUpdateInput); const client = await getClient(); - const updated = await client.requestParsed(Table, `/api/table/${id}`, { - method: "PUT", - body, - }); + const updated = await client.table.update(id, body); renderSummary( updated, tableView, diff --git a/src/commands/timeline-event/archive.ts b/packages/cli/src/commands/timeline-event/archive.ts similarity index 80% rename from src/commands/timeline-event/archive.ts rename to packages/cli/src/commands/timeline-event/archive.ts index e4ac7da..8a86e30 100644 --- a/src/commands/timeline-event/archive.ts +++ b/packages/cli/src/commands/timeline-event/archive.ts @@ -1,4 +1,5 @@ -import { TimelineEvent, timelineEventView } from "../../domain/timeline"; +import { TimelineEvent } from "@metabase/client/domain/timeline"; +import { timelineEventView } from "../../output/views/timeline"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,10 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const updated = await client.requestParsed(TimelineEvent, `/api/timeline-event/${id}`, { - method: "PUT", - body: { archived: true }, - }); + const updated = await client.timelineEvent.archive(id); renderSummary( updated, timelineEventView, diff --git a/src/commands/timeline-event/create.ts b/packages/cli/src/commands/timeline-event/create.ts similarity index 86% rename from src/commands/timeline-event/create.ts rename to packages/cli/src/commands/timeline-event/create.ts index 1f8244d..63aba11 100644 --- a/src/commands/timeline-event/create.ts +++ b/packages/cli/src/commands/timeline-event/create.ts @@ -1,4 +1,5 @@ -import { TimelineEvent, TimelineEventCreateInput, timelineEventView } from "../../domain/timeline"; +import { TimelineEvent, TimelineEventCreateInput } from "@metabase/client/domain/timeline"; +import { timelineEventView } from "../../output/views/timeline"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -26,10 +27,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, TimelineEventCreateInput); const client = await getClient(); - const created = await client.requestParsed(TimelineEvent, "/api/timeline-event", { - method: "POST", - body, - }); + const created = await client.timelineEvent.create(body); renderSummary( created, timelineEventView, diff --git a/src/commands/timeline-event/delete.ts b/packages/cli/src/commands/timeline-event/delete.ts similarity index 94% rename from src/commands/timeline-event/delete.ts rename to packages/cli/src/commands/timeline-event/delete.ts index f8c6b53..db239e8 100644 --- a/src/commands/timeline-event/delete.ts +++ b/packages/cli/src/commands/timeline-event/delete.ts @@ -20,12 +20,11 @@ export default defineMetabaseCommand({ const client = await getClient(); await confirmAndDelete({ id, - path: `/api/timeline-event/${id}`, yes: args.yes, promptMessage: `Delete timeline event ${id}?`, successMessage: `Deleted timeline event ${id}.`, abortMessage: `Aborted; timeline event ${id} was not deleted.`, - client, + deleteResource: () => client.timelineEvent.delete(id), ctx, }); }, diff --git a/src/commands/timeline-event/get.ts b/packages/cli/src/commands/timeline-event/get.ts similarity index 81% rename from src/commands/timeline-event/get.ts rename to packages/cli/src/commands/timeline-event/get.ts index e8c7136..312641a 100644 --- a/src/commands/timeline-event/get.ts +++ b/packages/cli/src/commands/timeline-event/get.ts @@ -1,4 +1,5 @@ -import { TimelineEvent, timelineEventView } from "../../domain/timeline"; +import { TimelineEvent } from "@metabase/client/domain/timeline"; +import { timelineEventView } from "../../output/views/timeline"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,7 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const event = await client.requestParsed(TimelineEvent, `/api/timeline-event/${id}`); + const event = await client.timelineEvent.get(id); renderItem(event, timelineEventView, ctx); }, }); diff --git a/src/commands/timeline-event/index.ts b/packages/cli/src/commands/timeline-event/index.ts similarity index 100% rename from src/commands/timeline-event/index.ts rename to packages/cli/src/commands/timeline-event/index.ts diff --git a/src/commands/timeline-event/update.ts b/packages/cli/src/commands/timeline-event/update.ts similarity index 86% rename from src/commands/timeline-event/update.ts rename to packages/cli/src/commands/timeline-event/update.ts index 32bee6a..f80a62c 100644 --- a/src/commands/timeline-event/update.ts +++ b/packages/cli/src/commands/timeline-event/update.ts @@ -1,4 +1,5 @@ -import { TimelineEvent, TimelineEventUpdateInput, timelineEventView } from "../../domain/timeline"; +import { TimelineEvent, TimelineEventUpdateInput } from "@metabase/client/domain/timeline"; +import { timelineEventView } from "../../output/views/timeline"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -29,10 +30,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, TimelineEventUpdateInput); const client = await getClient(); - const updated = await client.requestParsed(TimelineEvent, `/api/timeline-event/${id}`, { - method: "PUT", - body, - }); + const updated = await client.timelineEvent.update(id, body); renderSummary( updated, timelineEventView, diff --git a/src/commands/timeline/archive.ts b/packages/cli/src/commands/timeline/archive.ts similarity index 80% rename from src/commands/timeline/archive.ts rename to packages/cli/src/commands/timeline/archive.ts index 18be5d7..aa32f6a 100644 --- a/src/commands/timeline/archive.ts +++ b/packages/cli/src/commands/timeline/archive.ts @@ -1,4 +1,5 @@ -import { Timeline, timelineView } from "../../domain/timeline"; +import { Timeline } from "@metabase/client/domain/timeline"; +import { timelineView } from "../../output/views/timeline"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -21,10 +22,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const updated = await client.requestParsed(Timeline, `/api/timeline/${id}`, { - method: "PUT", - body: { archived: true }, - }); + const updated = await client.timeline.archive(id); renderSummary(updated, timelineView, `Archived timeline ${updated.id} "${updated.name}".`, ctx); }, }); diff --git a/src/commands/timeline/create.ts b/packages/cli/src/commands/timeline/create.ts similarity index 85% rename from src/commands/timeline/create.ts rename to packages/cli/src/commands/timeline/create.ts index 1569504..b63e570 100644 --- a/src/commands/timeline/create.ts +++ b/packages/cli/src/commands/timeline/create.ts @@ -1,4 +1,5 @@ -import { Timeline, TimelineCreateInput, timelineView } from "../../domain/timeline"; +import { Timeline, TimelineCreateInput } from "@metabase/client/domain/timeline"; +import { timelineView } from "../../output/views/timeline"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -26,10 +27,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, TimelineCreateInput); const client = await getClient(); - const created = await client.requestParsed(Timeline, "/api/timeline", { - method: "POST", - body, - }); + const created = await client.timeline.create(body); renderSummary(created, timelineView, `Created timeline ${created.id} "${created.name}".`, ctx); }, }); diff --git a/src/commands/timeline/delete.ts b/packages/cli/src/commands/timeline/delete.ts similarity index 95% rename from src/commands/timeline/delete.ts rename to packages/cli/src/commands/timeline/delete.ts index 98504b3..51fe9c9 100644 --- a/src/commands/timeline/delete.ts +++ b/packages/cli/src/commands/timeline/delete.ts @@ -23,12 +23,11 @@ export default defineMetabaseCommand({ const client = await getClient(); await confirmAndDelete({ id, - path: `/api/timeline/${id}`, yes: args.yes, promptMessage: `Delete timeline ${id} and all its events?`, successMessage: `Deleted timeline ${id}.`, abortMessage: `Aborted; timeline ${id} was not deleted.`, - client, + deleteResource: () => client.timeline.delete(id), ctx, }); }, diff --git a/src/commands/timeline/events.ts b/packages/cli/src/commands/timeline/events.ts similarity index 61% rename from src/commands/timeline/events.ts rename to packages/cli/src/commands/timeline/events.ts index e06f573..8c0c847 100644 --- a/src/commands/timeline/events.ts +++ b/packages/cli/src/commands/timeline/events.ts @@ -1,12 +1,12 @@ -import { Timeline, TimelineEventCompact, timelineEventView } from "../../domain/timeline"; +import { TimelineEventCompact } from "@metabase/client/domain/timeline"; +import { timelineEventView } from "../../output/views/timeline"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -const TimelineWithEvents = Timeline.required({ events: true }); - export const TimelineEventListEnvelope = listEnvelopeSchema(TimelineEventCompact); export default defineMetabaseCommand({ @@ -14,6 +14,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, archived: { type: "boolean", description: "Include archived events" }, @@ -24,9 +25,9 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const timeline = await client.requestParsed(TimelineWithEvents, `/api/timeline/${id}`, { - query: { include: "events", archived: args.archived || undefined }, + const { data, total } = await client.timeline.events(id, { + archived: args.archived || undefined, }); - renderList(wrapList(timeline.events), timelineEventView, ctx); + renderList(windowList(data, ctx.range, total), timelineEventView, ctx); }, }); diff --git a/src/commands/timeline/get.ts b/packages/cli/src/commands/timeline/get.ts similarity index 81% rename from src/commands/timeline/get.ts rename to packages/cli/src/commands/timeline/get.ts index 87f9375..0911aa6 100644 --- a/src/commands/timeline/get.ts +++ b/packages/cli/src/commands/timeline/get.ts @@ -1,4 +1,5 @@ -import { Timeline, timelineView } from "../../domain/timeline"; +import { Timeline } from "@metabase/client/domain/timeline"; +import { timelineView } from "../../output/views/timeline"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,7 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const timeline = await client.requestParsed(Timeline, `/api/timeline/${id}`); + const timeline = await client.timeline.get(id); renderItem(timeline, timelineView, ctx); }, }); diff --git a/src/commands/timeline/index.ts b/packages/cli/src/commands/timeline/index.ts similarity index 100% rename from src/commands/timeline/index.ts rename to packages/cli/src/commands/timeline/index.ts diff --git a/src/commands/timeline/list.ts b/packages/cli/src/commands/timeline/list.ts similarity index 58% rename from src/commands/timeline/list.ts rename to packages/cli/src/commands/timeline/list.ts index b9b191c..531a1d0 100644 --- a/src/commands/timeline/list.ts +++ b/packages/cli/src/commands/timeline/list.ts @@ -1,13 +1,11 @@ -import { z } from "zod"; - -import { Timeline, TimelineCompact, timelineView } from "../../domain/timeline"; +import { TimelineCompact } from "@metabase/client/domain/timeline"; +import { timelineView } from "../../output/views/timeline"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -const TimelineApiList = z.array(Timeline); - export const TimelineListEnvelope = listEnvelopeSchema(TimelineCompact); export default defineMetabaseCommand({ @@ -15,6 +13,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 58 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, archived: { type: "boolean", description: "Show archived timelines instead of active ones" }, @@ -23,9 +22,7 @@ export default defineMetabaseCommand({ examples: ["mb timeline list", "mb timeline list --json", "mb timeline list --archived --json"], async run({ args, ctx, getClient }) { const client = await getClient(); - const items = await client.requestParsed(TimelineApiList, "/api/timeline", { - query: { archived: args.archived || undefined }, - }); - renderList(wrapList(items), timelineView, ctx); + const { data, total } = await client.timeline.list({ archived: args.archived || undefined }); + renderList(windowList(data, ctx.range, total), timelineView, ctx); }, }); diff --git a/src/commands/timeline/update.ts b/packages/cli/src/commands/timeline/update.ts similarity index 86% rename from src/commands/timeline/update.ts rename to packages/cli/src/commands/timeline/update.ts index bcc2106..d2309b5 100644 --- a/src/commands/timeline/update.ts +++ b/packages/cli/src/commands/timeline/update.ts @@ -1,4 +1,5 @@ -import { Timeline, TimelineUpdateInput, timelineView } from "../../domain/timeline"; +import { Timeline, TimelineUpdateInput } from "@metabase/client/domain/timeline"; +import { timelineView } from "../../output/views/timeline"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -29,10 +30,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, TimelineUpdateInput); const client = await getClient(); - const updated = await client.requestParsed(Timeline, `/api/timeline/${id}`, { - method: "PUT", - body, - }); + const updated = await client.timeline.update(id, body); renderSummary(updated, timelineView, `Updated timeline ${updated.id} "${updated.name}".`, ctx); }, }); diff --git a/src/commands/transform-job/create.ts b/packages/cli/src/commands/transform-job/create.ts similarity index 82% rename from src/commands/transform-job/create.ts rename to packages/cli/src/commands/transform-job/create.ts index 4d03120..102edb0 100644 --- a/src/commands/transform-job/create.ts +++ b/packages/cli/src/commands/transform-job/create.ts @@ -1,8 +1,5 @@ -import { - TransformJob, - TransformJobCreateInput, - transformJobView, -} from "../../domain/transform-job"; +import { TransformJob, TransformJobCreateInput } from "@metabase/client/domain/transform-job"; +import { transformJobView } from "../../output/views/transform-job"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -23,10 +20,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, TransformJobCreateInput); const client = await getClient(); - const created = await client.requestParsed(TransformJob, "/api/transform-job", { - method: "POST", - body, - }); + const created = await client.transformJob.create(body); renderSummary( created, transformJobView, diff --git a/src/commands/transform-job/delete.ts b/packages/cli/src/commands/transform-job/delete.ts similarity index 94% rename from src/commands/transform-job/delete.ts rename to packages/cli/src/commands/transform-job/delete.ts index bbd6872..3307556 100644 --- a/src/commands/transform-job/delete.ts +++ b/packages/cli/src/commands/transform-job/delete.ts @@ -20,12 +20,11 @@ export default defineMetabaseCommand({ const client = await getClient(); await confirmAndDelete({ id, - path: `/api/transform-job/${id}`, yes: args.yes, promptMessage: `Delete transform job ${id}?`, successMessage: `Deleted transform job ${id}.`, abortMessage: `Aborted; transform job ${id} was not deleted.`, - client, + deleteResource: () => client.transformJob.delete(id), ctx, }); }, diff --git a/src/commands/transform-job/get.ts b/packages/cli/src/commands/transform-job/get.ts similarity index 80% rename from src/commands/transform-job/get.ts rename to packages/cli/src/commands/transform-job/get.ts index 72f7708..5eb360a 100644 --- a/src/commands/transform-job/get.ts +++ b/packages/cli/src/commands/transform-job/get.ts @@ -1,4 +1,5 @@ -import { TransformJob, transformJobView } from "../../domain/transform-job"; +import { TransformJob } from "@metabase/client/domain/transform-job"; +import { transformJobView } from "../../output/views/transform-job"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,7 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const job = await client.requestParsed(TransformJob, `/api/transform-job/${id}`); + const job = await client.transformJob.get(id); renderItem(job, transformJobView, ctx); }, }); diff --git a/src/commands/transform-job/index.ts b/packages/cli/src/commands/transform-job/index.ts similarity index 100% rename from src/commands/transform-job/index.ts rename to packages/cli/src/commands/transform-job/index.ts diff --git a/packages/cli/src/commands/transform-job/list.ts b/packages/cli/src/commands/transform-job/list.ts new file mode 100644 index 0000000..3ec4ba3 --- /dev/null +++ b/packages/cli/src/commands/transform-job/list.ts @@ -0,0 +1,22 @@ +import { TransformJobCompact } from "@metabase/client/domain/transform-job"; +import { transformJobView } from "../../output/views/transform-job"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const TransformJobListEnvelope = listEnvelopeSchema(TransformJobCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List transform jobs" }, + capabilities: { minVersion: 59 }, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, + outputSchema: TransformJobListEnvelope, + examples: ["mb transform-job list", "mb transform-job list --json"], + async run({ ctx, getClient }) { + const client = await getClient(); + const { data } = await client.transformJob.list(); + renderList(windowList(data, ctx.range), transformJobView, ctx); + }, +}); diff --git a/src/commands/transform-job/run.ts b/packages/cli/src/commands/transform-job/run.ts similarity index 67% rename from src/commands/transform-job/run.ts rename to packages/cli/src/commands/transform-job/run.ts index 36050fd..c420bd1 100644 --- a/src/commands/transform-job/run.ts +++ b/packages/cli/src/commands/transform-job/run.ts @@ -1,16 +1,14 @@ -import { z } from "zod"; +import { TransformJobRunResult } from "@metabase/client/domain/transform-job"; -import type { ResourceView } from "../../domain/view"; +import type { ResourceView } from "../../output/view"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -export const TransformJobRunResult = z.object({ - message: z.string(), - job_run_id: z.string(), -}); -export type TransformJobRunResultJson = z.infer; +// The e2e suite drives this command and parses its output against the result schema. +export { TransformJobRunResult }; +export type TransformJobRunResultJson = TransformJobRunResult; const transformJobRunResultView: ResourceView = { compactPick: TransformJobRunResult, @@ -41,11 +39,11 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const result = await client.requestParsed( - TransformJobRunResult, - `/api/transform-job/${id}/run`, - { method: "POST", body: { run_all: args["force-refresh"] === true } }, - ); - renderSummary(result, transformJobRunResultView, `Started transform job ${id}.`, ctx); + const result = await client.transformJob.run(id, { run_all: args["force-refresh"] === true }); + const summary = + result.job_run_id === null + ? `Transform job ${id} was not started (already running, or it resolves to no transforms).` + : `Started transform job ${id}.`; + renderSummary(result, transformJobRunResultView, summary, ctx); }, }); diff --git a/src/commands/transform-job/set-active.ts b/packages/cli/src/commands/transform-job/set-active.ts similarity index 77% rename from src/commands/transform-job/set-active.ts rename to packages/cli/src/commands/transform-job/set-active.ts index c8d4690..838264b 100644 --- a/src/commands/transform-job/set-active.ts +++ b/packages/cli/src/commands/transform-job/set-active.ts @@ -1,6 +1,8 @@ import { z } from "zod"; -import type { ResourceView } from "../../domain/view"; +import { TransformJobActiveResult } from "@metabase/client/domain/transform-job"; + +import type { ResourceView } from "../../output/view"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseEnumFlag } from "../parse-enum"; @@ -8,13 +10,10 @@ import { defineMetabaseCommand } from "../runtime"; const ActiveWord = z.enum(["true", "false"]); -export const TransformJobActiveResult = z.object({ - updated: z.number().int(), - failed: z.number().int(), -}); -export type TransformJobActiveResultJson = z.infer; +// The e2e suite drives this command and parses its output against the result schema. +export { TransformJobActiveResult }; -const transformJobActiveView: ResourceView = { +const transformJobActiveView: ResourceView = { compactPick: TransformJobActiveResult, tableColumns: [ { key: "updated", label: "Updated" }, @@ -42,14 +41,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const active = parseEnumFlag(args.active, ActiveWord, "active") === "true"; const client = await getClient(); - const result = await client.requestParsed( - TransformJobActiveResult, - "/api/transform-job/active", - { - method: "PUT", - body: { active }, - }, - ); + const result = await client.transformJob.setActive(active); const label = active ? "Activated" : "Deactivated"; renderSummary( result, diff --git a/src/commands/transform-job/transforms.ts b/packages/cli/src/commands/transform-job/transforms.ts similarity index 66% rename from src/commands/transform-job/transforms.ts rename to packages/cli/src/commands/transform-job/transforms.ts index 6780629..605a04b 100644 --- a/src/commands/transform-job/transforms.ts +++ b/packages/cli/src/commands/transform-job/transforms.ts @@ -1,14 +1,12 @@ -import { z } from "zod"; - -import { Transform, TransformCompact, transformView } from "../../domain/transform"; +import { TransformCompact } from "@metabase/client/domain/transform"; +import { transformView } from "../../output/views/transform"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -const TransformApiList = z.array(Transform); - export const TransformJobTransformsEnvelope = listEnvelopeSchema(TransformCompact); export default defineMetabaseCommand({ @@ -18,6 +16,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 59 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, id: { type: "positional", description: "Transform job id", required: true }, @@ -27,10 +26,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const items = await client.requestParsed( - TransformApiList, - `/api/transform-job/${id}/transforms`, - ); - renderList(wrapList(items), transformView, ctx); + const { data } = await client.transformJob.transforms(id); + renderList(windowList(data, ctx.range), transformView, ctx); }, }); diff --git a/src/commands/transform-job/update.ts b/packages/cli/src/commands/transform-job/update.ts similarity index 83% rename from src/commands/transform-job/update.ts rename to packages/cli/src/commands/transform-job/update.ts index ac87a9a..f3d38bb 100644 --- a/src/commands/transform-job/update.ts +++ b/packages/cli/src/commands/transform-job/update.ts @@ -1,8 +1,5 @@ -import { - TransformJob, - TransformJobUpdateInput, - transformJobView, -} from "../../domain/transform-job"; +import { TransformJob, TransformJobUpdateInput } from "@metabase/client/domain/transform-job"; +import { transformJobView } from "../../output/views/transform-job"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -31,10 +28,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, TransformJobUpdateInput); const client = await getClient(); - const updated = await client.requestParsed(TransformJob, `/api/transform-job/${id}`, { - method: "PUT", - body, - }); + const updated = await client.transformJob.update(id, body); renderSummary( updated, transformJobView, diff --git a/src/commands/transform-tag/create.ts b/packages/cli/src/commands/transform-tag/create.ts similarity index 82% rename from src/commands/transform-tag/create.ts rename to packages/cli/src/commands/transform-tag/create.ts index a13750b..3fa474c 100644 --- a/src/commands/transform-tag/create.ts +++ b/packages/cli/src/commands/transform-tag/create.ts @@ -1,8 +1,5 @@ -import { - TransformTag, - TransformTagCreateInput, - transformTagView, -} from "../../domain/transform-tag"; +import { TransformTag, TransformTagCreateInput } from "@metabase/client/domain/transform-tag"; +import { transformTagView } from "../../output/views/transform-tag"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -23,10 +20,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, TransformTagCreateInput); const client = await getClient(); - const created = await client.requestParsed(TransformTag, "/api/transform-tag", { - method: "POST", - body, - }); + const created = await client.transformTag.create(body); renderSummary( created, transformTagView, diff --git a/src/commands/transform-tag/delete.ts b/packages/cli/src/commands/transform-tag/delete.ts similarity index 94% rename from src/commands/transform-tag/delete.ts rename to packages/cli/src/commands/transform-tag/delete.ts index 862e15e..25e7b6e 100644 --- a/src/commands/transform-tag/delete.ts +++ b/packages/cli/src/commands/transform-tag/delete.ts @@ -20,12 +20,11 @@ export default defineMetabaseCommand({ const client = await getClient(); await confirmAndDelete({ id, - path: `/api/transform-tag/${id}`, yes: args.yes, promptMessage: `Delete transform tag ${id}?`, successMessage: `Deleted transform tag ${id}.`, abortMessage: `Aborted; transform tag ${id} was not deleted.`, - client, + deleteResource: () => client.transformTag.delete(id), ctx, }); }, diff --git a/src/commands/transform-tag/index.ts b/packages/cli/src/commands/transform-tag/index.ts similarity index 100% rename from src/commands/transform-tag/index.ts rename to packages/cli/src/commands/transform-tag/index.ts diff --git a/packages/cli/src/commands/transform-tag/list.ts b/packages/cli/src/commands/transform-tag/list.ts new file mode 100644 index 0000000..0fc42bb --- /dev/null +++ b/packages/cli/src/commands/transform-tag/list.ts @@ -0,0 +1,22 @@ +import { TransformTagCompact } from "@metabase/client/domain/transform-tag"; +import { transformTagView } from "../../output/views/transform-tag"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const TransformTagListEnvelope = listEnvelopeSchema(TransformTagCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List transform tags" }, + capabilities: { minVersion: 59 }, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, + outputSchema: TransformTagListEnvelope, + examples: ["mb transform-tag list", "mb transform-tag list --json"], + async run({ ctx, getClient }) { + const client = await getClient(); + const { data } = await client.transformTag.list(); + renderList(windowList(data, ctx.range), transformTagView, ctx); + }, +}); diff --git a/src/commands/transform-tag/update.ts b/packages/cli/src/commands/transform-tag/update.ts similarity index 83% rename from src/commands/transform-tag/update.ts rename to packages/cli/src/commands/transform-tag/update.ts index 4336832..3804999 100644 --- a/src/commands/transform-tag/update.ts +++ b/packages/cli/src/commands/transform-tag/update.ts @@ -1,8 +1,5 @@ -import { - TransformTag, - TransformTagUpdateInput, - transformTagView, -} from "../../domain/transform-tag"; +import { TransformTag, TransformTagUpdateInput } from "@metabase/client/domain/transform-tag"; +import { transformTagView } from "../../output/views/transform-tag"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -31,10 +28,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, TransformTagUpdateInput); const client = await getClient(); - const updated = await client.requestParsed(TransformTag, `/api/transform-tag/${id}`, { - method: "PUT", - body, - }); + const updated = await client.transformTag.update(id, body); renderSummary( updated, transformTagView, diff --git a/src/commands/transform/cancel.ts b/packages/cli/src/commands/transform/cancel.ts similarity index 82% rename from src/commands/transform/cancel.ts rename to packages/cli/src/commands/transform/cancel.ts index eaedd57..62605af 100644 --- a/src/commands/transform/cancel.ts +++ b/packages/cli/src/commands/transform/cancel.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { ResourceView } from "../../domain/view"; +import type { ResourceView } from "../../output/view"; import { renderSummary } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -10,7 +10,7 @@ export const TransformCancelResult = z.object({ canceled: z.boolean(), id: z.number().int(), }); -export type TransformCancelResultJson = z.infer; +type TransformCancelResultJson = z.infer; const transformCancelView: ResourceView = { compactPick: TransformCancelResult, @@ -34,10 +34,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - await client.requestRaw(`/api/transform/${id}/cancel`, { - method: "POST", - expectContentType: "binary", - }); + await client.transform.cancel(id); renderSummary( { canceled: true, id }, transformCancelView, diff --git a/src/commands/transform/collection-namespace.test.ts b/packages/cli/src/commands/transform/collection-namespace.test.ts similarity index 92% rename from src/commands/transform/collection-namespace.test.ts rename to packages/cli/src/commands/transform/collection-namespace.test.ts index 1f49baa..482ed22 100644 --- a/src/commands/transform/collection-namespace.test.ts +++ b/packages/cli/src/commands/transform/collection-namespace.test.ts @@ -1,7 +1,7 @@ import { assert, describe, expect, it } from "vitest"; -import { ConfigError } from "../../core/errors"; -import { HttpError } from "../../core/http/errors"; +import { ConfigError } from "@metabase/client/errors"; +import { HttpError } from "@metabase/client/http/errors"; import { enrichTransformCollectionError } from "./collection-namespace"; diff --git a/src/commands/transform/collection-namespace.ts b/packages/cli/src/commands/transform/collection-namespace.ts similarity index 89% rename from src/commands/transform/collection-namespace.ts rename to packages/cli/src/commands/transform/collection-namespace.ts index db1bb03..0d90305 100644 --- a/src/commands/transform/collection-namespace.ts +++ b/packages/cli/src/commands/transform/collection-namespace.ts @@ -1,5 +1,5 @@ -import { ConfigError } from "../../core/errors"; -import { HttpError } from "../../core/http/errors"; +import { ConfigError } from "@metabase/client/errors"; +import { HttpError } from "@metabase/client/http/errors"; const TRANSFORMS_NAMESPACE_HINT = 'transforms can only be filed in a :transforms-namespace collection. Create one with `mb collection create --body \'{"name":"…"}\' --namespace transforms` and pass its id as collection_id, or omit collection_id to leave the transform uncollected.'; diff --git a/src/commands/transform/create.ts b/packages/cli/src/commands/transform/create.ts similarity index 86% rename from src/commands/transform/create.ts rename to packages/cli/src/commands/transform/create.ts index c4cb2fa..ee6df65 100644 --- a/src/commands/transform/create.ts +++ b/packages/cli/src/commands/transform/create.ts @@ -1,4 +1,5 @@ -import { Transform, TransformCreateInput, transformView } from "../../domain/transform"; +import { Transform, TransformCreateInput } from "@metabase/client/domain/transform"; +import { transformView } from "../../output/views/transform"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -43,11 +44,9 @@ export default defineMetabaseCommand({ }); } const client = await getClient(); - const created = await client - .requestParsed(Transform, "/api/transform", { method: "POST", body }) - .catch((error: unknown) => { - throw enrichTransformCollectionError(error); - }); + const created = await client.transform.create(body).catch((error: unknown) => { + throw enrichTransformCollectionError(error); + }); renderSummary( created, transformView, diff --git a/src/commands/transform/delete-table.ts b/packages/cli/src/commands/transform/delete-table.ts similarity index 95% rename from src/commands/transform/delete-table.ts rename to packages/cli/src/commands/transform/delete-table.ts index d56059e..eae221d 100644 --- a/src/commands/transform/delete-table.ts +++ b/packages/cli/src/commands/transform/delete-table.ts @@ -23,12 +23,11 @@ export default defineMetabaseCommand({ const client = await getClient(); await confirmAndDelete({ id, - path: `/api/transform/${id}/table`, yes: args.yes, promptMessage: `Drop transform ${id}'s output table?`, successMessage: `Dropped transform ${id}'s output table.`, abortMessage: `Aborted; transform ${id}'s output table was not dropped.`, - client, + deleteResource: () => client.transform.deleteTable(id), ctx, }); }, diff --git a/src/commands/transform/delete.ts b/packages/cli/src/commands/transform/delete.ts similarity index 94% rename from src/commands/transform/delete.ts rename to packages/cli/src/commands/transform/delete.ts index 7630e4d..ff033a8 100644 --- a/src/commands/transform/delete.ts +++ b/packages/cli/src/commands/transform/delete.ts @@ -20,12 +20,11 @@ export default defineMetabaseCommand({ const client = await getClient(); await confirmAndDelete({ id, - path: `/api/transform/${id}`, yes: args.yes, promptMessage: `Delete transform ${id}?`, successMessage: `Deleted transform ${id}.`, abortMessage: `Aborted; transform ${id} was not deleted.`, - client, + deleteResource: () => client.transform.delete(id), ctx, }); }, diff --git a/src/commands/transform/dependencies.ts b/packages/cli/src/commands/transform/dependencies.ts similarity index 67% rename from src/commands/transform/dependencies.ts rename to packages/cli/src/commands/transform/dependencies.ts index 8f20f11..4c7bf0a 100644 --- a/src/commands/transform/dependencies.ts +++ b/packages/cli/src/commands/transform/dependencies.ts @@ -1,14 +1,12 @@ -import { z } from "zod"; - -import { Transform, TransformCompact, transformView } from "../../domain/transform"; +import { TransformCompact } from "@metabase/client/domain/transform"; +import { transformView } from "../../output/views/transform"; import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -const TransformApiList = z.array(Transform); - export const TransformDependenciesEnvelope = listEnvelopeSchema(TransformCompact); export default defineMetabaseCommand({ @@ -18,6 +16,7 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 59 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, id: { type: "positional", description: "Transform id", required: true }, @@ -27,7 +26,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const items = await client.requestParsed(TransformApiList, `/api/transform/${id}/dependencies`); - renderList(wrapList(items), transformView, ctx); + const { data } = await client.transform.dependencies(id); + renderList(windowList(data, ctx.range), transformView, ctx); }, }); diff --git a/src/commands/transform/get-run.ts b/packages/cli/src/commands/transform/get-run.ts similarity index 84% rename from src/commands/transform/get-run.ts rename to packages/cli/src/commands/transform/get-run.ts index c57310c..376256d 100644 --- a/src/commands/transform/get-run.ts +++ b/packages/cli/src/commands/transform/get-run.ts @@ -1,4 +1,5 @@ -import { TransformRun, transformRunView } from "../../domain/transform"; +import { TransformRun } from "@metabase/client/domain/transform"; +import { transformRunView } from "../../output/views/transform"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -20,7 +21,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id, "run id"); const client = await getClient(); - const run = await client.requestParsed(TransformRun, `/api/transform/run/${id}`); + const run = await client.transform.getRun(id); renderItem(run, transformRunView, ctx); }, }); diff --git a/src/commands/transform/get.ts b/packages/cli/src/commands/transform/get.ts similarity index 81% rename from src/commands/transform/get.ts rename to packages/cli/src/commands/transform/get.ts index 41b4713..8585e84 100644 --- a/src/commands/transform/get.ts +++ b/packages/cli/src/commands/transform/get.ts @@ -1,4 +1,5 @@ -import { Transform, transformView } from "../../domain/transform"; +import { Transform } from "@metabase/client/domain/transform"; +import { transformView } from "../../output/views/transform"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; @@ -18,7 +19,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const transform = await client.requestParsed(Transform, `/api/transform/${id}`); + const transform = await client.transform.get(id); renderItem(transform, transformView, ctx); }, }); diff --git a/src/commands/transform/index.ts b/packages/cli/src/commands/transform/index.ts similarity index 100% rename from src/commands/transform/index.ts rename to packages/cli/src/commands/transform/index.ts diff --git a/packages/cli/src/commands/transform/list.ts b/packages/cli/src/commands/transform/list.ts new file mode 100644 index 0000000..c99c647 --- /dev/null +++ b/packages/cli/src/commands/transform/list.ts @@ -0,0 +1,22 @@ +import { TransformCompact } from "@metabase/client/domain/transform"; +import { transformView } from "../../output/views/transform"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema } from "../../output/types"; +import { windowList } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const TransformListEnvelope = listEnvelopeSchema(TransformCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List transforms" }, + capabilities: { minVersion: 59 }, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, + outputSchema: TransformListEnvelope, + examples: ["mb transform list", "mb transform list --json"], + async run({ ctx, getClient }) { + const client = await getClient(); + const { data } = await client.transform.list(); + renderList(windowList(data, ctx.range), transformView, ctx); + }, +}); diff --git a/packages/cli/src/commands/transform/run.ts b/packages/cli/src/commands/transform/run.ts new file mode 100644 index 0000000..a2e7a61 --- /dev/null +++ b/packages/cli/src/commands/transform/run.ts @@ -0,0 +1,97 @@ +import { isTransformRunFailed, TransformRunResult } from "@metabase/client/domain/transform"; + +import { renderSummary } from "../../output/render"; +import type { ResourceView } from "../../output/view"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; +import { parseWaitFlags, waitFlags } from "../wait-flags"; + +const transformRunResultView: ResourceView = { + compactPick: TransformRunResult, + tableColumns: [ + { key: "run_id", label: "Run ID" }, + { key: "message", label: "Message" }, + { key: "target_table_id", label: "Target table" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { name: "run", description: "Trigger a transform run by id" }, + details: + "Starts a run and returns immediately. --wait polls the run to a terminal status. --sync additionally waits until the run's output table is registered and returns its `target_table_id`, so you can build MBQL cards against it — the run registers the table itself, so no separate `db sync-schema` is needed; --sync implies waiting for the run.", + capabilities: { minVersion: 59 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...waitFlags, + sync: { + type: "boolean", + description: + "After a successful run, wait until the output table is registered and return its id (implies --wait)", + default: false, + }, + id: { type: "positional", description: "Transform id", required: true }, + }, + outputSchema: TransformRunResult, + examples: [ + "mb transform run 1", + "mb transform run 1 --wait --json", + "mb transform run 1 --sync --json", + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const syncTarget = args.sync === true; + const wait = parseWaitFlags(args); + const waitForRun = wait.enabled || syncTarget; + const client = await getClient(); + const result = await client.transform.run(id, { + ...(waitForRun && { wait: wait.schedule }), + syncTarget, + }); + + if (!waitForRun) { + const started = + result.run_id === null + ? result.message + : `Started run ${result.run_id} for transform ${id}.`; + renderSummary(result, transformRunResultView, started, ctx); + return; + } + + // Waiting was asked for, so the absence of a run to report on means none was started. + if (result.run_id === null || result.final === null) { + renderSummary(result, transformRunResultView, result.message, ctx); + throw new Error(`transform run did not start: ${result.message}`); + } + + const status = result.final.status; + renderSummary( + result, + transformRunResultView, + summaryLine(id, result.run_id, status, syncTarget, result.target_table_id), + ctx, + ); + + if (isTransformRunFailed(status)) { + throw new Error(`transform run ${result.run_id} ${status}`); + } + }, +}); + +function summaryLine( + transformId: number, + runId: number, + status: string, + syncTarget: boolean, + targetTableId: number | null | undefined, +): string { + const base = `Run ${runId} of transform ${transformId} ${status}.`; + if (!syncTarget) { + return base; + } + return targetTableId === null || targetTableId === undefined + ? `${base} Output table not registered before the wait timeout (it may still be syncing).` + : `${base} Output table ${targetTableId} registered.`; +} diff --git a/src/commands/transform/runs.ts b/packages/cli/src/commands/transform/runs.ts similarity index 54% rename from src/commands/transform/runs.ts rename to packages/cli/src/commands/transform/runs.ts index 1a146d3..bd40963 100644 --- a/src/commands/transform/runs.ts +++ b/packages/cli/src/commands/transform/runs.ts @@ -1,8 +1,10 @@ -import { TransformRun, TransformRunCompact, transformRunView } from "../../domain/transform"; +import { TransformRunCompact } from "@metabase/client/domain/transform"; + import { renderList } from "../../output/render"; -import { listEnvelopeSchema, type ListEnvelope } from "../../output/types"; -import { collectPaginated } from "../../runtime/paginate"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { listEnvelopeSchema } from "../../output/types"; +import { transformRunView } from "../../output/views/transform"; +import { collectForOutput } from "../../output/window"; +import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; @@ -13,16 +15,13 @@ export default defineMetabaseCommand({ capabilities: { minVersion: 59 }, args: { ...outputFlags, + ...listFlags, ...profileFlag, ...connectionFlags, "transform-id": { type: "string", description: "Filter to runs of a single transform id", }, - limit: { - type: "string", - description: "Cap total runs returned (default: drain all pages)", - }, }, outputSchema: TransformRunListEnvelope, examples: [ @@ -35,19 +34,13 @@ export default defineMetabaseCommand({ args["transform-id"] === undefined ? undefined : parseId(args["transform-id"], "--transform-id"); - const max = args.limit === undefined ? undefined : parseId(args.limit, "--limit"); const client = await getClient(); - const items = await collectPaginated(client, "/api/transform/run", TransformRun, { - query: { "transform-ids": transformId }, - ...(max !== undefined && { max }), - }); - - const envelope: ListEnvelope = { - data: items, - returned: items.length, - ...(max === undefined ? { total: items.length } : { limit: max }), - }; + const envelope = await collectForOutput( + (request) => client.transform.runPages({ "transform-ids": transformId }, request), + transformRunView, + ctx, + ); renderList(envelope, transformRunView, ctx); }, }); diff --git a/src/commands/transform/update.ts b/packages/cli/src/commands/transform/update.ts similarity index 87% rename from src/commands/transform/update.ts rename to packages/cli/src/commands/transform/update.ts index 1949c63..74e4c9b 100644 --- a/src/commands/transform/update.ts +++ b/packages/cli/src/commands/transform/update.ts @@ -1,4 +1,5 @@ -import { Transform, TransformUpdateInput, transformView } from "../../domain/transform"; +import { Transform, TransformUpdateInput } from "@metabase/client/domain/transform"; +import { transformView } from "../../output/views/transform"; import { renderSummary } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -47,11 +48,9 @@ export default defineMetabaseCommand({ }); } const client = await getClient(); - const updated = await client - .requestParsed(Transform, `/api/transform/${id}`, { method: "PUT", body }) - .catch((error: unknown) => { - throw enrichTransformCollectionError(error); - }); + const updated = await client.transform.update(id, body).catch((error: unknown) => { + throw enrichTransformCollectionError(error); + }); renderSummary( updated, transformView, diff --git a/src/commands/upgrade.ts b/packages/cli/src/commands/upgrade.ts similarity index 91% rename from src/commands/upgrade.ts rename to packages/cli/src/commands/upgrade.ts index 59cf165..47f015f 100644 --- a/src/commands/upgrade.ts +++ b/packages/cli/src/commands/upgrade.ts @@ -1,8 +1,9 @@ import { z } from "zod"; +import { AbortError, UnknownError } from "@metabase/client/errors"; + import packageJson from "../../package.json" with { type: "json" }; -import { AbortError, UnknownError } from "../core/errors"; -import { fetchNpmDistTags, type FetchDistTagsOptions } from "../core/http/npm-registry"; +import { fetchNpmDistTags, type FetchDistTagsOptions } from "../core/npm-registry"; import { buildInstallCommand, detectInstallMethod, @@ -12,12 +13,13 @@ import { type InstallMethod, } from "../core/install-method"; import { compareSemver, SemverString } from "../core/semver"; -import type { ResourceView } from "../domain/view"; +import { USER_AGENT } from "../core/user-agent"; import { renderItem, writeText } from "../output/render"; import { promptConfirm } from "../output/prompt"; import type { RenderOptions } from "../output/types"; +import type { ResourceView } from "../output/view"; +import { interruptSignal } from "../runtime/interrupt"; import { streamProcess } from "../runtime/process"; - import { outputFlags } from "./flags"; import { defineMetabaseCommand } from "./runtime"; @@ -88,17 +90,18 @@ export default defineMetabaseCommand({ "mb upgrade --to 0.1.2", ], async run({ args, ctx }) { + const packageName = packageJson.name; const currentVersion = SemverString.parse(packageJson.version); const install = detectInstallMethod(process.argv[1]); - const distTags = await fetchNpmDistTags(packageJson.name, distTagsOptions(args.registry)); + const distTags = await fetchNpmDistTags(packageName, distTagsOptions(args.registry)); const latestVersion = SemverString.parse(distTags.latest); const targetVersion = resolveTargetVersion(args.to, latestVersion); const updateAvailable = compareSemver(currentVersion, latestVersion) < 0; const changeRequired = compareSemver(currentVersion, targetVersion) !== 0; - const command = buildInstallCommand(install, packageJson.name, targetVersion); + const command = buildInstallCommand(install, packageName, targetVersion); const canAutoInstall = install.kind === "npm-global" && command !== null; const status: UpgradeStatus = { - packageName: packageJson.name, + packageName, currentVersion, latestVersion, targetVersion, @@ -143,9 +146,9 @@ export default defineMetabaseCommand({ function distTagsOptions(registryArg: string | undefined): FetchDistTagsOptions { const registry = registryArg?.trim(); if (registry === undefined || registry === "") { - return {}; + return { userAgent: USER_AGENT, signal: interruptSignal }; } - return { registry }; + return { userAgent: USER_AGENT, registry, signal: interruptSignal }; } function resolveTargetVersion(toArg: string | undefined, latestVersion: string): string { diff --git a/src/commands/upload/append.ts b/packages/cli/src/commands/upload/append.ts similarity index 87% rename from src/commands/upload/append.ts rename to packages/cli/src/commands/upload/append.ts index a60a81e..27b47dc 100644 --- a/src/commands/upload/append.ts +++ b/packages/cli/src/commands/upload/append.ts @@ -1,12 +1,12 @@ +import { UploadUpdateResult } from "@metabase/client/domain/upload"; + import { renderSummary } from "../../output/render"; +import { uploadUpdateResultView } from "../../output/views/upload"; import { readCsvFile, requireUploadFilePath } from "../../runtime/upload"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { updateTableFromCsv } from "./requests"; -import { UploadUpdateResult, uploadUpdateResultView } from "./results"; - export default defineMetabaseCommand({ meta: { name: "append", description: "Append a CSV file's rows to an existing uploaded table" }, details: @@ -28,7 +28,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const file = await readCsvFile(requireUploadFilePath(args.file)); const client = await getClient(); - const result = await updateTableFromCsv(client, id, "append", file); + const result = await client.table.appendCsv(id, file); renderSummary( result, uploadUpdateResultView, diff --git a/src/commands/upload/csv.ts b/packages/cli/src/commands/upload/csv.ts similarity index 75% rename from src/commands/upload/csv.ts rename to packages/cli/src/commands/upload/csv.ts index 45918b3..ee00cdd 100644 --- a/src/commands/upload/csv.ts +++ b/packages/cli/src/commands/upload/csv.ts @@ -1,13 +1,12 @@ +import { UploadResult } from "@metabase/client/domain/upload"; + import { renderSummary } from "../../output/render"; -import { buildCsvFormData, readCsvFile, requireUploadFilePath } from "../../runtime/upload"; +import { uploadResultView } from "../../output/views/upload"; +import { readCsvFile, requireUploadFilePath } from "../../runtime/upload"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { parseCreateUploadResult } from "./requests"; -import { UploadResult, uploadResultView } from "./results"; - -const UPLOAD_CSV_PATH = "/api/upload/csv"; const ROOT_COLLECTION = "root"; export default defineMetabaseCommand({ @@ -31,15 +30,8 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const collectionId = resolveCollection(args.collection); const file = await readCsvFile(requireUploadFilePath(args.file)); - const form = buildCsvFormData(file); - form.append("collection_id", collectionId); const client = await getClient(); - const response = await client.requestRaw(UPLOAD_CSV_PATH, { - method: "POST", - body: form, - expectContentType: "binary", - }); - const result = parseCreateUploadResult(await response.text(), response.headers); + const result = await client.upload.createFromCsv(file, { collection_id: collectionId }); renderSummary( result, uploadResultView, diff --git a/src/commands/upload/index.ts b/packages/cli/src/commands/upload/index.ts similarity index 100% rename from src/commands/upload/index.ts rename to packages/cli/src/commands/upload/index.ts diff --git a/src/commands/upload/replace.ts b/packages/cli/src/commands/upload/replace.ts similarity index 87% rename from src/commands/upload/replace.ts rename to packages/cli/src/commands/upload/replace.ts index d9c973b..0ac70bd 100644 --- a/src/commands/upload/replace.ts +++ b/packages/cli/src/commands/upload/replace.ts @@ -1,12 +1,12 @@ +import { UploadUpdateResult } from "@metabase/client/domain/upload"; + import { renderSummary } from "../../output/render"; +import { uploadUpdateResultView } from "../../output/views/upload"; import { readCsvFile, requireUploadFilePath } from "../../runtime/upload"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { updateTableFromCsv } from "./requests"; -import { UploadUpdateResult, uploadUpdateResultView } from "./results"; - export default defineMetabaseCommand({ meta: { name: "replace", @@ -28,7 +28,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const file = await readCsvFile(requireUploadFilePath(args.file)); const client = await getClient(); - const result = await updateTableFromCsv(client, id, "replace", file); + const result = await client.table.replaceCsv(id, file); renderSummary( result, uploadUpdateResultView, diff --git a/src/commands/uuid.test.ts b/packages/cli/src/commands/uuid.test.ts similarity index 97% rename from src/commands/uuid.test.ts rename to packages/cli/src/commands/uuid.test.ts index f52a73b..b5e4d8a 100644 --- a/src/commands/uuid.test.ts +++ b/packages/cli/src/commands/uuid.test.ts @@ -1,7 +1,7 @@ import { runCommand } from "citty"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { parseJson } from "../runtime/json"; +import { parseJson } from "@metabase/client/json"; import uuidCommand, { MAX_COUNT, UuidList } from "./uuid"; diff --git a/src/commands/uuid.ts b/packages/cli/src/commands/uuid.ts similarity index 96% rename from src/commands/uuid.ts rename to packages/cli/src/commands/uuid.ts index 6b3d427..37e6681 100644 --- a/src/commands/uuid.ts +++ b/packages/cli/src/commands/uuid.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { z } from "zod"; -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; import { writeJson, writeText } from "../output/render"; import { outputFlags } from "./flags"; diff --git a/src/commands/validate-query.test.ts b/packages/cli/src/commands/validate-query.test.ts similarity index 97% rename from src/commands/validate-query.test.ts rename to packages/cli/src/commands/validate-query.test.ts index e0f8587..924f00f 100644 --- a/src/commands/validate-query.test.ts +++ b/packages/cli/src/commands/validate-query.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ConfigError } from "../core/errors"; -import { ValidationOutcome } from "../core/schema/validate"; -import { parseJson } from "../runtime/json"; +import { ConfigError } from "@metabase/client/errors"; +import { parseJson } from "@metabase/client/json"; +import { ValidationOutcome } from "../core/schema/validate"; import { CARD_DATASET_QUERY_LABELS, TRANSFORM_SOURCE_QUERY_LABELS, diff --git a/src/commands/validate-query.ts b/packages/cli/src/commands/validate-query.ts similarity index 93% rename from src/commands/validate-query.ts rename to packages/cli/src/commands/validate-query.ts index 59a1604..c4d17e7 100644 --- a/src/commands/validate-query.ts +++ b/packages/cli/src/commands/validate-query.ts @@ -1,4 +1,4 @@ -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; import { assertNotLegacyEnvelopeWrappingMbql5, isMbql5Query, @@ -14,7 +14,7 @@ export const skipValidateFlag = { }, } as const; -export interface PreflightLabels { +interface PreflightLabels { readonly contextLabel: string; readonly bodyNoun: string; } @@ -39,7 +39,7 @@ export const SEGMENT_DEFINITION_LABELS: PreflightLabels = { bodyNoun: "definition", }; -export interface PreflightOptions { +interface PreflightOptions { readonly skip: boolean; } diff --git a/src/commands/wait-flags.test.ts b/packages/cli/src/commands/wait-flags.test.ts similarity index 55% rename from src/commands/wait-flags.test.ts rename to packages/cli/src/commands/wait-flags.test.ts index 2025c0f..8dbeb61 100644 --- a/src/commands/wait-flags.test.ts +++ b/packages/cli/src/commands/wait-flags.test.ts @@ -1,25 +1,36 @@ import { describe, expect, it } from "vitest"; -import { ConfigError } from "../core/errors"; -import { DEFAULT_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from "../runtime/poll"; +import { ConfigError } from "@metabase/client/errors"; +import { DEFAULT_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from "@metabase/client/poll"; +import { interruptSignal } from "../runtime/interrupt"; import { parseWaitFlags } from "./wait-flags"; describe("parseWaitFlags", () => { it("returns disabled with default schedule when no flags are passed", () => { expect(parseWaitFlags({})).toEqual({ enabled: false, - schedule: { intervalMs: DEFAULT_INTERVAL_MS, timeoutMs: DEFAULT_TIMEOUT_MS }, + schedule: { + intervalMs: DEFAULT_INTERVAL_MS, + timeoutMs: DEFAULT_TIMEOUT_MS, + signal: interruptSignal, + }, }); }); it("enables waiting and honors --interval / --timeout overrides", () => { expect(parseWaitFlags({ wait: true, interval: "500", timeout: "30000" })).toEqual({ enabled: true, - schedule: { intervalMs: 500, timeoutMs: 30_000 }, + schedule: { intervalMs: 500, timeoutMs: 30_000, signal: interruptSignal }, }); }); + // Two distinct AbortSignals compare equal under toEqual (neither carries an own enumerable + // property), so identity is the only assertion that proves a poll loop is Ctrl-C-interruptible. + it("carries the process interrupt signal itself, not some other signal", () => { + expect(parseWaitFlags({ wait: true }).schedule.signal).toBe(interruptSignal); + }); + it("rejects a non-numeric --interval with ConfigError", () => { expect(() => parseWaitFlags({ wait: true, interval: "fast" })).toThrowError( new ConfigError(`invalid interval: "fast" (expected integer)`), diff --git a/src/commands/wait-flags.ts b/packages/cli/src/commands/wait-flags.ts similarity index 68% rename from src/commands/wait-flags.ts rename to packages/cli/src/commands/wait-flags.ts index e4d416f..1f32778 100644 --- a/src/commands/wait-flags.ts +++ b/packages/cli/src/commands/wait-flags.ts @@ -1,5 +1,6 @@ -import { DEFAULT_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from "../runtime/poll"; +import { DEFAULT_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from "@metabase/client/poll"; +import { interruptSignal } from "../runtime/interrupt"; import { parseId } from "./parse-id"; const waitScheduleFlags = { @@ -38,30 +39,38 @@ export const gitSyncWaitFlags = { ...waitScheduleFlags, } as const; -export interface WaitArgs { - wait?: boolean; +interface WaitScheduleArgs { timeout?: string; interval?: string; } +interface WaitArgs extends WaitScheduleArgs { + wait?: boolean; +} + +// `signal` rides along with the interval and timeout so that every poll loop the CLI starts is +// interruptible by construction — a command that forgets it would hang on Ctrl-C. export interface WaitSchedule { intervalMs: number; timeoutMs: number; + signal: AbortSignal; } -export interface WaitOptions { +interface WaitOptions { enabled: boolean; schedule: WaitSchedule; } -export function parseWaitFlags(args: WaitArgs): WaitOptions { +export function parseWaitSchedule(args: WaitScheduleArgs): WaitSchedule { const interval = args.interval ?? String(DEFAULT_INTERVAL_MS); const timeout = args.timeout ?? String(DEFAULT_TIMEOUT_MS); return { - enabled: args.wait === true, - schedule: { - intervalMs: parseId(interval, "interval"), - timeoutMs: parseId(timeout, "timeout"), - }, + intervalMs: parseId(interval, "interval"), + timeoutMs: parseId(timeout, "timeout"), + signal: interruptSignal, }; } + +export function parseWaitFlags(args: WaitArgs): WaitOptions { + return { enabled: args.wait === true, schedule: parseWaitSchedule(args) }; +} diff --git a/src/core/auth/keyring-mock.ts b/packages/cli/src/core/auth/keyring-mock.ts similarity index 94% rename from src/core/auth/keyring-mock.ts rename to packages/cli/src/core/auth/keyring-mock.ts index 6852dc6..5a07ac7 100644 --- a/src/core/auth/keyring-mock.ts +++ b/packages/cli/src/core/auth/keyring-mock.ts @@ -1,9 +1,9 @@ -export interface KeyringMockState { +interface KeyringMockState { store: Map; controls: { broken: boolean }; } -export interface KeyringMockModule { +interface KeyringMockModule { Entry: new (service: string, account: string) => KeyringMockEntry; } diff --git a/src/core/auth/profile-record.test.ts b/packages/cli/src/core/auth/profile-record.test.ts similarity index 83% rename from src/core/auth/profile-record.test.ts rename to packages/cli/src/core/auth/profile-record.test.ts index e880090..7877cb7 100644 --- a/src/core/auth/profile-record.test.ts +++ b/packages/cli/src/core/auth/profile-record.test.ts @@ -1,6 +1,6 @@ import { describe, expectTypeOf, it } from "vitest"; -import { type ParsedVersion } from "../version/tag"; +import { type ParsedVersion } from "@metabase/client/version/tag"; import { type ProfileLastProbe } from "./profile-record"; diff --git a/src/core/auth/profile-record.ts b/packages/cli/src/core/auth/profile-record.ts similarity index 93% rename from src/core/auth/profile-record.ts rename to packages/cli/src/core/auth/profile-record.ts index b4be959..874985b 100644 --- a/src/core/auth/profile-record.ts +++ b/packages/cli/src/core/auth/profile-record.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import { TokenFeatures } from "../../domain/session-properties"; -import { ParsedVersionSchema } from "../version/tag"; +import { TokenFeatures } from "@metabase/client/domain/session-properties"; +import { ParsedVersion } from "@metabase/client/version/tag"; export const ProbedUser = z.object({ id: z.number().int(), @@ -12,7 +12,7 @@ export type ProbedUser = z.infer; export const ProfileLastProbe = z.object({ at: z.iso.datetime(), - version: ParsedVersionSchema.nullable(), + version: ParsedVersion.nullable(), tokenFeatures: TokenFeatures.nullable(), user: ProbedUser, }); diff --git a/src/core/auth/storage.test.ts b/packages/cli/src/core/auth/storage.test.ts similarity index 99% rename from src/core/auth/storage.test.ts rename to packages/cli/src/core/auth/storage.test.ts index 0173506..4f48102 100644 --- a/src/core/auth/storage.test.ts +++ b/packages/cli/src/core/auth/storage.test.ts @@ -1,10 +1,10 @@ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; - import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { parseJson } from "@metabase/client/json"; + import { ProfilesFile } from "./profile-record"; -import { parseJson } from "../../runtime/json"; import { configDir } from "../paths"; import { setupTempConfigHome, type TempConfigHome } from "./temp-config-home"; @@ -39,7 +39,7 @@ const { writeProfile, } = storage; -import type { OAuthCredential } from "./credential"; +import type { OAuthCredential } from "@metabase/client/auth/credential"; import type { FileLocation } from "./storage"; const OAUTH: OAuthCredential = { diff --git a/src/core/auth/storage.ts b/packages/cli/src/core/auth/storage.ts similarity index 95% rename from src/core/auth/storage.ts rename to packages/cli/src/core/auth/storage.ts index 4182fbc..045a4db 100644 --- a/src/core/auth/storage.ts +++ b/packages/cli/src/core/auth/storage.ts @@ -1,15 +1,14 @@ +import { Entry } from "@napi-rs/keyring"; import { promises as fs } from "node:fs"; import { dirname, join } from "node:path"; -import { Entry } from "@napi-rs/keyring"; +import type { Credential, OAuthCredential } from "@metabase/client/auth/credential"; +import { isFileNotFoundError, ValidationError } from "@metabase/client/errors"; +import { parseJsonResult } from "@metabase/client/json"; +import type { ServerInfo } from "@metabase/client/version/probe"; -import { parseJsonResult } from "../../runtime/json"; import { ENV_DISABLE_KEYRING, readEnv } from "../env"; -import { isNotFoundError, ValidationError } from "../errors"; import { configDir } from "../paths"; -import type { ServerInfo } from "../version/probe"; - -import type { Credential, OAuthCredential } from "./credential"; import { ProfileLastFailure, ProfileLastProbe, @@ -35,10 +34,10 @@ export const LEGACY_STORAGE_NOTICE = export const KEYCHAIN_RESIDUAL_NOTICE = "warning: could not remove one or more secrets from the OS keychain; a stored token may remain — remove it manually or retry."; -export type ProfileApiKeyAccount = `profile:${string}:apiKey`; -export type ProfileOAuthAccessAccount = `profile:${string}:oauthAccess`; -export type ProfileOAuthRefreshAccount = `profile:${string}:oauthRefresh`; -export type CredentialAccount = +type ProfileApiKeyAccount = `profile:${string}:apiKey`; +type ProfileOAuthAccessAccount = `profile:${string}:oauthAccess`; +type ProfileOAuthRefreshAccount = `profile:${string}:oauthRefresh`; +type CredentialAccount = | ProfileApiKeyAccount | ProfileOAuthAccessAccount | ProfileOAuthRefreshAccount; @@ -51,13 +50,13 @@ export const account = { `profile:${profile}:oauthRefresh`, } as const; -export interface KeyringLocation { +interface KeyringLocation { backend: "keyring"; service: string; account: CredentialAccount; } -export type KeyringFallbackReason = "disabled" | "unavailable"; +type KeyringFallbackReason = "disabled" | "unavailable"; export interface FileLocation { backend: "file"; @@ -73,12 +72,12 @@ export interface Profile { apiKey: string; } -export interface ResolvedCredential { +interface ResolvedCredential { url: string; credential: Credential; } -export interface ProbeWriteInput { +interface ProbeWriteInput { user: ProbedUser; server: ServerInfo; } @@ -173,7 +172,7 @@ async function readProfilesFile(): Promise { try { raw = await fs.readFile(path, "utf8"); } catch (error) { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { await detectLegacyArtifacts(); return { profiles: [] }; } @@ -467,7 +466,7 @@ export async function writeProbeResult( return probe; } -export interface ProbeFailureInput { +interface ProbeFailureInput { kind: ProfileFailureKind; reason: string; } diff --git a/src/core/auth/temp-config-home.ts b/packages/cli/src/core/auth/temp-config-home.ts similarity index 100% rename from src/core/auth/temp-config-home.ts rename to packages/cli/src/core/auth/temp-config-home.ts diff --git a/packages/cli/src/core/auth/verify.test.ts b/packages/cli/src/core/auth/verify.test.ts new file mode 100644 index 0000000..8352f76 --- /dev/null +++ b/packages/cli/src/core/auth/verify.test.ts @@ -0,0 +1,106 @@ +import { setImmediate as flushPending } from "node:timers/promises"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ApiKeyCredential } from "@metabase/client/auth/credential"; +import { captureFetch, jsonResponse } from "@metabase/client/testing/fetch-capture"; + +import { USER_AGENT } from "../user-agent"; +import { verifyAndProbe } from "./verify"; + +const CREDENTIAL: ApiKeyCredential = { kind: "apiKey", apiKey: "mb_verify-key" }; +const BASE_URL = "https://m.example.com"; + +const CURRENT_USER = { + id: 1, + email: "admin@example.com", + common_name: "Admin", + is_superuser: true, +}; +const SESSION_PROPERTIES = { version: { tag: "v0.58.0" }, "token-features": { transforms: true } }; + +const READ_HEADERS = { + accept: "application/json", + "user-agent": USER_AGENT, + "x-api-key": "mb_verify-key", +}; + +describe("verifyAndProbe", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("names the CLI and its version on every request it issues", async () => { + const capture = captureFetch([jsonResponse(CURRENT_USER), jsonResponse(SESSION_PROPERTIES)]); + vi.stubGlobal("fetch", capture.fetch); + + await verifyAndProbe(BASE_URL, CREDENTIAL); + + expect(capture.calls.map((call) => call.headers["user-agent"])).toEqual([ + USER_AGENT, + USER_AGENT, + ]); + }); + + // The transport's single-flight refresh exists so two concurrent 401s share one token refresh. + // Serialising the two reads would leave it unexercised, so neither response is answered until + // both requests have been observed on the wire. + it("holds the user read and the version probe in flight together", async () => { + let answer: (() => void) | undefined; + const answered = new Promise((resolve) => { + answer = resolve; + }); + const held = (body: unknown) => async (): Promise => { + await answered; + return jsonResponse(body); + }; + const capture = captureFetch([held(CURRENT_USER), held(SESSION_PROPERTIES)]); + vi.stubGlobal("fetch", capture.fetch); + + const pending = verifyAndProbe(BASE_URL, CREDENTIAL); + try { + await flushPending(); + expect(capture.calls).toEqual([ + { + url: `${BASE_URL}/api/user/current`, + method: "GET", + headers: READ_HEADERS, + body: null, + }, + { + url: `${BASE_URL}/api/session/properties`, + method: "GET", + headers: READ_HEADERS, + body: null, + }, + ]); + } finally { + answer?.(); + } + + expect(await pending).toEqual({ + ok: true, + user: { id: 1, name: "Admin", isAdmin: true }, + server: { + version: { tag: "v0.58.0", major: 58, patch: 0 }, + tokenFeatures: { transforms: true }, + }, + }); + }); + + // A transport failure names no route, and a login that reports only "verification failed + // (current user)" leaves a user unable to tell a blocked route from an unreachable host. + it("names the request a transport failure never reached", async () => { + const unreachable = [new TypeError("fetch failed"), new TypeError("fetch failed")]; + const capture = captureFetch(unreachable); + vi.stubGlobal("fetch", capture.fetch); + + expect(await verifyAndProbe(BASE_URL, CREDENTIAL)).toEqual({ + ok: false, + which: "user", + kind: "network", + endpoint: `${BASE_URL}/api/user/current`, + message: "Could not reach Metabase: fetch failed", + }); + }); +}); diff --git a/packages/cli/src/core/auth/verify.ts b/packages/cli/src/core/auth/verify.ts new file mode 100644 index 0000000..acebf14 --- /dev/null +++ b/packages/cli/src/core/auth/verify.ts @@ -0,0 +1,109 @@ +import type { Credential, CredentialRefresher } from "@metabase/client/auth/credential"; +import { createClient } from "@metabase/client/client"; +import { errorMessage, MetabaseError, NetworkError, TimeoutError } from "@metabase/client/errors"; +import { HttpError } from "@metabase/client/http/errors"; +import { probeServer, type ServerInfo } from "@metabase/client/version/probe"; + +import { USER_AGENT } from "../user-agent"; +import { type ProbedUser, type ProfileFailureKind } from "./profile-record"; + +const VERIFY_TIMEOUT_MS = 15_000; + +export type VerifyWhich = "user" | "server"; + +interface VerifySuccess { + ok: true; + user: ProbedUser; + server: ServerInfo; +} + +export interface VerifyFailure { + ok: false; + which: VerifyWhich; + kind: ProfileFailureKind; + status?: number; + // The request that failed. A proxy or gateway in front of Metabase typically blocks one route + // rather than the host, and neither a server's own message nor a transport failure names it. + // Absent only for a failure raised before a request was made. + endpoint?: string; + message: string; +} + +export type Verification = VerifySuccess | VerifyFailure; + +interface VerifyOptions { + refresh?: CredentialRefresher; + signal?: AbortSignal; +} + +export async function verifyAndProbe( + url: string, + credential: Credential, + options: VerifyOptions = {}, +): Promise { + const { refresh, signal } = options; + const mb = createClient( + { url, credential }, + { + userAgent: USER_AGENT, + ...(refresh !== undefined && { refreshCredential: refresh }), + ...(signal !== undefined && { signal }), + }, + ); + // Concurrent by design: two simultaneous 401s share the transport's single-flight token refresh, + // and serialising them would leave that path unexercised. + const userPromise = mb.user.current({ timeoutMs: VERIFY_TIMEOUT_MS, retries: 0 }); + const serverPromise = probeServer(mb); + const [userResult, serverResult] = await Promise.allSettled([userPromise, serverPromise]); + + if (userResult.status === "rejected") { + return failure(userResult.reason, "user"); + } + if (serverResult.status === "rejected") { + return failure(serverResult.reason, "server"); + } + + const probed: ProbedUser = { + id: userResult.value.id, + name: userResult.value.common_name, + isAdmin: userResult.value.is_superuser, + }; + return { ok: true, user: probed, server: serverResult.value }; +} + +function failure(error: unknown, which: VerifyWhich): VerifyFailure { + if (error instanceof HttpError) { + const kind = error.status === 401 || error.status === 403 ? "auth" : "server"; + return { + ok: false, + which, + kind, + status: error.status, + endpoint: error.developerDetail.url, + message: error.userMessage, + }; + } + if (error instanceof NetworkError) { + return { + ok: false, + which, + kind: "network", + endpoint: error.developerDetail.url, + message: error.userMessage, + }; + } + if (error instanceof TimeoutError) { + const detail = error.developerDetail; + return { + ok: false, + which, + kind: "network", + ...(detail.kind === "http" && { endpoint: detail.url }), + message: error.userMessage, + }; + } + if (error instanceof MetabaseError) { + return { ok: false, which, kind: "server", message: error.userMessage }; + } + return { ok: false, which, kind: "server", message: errorMessage(error) }; +} diff --git a/src/core/config.test.ts b/packages/cli/src/core/config.test.ts similarity index 98% rename from src/core/config.test.ts rename to packages/cli/src/core/config.test.ts index df61d4d..558405a 100644 --- a/src/core/config.test.ts +++ b/packages/cli/src/core/config.test.ts @@ -17,7 +17,7 @@ vi.mock("@napi-rs/keyring", async () => { return createKeyringMockModule(hoisted); }); -vi.mock("./auth/oauth-session", () => ({ +vi.mock("@metabase/client/auth/oauth-session", () => ({ refreshOAuthCredential: async ( url: string, credential: OAuthCredential, @@ -36,7 +36,9 @@ vi.mock("./auth/oauth-session", () => ({ revokeOAuthCredential: async (): Promise => true, })); -import type { OAuthCredential } from "./auth/credential"; +import type { OAuthCredential } from "@metabase/client/auth/credential"; +import { ConfigError } from "@metabase/client/errors"; + import { readProfileCredential, writeOAuthProfile, @@ -51,7 +53,6 @@ import { resolveConfig, resolveProfileName, } from "./config"; -import { ConfigError } from "./errors"; const STORED_OAUTH: OAuthCredential = { kind: "oauth", diff --git a/src/core/config.ts b/packages/cli/src/core/config.ts similarity index 94% rename from src/core/config.ts rename to packages/cli/src/core/config.ts index 1d3c10e..7e5e04f 100644 --- a/src/core/config.ts +++ b/packages/cli/src/core/config.ts @@ -3,8 +3,12 @@ import { type CredentialRefresher, isOAuthExpired, type OAuthCredential, -} from "./auth/credential"; -import { refreshOAuthCredential } from "./auth/oauth-session"; +} from "@metabase/client/auth/credential"; +import { refreshOAuthCredential } from "@metabase/client/auth/oauth-session"; +import { ConfigError } from "@metabase/client/errors"; +import { normalizeUrl } from "@metabase/client/url"; + +import { USER_AGENT } from "./user-agent"; import { DEFAULT_PROFILE, readProfileCredential, @@ -12,8 +16,6 @@ import { writeOAuthProfile, } from "./auth/storage"; import { ENV_API_KEY, ENV_PROFILE, ENV_SKIP_PREFLIGHT, ENV_URL, readEnv } from "./env"; -import { ConfigError } from "./errors"; -import { normalizeUrl } from "./url"; export const SKIP_PREFLIGHT_ENV = ENV_SKIP_PREFLIGHT; @@ -21,7 +23,7 @@ export function isPreflightSkipped(): boolean { return readEnv(ENV_SKIP_PREFLIGHT) === "1"; } -export type ConfigSource = "flag" | "env" | "stored" | "mixed"; +type ConfigSource = "flag" | "env" | "stored" | "mixed"; export interface ConfigFlags { profile?: string; @@ -36,7 +38,7 @@ export interface ResolvedConfig { source: ConfigSource; } -export interface EnvCredentials { +interface EnvCredentials { url: string | null; apiKey: string | null; } @@ -136,7 +138,7 @@ async function ensureFreshCredential( } let refreshed: OAuthCredential; try { - refreshed = await refreshOAuthCredential(issuerUrl, credential, Date.now()); + refreshed = await refreshOAuthCredential(issuerUrl, credential, Date.now(), USER_AGENT); } catch { // Best-effort: a failed network refresh (server blip, offline) falls back to the existing // credential and lets the reactive 401-refresh retry, rather than failing every command. @@ -154,7 +156,7 @@ async function refreshAndPersist( issuerUrl: string, credential: OAuthCredential, ): Promise { - const refreshed = await refreshOAuthCredential(issuerUrl, credential, Date.now()); + const refreshed = await refreshOAuthCredential(issuerUrl, credential, Date.now(), USER_AGENT); await persistRefreshed(profile, issuerUrl, refreshed); return refreshed; } diff --git a/src/core/env.test.ts b/packages/cli/src/core/env.test.ts similarity index 100% rename from src/core/env.test.ts rename to packages/cli/src/core/env.test.ts diff --git a/src/core/env.ts b/packages/cli/src/core/env.ts similarity index 96% rename from src/core/env.ts rename to packages/cli/src/core/env.ts index db87678..81b2437 100644 --- a/src/core/env.ts +++ b/packages/cli/src/core/env.ts @@ -7,6 +7,7 @@ export const ENV_PROFILE = "MB_PROFILE"; export const ENV_VERBOSE = "MB_VERBOSE"; export const ENV_SKIP_PREFLIGHT = "MB_CLI_SKIP_PREFLIGHT"; export const ENV_DISABLE_KEYRING = "MB_CLI_DISABLE_KEYRING"; +export const ENV_SKILLS_DIR = "MB_SKILLS_DIR"; function legacyNameFor(canonical: string): string { return LEGACY_PREFIX + canonical.slice(CANONICAL_PREFIX.length); diff --git a/src/core/install-method.test.ts b/packages/cli/src/core/install-method.test.ts similarity index 100% rename from src/core/install-method.test.ts rename to packages/cli/src/core/install-method.test.ts diff --git a/src/core/install-method.ts b/packages/cli/src/core/install-method.ts similarity index 95% rename from src/core/install-method.ts rename to packages/cli/src/core/install-method.ts index 7f9a4b4..a9a3d61 100644 --- a/src/core/install-method.ts +++ b/packages/cli/src/core/install-method.ts @@ -3,10 +3,10 @@ import { realpathSync } from "node:fs"; import { z } from "zod"; export const InstallMethodKindSchema = z.enum(["npm-global", "npm-local", "npx", "dev", "unknown"]); -export type InstallMethodKind = z.infer; +type InstallMethodKind = z.infer; export const PackageManagerSchema = z.enum(["npm", "pnpm", "yarn", "bun", "unknown"]); -export type PackageManager = z.infer; +type PackageManager = z.infer; export interface InstallMethod { readonly kind: InstallMethodKind; @@ -42,7 +42,7 @@ const GLOBAL_MARKERS: ReadonlyArray = [ const NPX_MARKER = "/_npx/"; const NODE_MODULES_MARKER = "/node_modules/"; -export interface DetectInstallMethodOptions { +interface DetectInstallMethodOptions { npmConfigPrefix?: string; } diff --git a/src/core/http/npm-registry.test.ts b/packages/cli/src/core/npm-registry.test.ts similarity index 84% rename from src/core/http/npm-registry.test.ts rename to packages/cli/src/core/npm-registry.test.ts index d6db4d7..715b3e4 100644 --- a/src/core/http/npm-registry.test.ts +++ b/packages/cli/src/core/npm-registry.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { NetworkError, TimeoutError, ValidationError } from "../errors"; +import { NetworkError, TimeoutError, ValidationError } from "@metabase/client/errors"; +import { HttpError } from "@metabase/client/http/errors"; -import { HttpError } from "./errors"; import { fetchNpmDistTags } from "./npm-registry"; interface FakeCall { @@ -65,6 +65,8 @@ const pendingAbortFetch: typeof fetch = (_input, init) => }); }); +const CALLER_USER_AGENT = "metabase-cli/9.9.9"; + describe("fetchNpmDistTags", () => { it("hits /-/package//dist-tags on the default registry", async () => { const { fetchImpl, calls } = makeFetch( @@ -73,14 +75,17 @@ describe("fetchNpmDistTags", () => { headers: { "content-type": "application/json" }, }), ); - const result = await fetchNpmDistTags("@metabase/cli", { fetchImpl }); + const result = await fetchNpmDistTags("@metabase/cli", { + fetchImpl, + userAgent: CALLER_USER_AGENT, + }); expect(result).toEqual({ latest: "1.2.3", beta: "1.3.0-beta.1" }); expect(calls).toEqual([ { url: "https://registry.npmjs.org/-/package/@metabase%2Fcli/dist-tags", headers: { accept: "application/json", - "user-agent": expect.stringMatching(/^metabase-cli\//), + "user-agent": CALLER_USER_AGENT, }, }, ]); @@ -95,6 +100,7 @@ describe("fetchNpmDistTags", () => { ); await fetchNpmDistTags("@metabase/cli", { fetchImpl, + userAgent: CALLER_USER_AGENT, registry: "http://localhost:1234///", }); expect(calls[0]?.url).toBe("http://localhost:1234/-/package/@metabase%2Fcli/dist-tags"); @@ -107,7 +113,7 @@ describe("fetchNpmDistTags", () => { headers: { "content-type": "application/json" }, }), ); - await fetchNpmDistTags("unscoped-pkg", { fetchImpl }); + await fetchNpmDistTags("unscoped-pkg", { fetchImpl, userAgent: CALLER_USER_AGENT }); expect(calls[0]?.url).toBe("https://registry.npmjs.org/-/package/unscoped-pkg/dist-tags"); }); @@ -117,6 +123,7 @@ describe("fetchNpmDistTags", () => { try { await fetchNpmDistTags("@metabase/cli", { fetchImpl, + userAgent: CALLER_USER_AGENT, registry: "http://localhost:1234", }); } catch (error) { @@ -133,7 +140,11 @@ describe("fetchNpmDistTags", () => { it("throws TimeoutError when the timeout signal aborts", async () => { let captured: unknown; try { - await fetchNpmDistTags("@metabase/cli", { fetchImpl: pendingAbortFetch, timeoutMs: 5 }); + await fetchNpmDistTags("@metabase/cli", { + fetchImpl: pendingAbortFetch, + userAgent: CALLER_USER_AGENT, + timeoutMs: 5, + }); } catch (error) { captured = error; } @@ -153,7 +164,7 @@ describe("fetchNpmDistTags", () => { ); let captured: unknown; try { - await fetchNpmDistTags("@no/such-thing", { fetchImpl }); + await fetchNpmDistTags("@no/such-thing", { fetchImpl, userAgent: CALLER_USER_AGENT }); } catch (error) { captured = error; } @@ -170,8 +181,8 @@ describe("fetchNpmDistTags", () => { headers: { "content-type": "application/json" }, }), ); - await expect(fetchNpmDistTags("@metabase/cli", { fetchImpl })).rejects.toBeInstanceOf( - ValidationError, - ); + await expect( + fetchNpmDistTags("@metabase/cli", { fetchImpl, userAgent: CALLER_USER_AGENT }), + ).rejects.toBeInstanceOf(ValidationError); }); }); diff --git a/src/core/http/npm-registry.ts b/packages/cli/src/core/npm-registry.ts similarity index 78% rename from src/core/http/npm-registry.ts rename to packages/cli/src/core/npm-registry.ts index 7925d27..6537dac 100644 --- a/src/core/http/npm-registry.ts +++ b/packages/cli/src/core/npm-registry.ts @@ -1,24 +1,23 @@ import { z } from "zod"; -import { errorMessage, NetworkError, TimeoutError } from "../errors"; -import { parseJson } from "../../runtime/json"; -import { combineAborts, throwIfAborted } from "../../runtime/signal"; - -import { USER_AGENT } from "./client"; -import { HttpError } from "./errors"; +import { errorMessage, NetworkError, TimeoutError } from "@metabase/client/errors"; +import { HttpError } from "@metabase/client/http/errors"; +import { parseJson } from "@metabase/client/json"; +import { combineAborts, throwIfAborted } from "@metabase/client/signal"; const DEFAULT_REGISTRY = "https://registry.npmjs.org"; const DEFAULT_TIMEOUT_MS = 15_000; const ERROR_BODY_BYTE_CAP = 8 * 1024; -export const NpmDistTags = z +const NpmDistTags = z .object({ latest: z.string(), }) .loose(); -export type NpmDistTags = z.infer; +type NpmDistTags = z.infer; export interface FetchDistTagsOptions { + userAgent: string; registry?: string; signal?: AbortSignal; timeoutMs?: number; @@ -27,24 +26,24 @@ export interface FetchDistTagsOptions { export async function fetchNpmDistTags( packageName: string, - opts: FetchDistTagsOptions = {}, + opts: FetchDistTagsOptions, ): Promise { const registry = opts.registry ?? DEFAULT_REGISTRY; const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis); const url = buildDistTagsUrl(registry, packageName); const timeoutSignal = AbortSignal.timeout(timeoutMs); - const { combined, processSignal } = combineAborts(timeoutSignal, opts.signal); + const combined = combineAborts(timeoutSignal, opts.signal); let response: Response; try { response = await fetchImpl(url, { method: "GET", - headers: { accept: "application/json", "user-agent": USER_AGENT }, + headers: { accept: "application/json", "user-agent": opts.userAgent }, signal: combined, }); } catch (error) { - throwIfAborted(opts.signal, processSignal); + throwIfAborted(opts.signal); if (timeoutSignal.aborted) { throw new TimeoutError(`npm registry request timed out after ${timeoutMs}ms`, { kind: "http", diff --git a/src/core/paths.test.ts b/packages/cli/src/core/paths.test.ts similarity index 100% rename from src/core/paths.test.ts rename to packages/cli/src/core/paths.test.ts diff --git a/src/core/paths.ts b/packages/cli/src/core/paths.ts similarity index 100% rename from src/core/paths.ts rename to packages/cli/src/core/paths.ts diff --git a/src/core/schema/data/LICENSE.txt b/packages/cli/src/core/schema/data/LICENSE.txt similarity index 100% rename from src/core/schema/data/LICENSE.txt rename to packages/cli/src/core/schema/data/LICENSE.txt diff --git a/src/core/schema/data/schemas/common/id.json b/packages/cli/src/core/schema/data/schemas/common/id.json similarity index 92% rename from src/core/schema/data/schemas/common/id.json rename to packages/cli/src/core/schema/data/schemas/common/id.json index f2a5765..daff5cf 100644 --- a/src/core/schema/data/schemas/common/id.json +++ b/packages/cli/src/core/schema/data/schemas/common/id.json @@ -25,7 +25,10 @@ "description": "Database name" }, { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Schema name (null for schemaless databases)" }, { @@ -45,7 +48,10 @@ "description": "Database name" }, { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Schema name (null for schemaless databases)" }, { diff --git a/src/core/schema/data/schemas/common/parameter.json b/packages/cli/src/core/schema/data/schemas/common/parameter.json similarity index 91% rename from src/core/schema/data/schemas/common/parameter.json rename to packages/cli/src/core/schema/data/schemas/common/parameter.json index 4767fc3..26c757d 100644 --- a/src/core/schema/data/schemas/common/parameter.json +++ b/packages/cli/src/core/schema/data/schemas/common/parameter.json @@ -2,7 +2,12 @@ "title": "Parameter", "description": "A filter control parameter for cards and dashboards. Parameters allow users to interactively filter data via UI controls.\n", "type": "object", - "required": ["id", "name", "slug", "type"], + "required": [ + "id", + "name", + "slug", + "type" + ], "properties": { "id": { "type": "string", @@ -31,23 +36,50 @@ "sectionId": { "type": "string", "description": "UI section grouping. Restricts which columns are available for mapping. For id — only PK and FK columns. For location — only location columns (country, city, etc.). Normally matches the first part of the parameter type.\n", - "enum": ["string", "number", "date", "boolean", "temporal-unit", "id", "location"] + "enum": [ + "string", + "number", + "date", + "boolean", + "temporal-unit", + "id", + "location" + ] }, "temporal_units": { "type": "array", "description": "Allowed temporal units (for temporal-unit type)", "items": { "type": "string", - "enum": ["minute", "hour", "day", "week", "month", "quarter", "year"] + "enum": [ + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ] } }, "values_query_type": { "type": "string", - "enum": ["list", "search", "none"] + "enum": [ + "list", + "search", + "none" + ] }, "values_source_type": { - "type": ["string", "null"], - "enum": [null, "card", "static-list"] + "type": [ + "string", + "null" + ], + "enum": [ + null, + "card", + "static-list" + ] }, "values_source_config": { "type": "object", diff --git a/src/core/schema/data/schemas/common/query.json b/packages/cli/src/core/schema/data/schemas/common/query.json similarity index 86% rename from src/core/schema/data/schemas/common/query.json rename to packages/cli/src/core/schema/data/schemas/common/query.json index cdde591..79bb4bf 100644 --- a/src/core/schema/data/schemas/common/query.json +++ b/packages/cli/src/core/schema/data/schemas/common/query.json @@ -2,7 +2,11 @@ "title": "Query", "description": "A query definition in serialized pMBQL format. Contains a flat list of stages (no recursive source-query nesting). Each stage is either an MBQL structured stage or a native SQL stage.\n", "type": "object", - "required": ["lib/type", "database", "stages"], + "required": [ + "lib/type", + "database", + "stages" + ], "properties": { "lib/type": { "type": "string", @@ -99,7 +103,9 @@ }, "stage": { "type": "object", - "required": ["lib/type"], + "required": [ + "lib/type" + ], "allOf": [ { "if": { @@ -208,13 +214,22 @@ } }, "limit": { - "type": ["integer", "null"] + "type": [ + "integer", + "null" + ] } } }, "expression": { "description": "An MBQL expression clause. Either a literal value or an array [operator, options, ...args] where options is always an object.\n", - "type": ["string", "number", "boolean", "null", "array"], + "type": [ + "string", + "number", + "boolean", + "null", + "array" + ], "if": { "type": "array" }, @@ -310,7 +325,11 @@ "$ref": "#/$defs/options" }, { - "type": ["string", "number", "boolean"] + "type": [ + "string", + "number", + "boolean" + ] } ], "minItems": 3, @@ -321,14 +340,24 @@ "if": { "prefixItems": [ { - "enum": ["+", "-", "*", "/"] + "enum": [ + "+", + "-", + "*", + "/" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["+", "-", "*", "/"] + "enum": [ + "+", + "-", + "*", + "/" + ] }, { "$ref": "#/$defs/options" @@ -347,14 +376,20 @@ "if": { "prefixItems": [ { - "enum": ["and", "or"] + "enum": [ + "and", + "or" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["and", "or"] + "enum": [ + "and", + "or" + ] }, { "$ref": "#/$defs/options" @@ -400,14 +435,28 @@ "if": { "prefixItems": [ { - "enum": ["=", "!=", "<", ">", "<=", ">="] + "enum": [ + "=", + "!=", + "<", + ">", + "<=", + ">=" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["=", "!=", "<", ">", "<=", ">="] + "enum": [ + "=", + "!=", + "<", + ">", + "<=", + ">=" + ] }, { "$ref": "#/$defs/options" @@ -429,14 +478,20 @@ "if": { "prefixItems": [ { - "enum": ["in", "not-in"] + "enum": [ + "in", + "not-in" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["in", "not-in"] + "enum": [ + "in", + "not-in" + ] }, { "$ref": "#/$defs/options" @@ -527,14 +582,24 @@ "if": { "prefixItems": [ { - "enum": ["is-null", "not-null", "is-empty", "not-empty"] + "enum": [ + "is-null", + "not-null", + "is-empty", + "not-empty" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["is-null", "not-null", "is-empty", "not-empty"] + "enum": [ + "is-null", + "not-null", + "is-empty", + "not-empty" + ] }, { "$ref": "#/$defs/options" @@ -551,14 +616,24 @@ "if": { "prefixItems": [ { - "enum": ["contains", "does-not-contain", "starts-with", "ends-with"] + "enum": [ + "contains", + "does-not-contain", + "starts-with", + "ends-with" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["contains", "does-not-contain", "starts-with", "ends-with"] + "enum": [ + "contains", + "does-not-contain", + "starts-with", + "ends-with" + ] }, { "$ref": "#/$defs/case_sensitive_options" @@ -603,7 +678,11 @@ "type": "integer" }, "else": { - "enum": ["current", "last", "next"] + "enum": [ + "current", + "last", + "next" + ] } }, { @@ -654,14 +733,20 @@ "if": { "prefixItems": [ { - "enum": ["now", "today"] + "enum": [ + "now", + "today" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["now", "today"] + "enum": [ + "now", + "today" + ] }, { "$ref": "#/$defs/options" @@ -814,14 +899,20 @@ "if": { "prefixItems": [ { - "enum": ["get-day-of-week", "get-week"] + "enum": [ + "get-day-of-week", + "get-week" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["get-day-of-week", "get-week"] + "enum": [ + "get-day-of-week", + "get-week" + ] }, { "$ref": "#/$defs/options" @@ -831,7 +922,11 @@ }, { "type": "string", - "enum": ["iso", "us", "instance"] + "enum": [ + "iso", + "us", + "instance" + ] } ], "minItems": 3, @@ -869,14 +964,20 @@ "if": { "prefixItems": [ { - "enum": ["replace", "split-part"] + "enum": [ + "replace", + "split-part" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["replace", "split-part"] + "enum": [ + "replace", + "split-part" + ] }, { "$ref": "#/$defs/options" @@ -1041,14 +1142,20 @@ "if": { "prefixItems": [ { - "enum": ["datetime-add", "datetime-subtract"] + "enum": [ + "datetime-add", + "datetime-subtract" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["datetime-add", "datetime-subtract"] + "enum": [ + "datetime-add", + "datetime-subtract" + ] }, { "$ref": "#/$defs/options" @@ -1151,7 +1258,11 @@ }, { "type": "string", - "enum": ["iso", "us", "instance"] + "enum": [ + "iso", + "us", + "instance" + ] } ], "minItems": 4, @@ -1162,14 +1273,20 @@ "if": { "prefixItems": [ { - "enum": ["count", "cum-count"] + "enum": [ + "count", + "cum-count" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["count", "cum-count"] + "enum": [ + "count", + "cum-count" + ] }, { "$ref": "#/$defs/options" @@ -1257,14 +1374,20 @@ "if": { "prefixItems": [ { - "enum": ["count-where", "share"] + "enum": [ + "count-where", + "share" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["count-where", "share"] + "enum": [ + "count-where", + "share" + ] }, { "$ref": "#/$defs/options" @@ -1281,14 +1404,20 @@ "if": { "prefixItems": [ { - "enum": ["sum-where", "distinct-where"] + "enum": [ + "sum-where", + "distinct-where" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["sum-where", "distinct-where"] + "enum": [ + "sum-where", + "distinct-where" + ] }, { "$ref": "#/$defs/options" @@ -1308,14 +1437,20 @@ "if": { "prefixItems": [ { - "enum": ["case", "if"] + "enum": [ + "case", + "if" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["case", "if"] + "enum": [ + "case", + "if" + ] }, { "$ref": "#/$defs/options" @@ -1375,14 +1510,20 @@ "if": { "prefixItems": [ { - "enum": ["asc", "desc"] + "enum": [ + "asc", + "desc" + ] } ] }, "then": { "prefixItems": [ { - "enum": ["asc", "desc"] + "enum": [ + "asc", + "desc" + ] }, { "$ref": "#/$defs/options" @@ -1400,11 +1541,24 @@ }, "datetime_diff_unit": { "type": "string", - "enum": ["second", "minute", "hour", "day", "week", "month", "quarter", "year"] + "enum": [ + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ] }, "join": { "type": "object", - "required": ["stages", "conditions", "alias"], + "required": [ + "stages", + "conditions", + "alias" + ], "properties": { "stages": { "type": "array", @@ -1425,14 +1579,22 @@ }, "strategy": { "type": "string", - "enum": ["left-join", "right-join", "inner-join", "full-join"] + "enum": [ + "left-join", + "right-join", + "inner-join", + "full-join" + ] }, "fields": { "if": { "type": "string" }, "then": { - "enum": ["all", "none"] + "enum": [ + "all", + "none" + ] }, "else": { "type": "array", @@ -1445,7 +1607,10 @@ }, "native_stage": { "type": "object", - "required": ["lib/type", "native"], + "required": [ + "lib/type", + "native" + ], "properties": { "lib/type": { "const": "mbql.stage/native" @@ -1463,7 +1628,11 @@ }, "template_tag": { "type": "object", - "required": ["type", "name", "id"], + "required": [ + "type", + "name", + "id" + ], "properties": { "type": { "type": "string", @@ -1506,7 +1675,10 @@ } }, "then": { - "required": ["dimension", "widget-type"], + "required": [ + "dimension", + "widget-type" + ], "properties": { "dimension": { "$ref": "#/$defs/expression" @@ -1532,7 +1704,9 @@ } }, "then": { - "required": ["dimension"], + "required": [ + "dimension" + ], "properties": { "dimension": { "$ref": "#/$defs/expression" @@ -1552,7 +1726,9 @@ } }, "then": { - "required": ["card-id"], + "required": [ + "card-id" + ], "properties": { "card-id": { "$ref": "id.yaml#/$defs/entity_id" @@ -1569,7 +1745,10 @@ } }, "then": { - "required": ["snippet-name", "snippet-id"], + "required": [ + "snippet-name", + "snippet-id" + ], "properties": { "snippet-name": { "type": "string" @@ -1589,7 +1768,9 @@ } }, "then": { - "required": ["table-id"], + "required": [ + "table-id" + ], "properties": { "table-id": { "$ref": "id.yaml#/$defs/table_id" diff --git a/src/core/schema/data/schemas/common/ref.json b/packages/cli/src/core/schema/data/schemas/common/ref.json similarity index 90% rename from src/core/schema/data/schemas/common/ref.json rename to packages/cli/src/core/schema/data/schemas/common/ref.json index ab5fb5b..28bdf80 100644 --- a/src/core/schema/data/schemas/common/ref.json +++ b/packages/cli/src/core/schema/data/schemas/common/ref.json @@ -19,11 +19,17 @@ }, "binning": { "type": "object", - "required": ["strategy"], + "required": [ + "strategy" + ], "properties": { "strategy": { "type": "string", - "enum": ["default", "num-bins", "bin-width"] + "enum": [ + "default", + "num-bins", + "bin-width" + ] } }, "allOf": [ @@ -36,7 +42,9 @@ } }, "then": { - "required": ["bin-width"], + "required": [ + "bin-width" + ], "properties": { "bin-width": { "type": "number", @@ -54,7 +62,9 @@ } }, "then": { - "required": ["num-bins"], + "required": [ + "num-bins" + ], "properties": { "num-bins": { "type": "integer", @@ -202,7 +212,10 @@ } }, { - "type": ["object", "null"] + "type": [ + "object", + "null" + ] } ], "minItems": 3, @@ -219,7 +232,10 @@ "type": "string" }, { - "type": ["object", "null"] + "type": [ + "object", + "null" + ] } ], "minItems": 2, diff --git a/src/core/schema/data/schemas/common/temporal_bucketing.json b/packages/cli/src/core/schema/data/schemas/common/temporal_bucketing.json similarity index 87% rename from src/core/schema/data/schemas/common/temporal_bucketing.json rename to packages/cli/src/core/schema/data/schemas/common/temporal_bucketing.json index 0333c35..a3d530b 100644 --- a/src/core/schema/data/schemas/common/temporal_bucketing.json +++ b/packages/cli/src/core/schema/data/schemas/common/temporal_bucketing.json @@ -5,7 +5,17 @@ "datetime_truncation_unit": { "description": "Truncation units — truncate a datetime to a boundary. Also used for intervals and datetime arithmetic.\n", "type": "string", - "enum": ["millisecond", "second", "minute", "hour", "day", "week", "month", "quarter", "year"] + "enum": [ + "millisecond", + "second", + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "year" + ] }, "datetime_extraction_unit": { "description": "Extraction units — extract a numeric component from a datetime.\n", diff --git a/src/core/schema/validate.test.ts b/packages/cli/src/core/schema/validate.test.ts similarity index 100% rename from src/core/schema/validate.test.ts rename to packages/cli/src/core/schema/validate.test.ts diff --git a/src/core/schema/validate.ts b/packages/cli/src/core/schema/validate.ts similarity index 97% rename from src/core/schema/validate.ts rename to packages/cli/src/core/schema/validate.ts index 75830dc..454ba1c 100644 --- a/src/core/schema/validate.ts +++ b/packages/cli/src/core/schema/validate.ts @@ -3,9 +3,9 @@ import addFormats from "ajv-formats"; import type { ErrorObject, ValidateFunction } from "ajv"; import { z } from "zod"; -import { isPlainObject } from "../../runtime/predicates"; -import { ConfigError } from "../errors"; -import { escapeJsonPointerSegment } from "../json-pointer"; +import { isPlainObject } from "@metabase/client/predicates"; +import { ConfigError } from "@metabase/client/errors"; +import { escapeJsonPointerSegment } from "@metabase/client/json-pointer"; import parameterSchema from "./data/schemas/common/parameter.json" with { type: "json" }; import querySchema from "./data/schemas/common/query.json" with { type: "json" }; @@ -240,7 +240,7 @@ export function isLegacyEnvelopeWrappingMbql5(value: unknown): boolean { return "lib/type" in inner && inner["lib/type"] === "mbql/query"; } -export interface LegacyEnvelopeAssertOptions { +interface LegacyEnvelopeAssertOptions { readonly contextLabel: string; readonly bodyNoun: string; } diff --git a/src/core/semver.test.ts b/packages/cli/src/core/semver.test.ts similarity index 100% rename from src/core/semver.test.ts rename to packages/cli/src/core/semver.test.ts diff --git a/src/core/semver.ts b/packages/cli/src/core/semver.ts similarity index 91% rename from src/core/semver.ts rename to packages/cli/src/core/semver.ts index 7a99af2..51d38df 100644 --- a/src/core/semver.ts +++ b/packages/cli/src/core/semver.ts @@ -7,7 +7,7 @@ export const SemverString = z.string().refine((value) => valid(value) !== null, message: SEMVER_MESSAGE, }); -export type Ordering = -1 | 0 | 1; +type Ordering = -1 | 0 | 1; export function compareSemver(a: string, b: string): Ordering { return compare(a, b); diff --git a/src/core/skills.test.ts b/packages/cli/src/core/skills.test.ts similarity index 96% rename from src/core/skills.test.ts rename to packages/cli/src/core/skills.test.ts index 76f301a..005f42e 100644 --- a/src/core/skills.test.ts +++ b/packages/cli/src/core/skills.test.ts @@ -4,7 +4,8 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { ConfigError } from "./errors"; +import { ConfigError } from "@metabase/client/errors"; +import { ENV_SKILLS_DIR } from "./env"; import { availableSkillNames, discoverSkills, @@ -13,7 +14,6 @@ import { readSkillContent, resolveSkillDirs, selectSkillsByNames, - SKILLS_DIR_ENV, type SkillInfo, } from "./skills"; @@ -305,7 +305,7 @@ describe("selectSkillsByNames", () => { describe("resolveSkillDirs", () => { let temp: TempDirs; - const originalEnv = process.env[SKILLS_DIR_ENV]; + const originalEnv = process.env[ENV_SKILLS_DIR]; beforeEach(() => { temp = makeSkillsRoot(); @@ -314,22 +314,22 @@ describe("resolveSkillDirs", () => { afterEach(() => { rmSync(temp.root, { recursive: true, force: true }); if (originalEnv === undefined) { - delete process.env[SKILLS_DIR_ENV]; + delete process.env[ENV_SKILLS_DIR]; } else { - process.env[SKILLS_DIR_ENV] = originalEnv; + process.env[ENV_SKILLS_DIR] = originalEnv; } }); it("returns the env-var override directory when set", () => { - process.env[SKILLS_DIR_ENV] = temp.skillData; + process.env[ENV_SKILLS_DIR] = temp.skillData; expect(resolveSkillDirs()).toEqual([temp.skillData]); }); it("throws ConfigError when the env-var override is not a directory", () => { - process.env[SKILLS_DIR_ENV] = join(temp.root, "does-not-exist"); + process.env[ENV_SKILLS_DIR] = join(temp.root, "does-not-exist"); expect(() => resolveSkillDirs()).toThrow( new ConfigError( - `${SKILLS_DIR_ENV} points at ${join(temp.root, "does-not-exist")}, which is not a directory`, + `${ENV_SKILLS_DIR} points at ${join(temp.root, "does-not-exist")}, which is not a directory`, ), ); }); diff --git a/src/core/skills.ts b/packages/cli/src/core/skills.ts similarity index 89% rename from src/core/skills.ts rename to packages/cli/src/core/skills.ts index 2ba2128..b874715 100644 --- a/src/core/skills.ts +++ b/packages/cli/src/core/skills.ts @@ -3,24 +3,25 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { z } from "zod"; -import { parseYamlResult } from "../runtime/yaml"; +import { ConfigError, isFileNotFoundError } from "@metabase/client/errors"; -import { ConfigError, isNotFoundError } from "./errors"; +import { parseYamlResult } from "../runtime/yaml"; +import { ENV_SKILLS_DIR, readEnv } from "./env"; -export const Frontmatter = z +const Frontmatter = z .object({ name: z.string().min(1), description: z.string().default(""), hidden: z.boolean().default(false), }) .loose(); -export type Frontmatter = z.infer; +type Frontmatter = z.infer; -export const SkillExtraFile = z.object({ +const SkillExtraFile = z.object({ path: z.string(), content: z.string(), }); -export type SkillExtraFile = z.infer; +type SkillExtraFile = z.infer; export const SkillContent = z.object({ name: z.string(), @@ -38,15 +39,14 @@ export interface SkillInfo { dir: string; } -export interface ReadSkillContentOptions { +interface ReadSkillContentOptions { includeExtras: boolean; } -export const SKILL_DIR_NAMES = ["skills", "skill-data"] as const; +const SKILL_DIR_NAMES = ["skills", "skill-data"] as const; export const SKILL_MD_FILENAME = "SKILL.md"; -export const SKILL_REFERENCES_DIR = "references"; -export const SKILL_TEMPLATES_DIR = "templates"; -export const SKILLS_DIR_ENV = "MB_SKILLS_DIR"; +const SKILL_REFERENCES_DIR = "references"; +const SKILL_TEMPLATES_DIR = "templates"; const FRONTMATTER_PREFIX_BYTES = 8192; const FRONTMATTER_FENCE = "---"; @@ -99,10 +99,10 @@ export function availableSkillNames(all: readonly SkillInfo[]): string { } export function resolveSkillDirs(): string[] { - const override = process.env[SKILLS_DIR_ENV]; + const override = readEnv(ENV_SKILLS_DIR); if (override !== undefined && override !== "") { if (!isDirectory(override)) { - throw new ConfigError(`${SKILLS_DIR_ENV} points at ${override}, which is not a directory`); + throw new ConfigError(`${ENV_SKILLS_DIR} points at ${override}, which is not a directory`); } return [resolve(override)]; } @@ -137,7 +137,7 @@ export function discoverSkills(dirs: readonly string[]): SkillInfo[] { try { entries = readdirSync(dir); } catch (error) { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { continue; } throw error; @@ -169,7 +169,7 @@ function readFilePrefix(path: string, maxBytes: number): string | null { try { fd = openSync(path, "r"); } catch (error) { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { return null; } throw error; @@ -242,7 +242,7 @@ function isDirectory(path: string): boolean { try { return statSync(path).isDirectory(); } catch (error) { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { return false; } throw error; @@ -253,7 +253,7 @@ function isFile(path: string): boolean { try { return statSync(path).isFile(); } catch (error) { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { return false; } throw error; diff --git a/src/core/http/system-ca.ts b/packages/cli/src/core/system-ca.ts similarity index 100% rename from src/core/http/system-ca.ts rename to packages/cli/src/core/system-ca.ts diff --git a/packages/cli/src/core/temp-skills-dir.ts b/packages/cli/src/core/temp-skills-dir.ts new file mode 100644 index 0000000..533f38c --- /dev/null +++ b/packages/cli/src/core/temp-skills-dir.ts @@ -0,0 +1,43 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { SKILL_MD_FILENAME } from "./skills"; + +export interface TempSkillsDir { + path: string; + cleanup(): void; +} + +interface SkillFixture { + name: string; + description: string; + body: string; +} + +const FIXTURES: readonly SkillFixture[] = [ + { name: "alpha", description: "The first skill.", body: "Alpha instructions." }, + { name: "beta", description: "The second skill.", body: "Beta instructions." }, +]; + +// Two skills is the smallest collection that can show a window dropping one. Pointing +// MB_SKILLS_DIR here keeps an assertion over rendered output independent of whatever +// skill-data/ happens to ship. +export function createTempSkillsDir(): TempSkillsDir { + const path = mkdtempSync(join(tmpdir(), "mb-skills-")); + for (const fixture of FIXTURES) { + const dir = join(path, fixture.name); + mkdirSync(dir); + writeFileSync(join(dir, SKILL_MD_FILENAME), skillMarkdown(fixture), "utf8"); + } + return { + path, + cleanup() { + rmSync(path, { recursive: true, force: true }); + }, + }; +} + +function skillMarkdown(fixture: SkillFixture): string { + return `---\nname: ${fixture.name}\ndescription: ${fixture.description}\n---\n\n${fixture.body}\n`; +} diff --git a/packages/cli/src/core/user-agent.ts b/packages/cli/src/core/user-agent.ts new file mode 100644 index 0000000..a517082 --- /dev/null +++ b/packages/cli/src/core/user-agent.ts @@ -0,0 +1,9 @@ +import packageJson from "../../package.json" with { type: "json" }; + +// The CLI's wire identity. The client takes a userAgent from whoever calls it rather than inventing +// one, so the manifest that names this caller is read here and nowhere else. +export const USER_AGENT = `metabase-cli/${packageJson.version}`; + +// The name a Metabase persists for the OAuth client this CLI registers, shown wherever that +// instance lists its authorized applications. +export const OAUTH_CLIENT_NAME = "Metabase CLI"; diff --git a/src/main.ts b/packages/cli/src/main.ts similarity index 100% rename from src/main.ts rename to packages/cli/src/main.ts diff --git a/packages/cli/src/output/cap.test.ts b/packages/cli/src/output/cap.test.ts new file mode 100644 index 0000000..7fe296a --- /dev/null +++ b/packages/cli/src/output/cap.test.ts @@ -0,0 +1,233 @@ +import * as fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { capListEnvelope } from "./cap"; +import type { ListEnvelope } from "./types"; + +interface Item { + id: number; + name: string; +} + +function envelope(items: Item[]): ListEnvelope { + return { + data: items, + returned: items.length, + offset: 0, + total: items.length, + has_more: false, + next_offset: null, + }; +} + +function byteLength(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), "utf8"); +} + +describe("capListEnvelope", () => { + it("returns input unchanged when maxBytes is 0", () => { + const input = envelope([ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ]); + expect(capListEnvelope(input, 0)).toEqual(input); + }); + + it("returns input unchanged when fully under cap", () => { + const input = envelope([{ id: 1, name: "a" }]); + expect(capListEnvelope(input, 10_000)).toEqual(input); + }); + + it("drops tail items when over cap, never partial", () => { + const input = envelope([ + { id: 1, name: "a".repeat(100) }, + { id: 2, name: "b".repeat(100) }, + { id: 3, name: "c".repeat(100) }, + ]); + const result = capListEnvelope(input, 400); + expect( + result.data.every((item) => typeof item.name === "string" && item.name.length === 100), + ).toBe(true); + expect(result.returned).toBe(result.data.length); + expect(result.truncated).toEqual({ reason: "max_bytes", bytes: byteLength(input) }); + expect(byteLength(result)).toBeLessThanOrEqual(400); + }); + + // The rows are unreachable by offset — asking again at the same one returns the same answer — + // so the window reports that they exist and offers nothing to follow. + it("keeps no row when none fits, and offers no offset to resume from", () => { + const input: ListEnvelope = { + ...envelope([{ id: 1, name: "x".repeat(500) }]), + offset: 6, + }; + + expect(capListEnvelope(input, 50)).toEqual({ + data: [], + returned: 0, + offset: 6, + total: 1, + has_more: true, + next_offset: null, + truncated: { reason: "max_bytes", bytes: byteLength(input) }, + }); + }); + + it("leaves an empty window alone when the cap cannot hold even the bare envelope", () => { + const input = envelope([]); + + expect(capListEnvelope(input, 10)).toEqual(input); + }); + + it("makes the cut resumable: next_offset points at the first dropped item", () => { + const input: ListEnvelope = { + data: [ + { id: 1, name: "a".repeat(100) }, + { id: 2, name: "b".repeat(100) }, + { id: 3, name: "c".repeat(100) }, + ], + returned: 3, + offset: 40, + total: 500, + has_more: true, + next_offset: 43, + }; + + const result = capListEnvelope(input, 250); + + expect(result).toEqual({ + data: [{ id: 1, name: "a".repeat(100) }], + returned: 1, + offset: 40, + total: 500, + has_more: true, + next_offset: 41, + truncated: { reason: "max_bytes", bytes: byteLength(input) }, + }); + }); + + it("reports a continuation even when the source thought it was done", () => { + const input: ListEnvelope = { + data: [ + { id: 1, name: "a".repeat(100) }, + { id: 2, name: "b".repeat(100) }, + ], + returned: 2, + offset: 7, + total: 9, + has_more: false, + next_offset: null, + }; + + const result = capListEnvelope(input, 250); + + expect(result).toEqual({ + data: [{ id: 1, name: "a".repeat(100) }], + returned: 1, + offset: 7, + total: 9, + has_more: true, + next_offset: 8, + truncated: { reason: "max_bytes", bytes: byteLength(input) }, + }); + }); + + it("spends the envelope's own metadata out of the same budget as the rows", () => { + const items = Array.from({ length: 12 }, (_, index) => ({ id: index, name: `row-${index}` })); + + const result = capListEnvelope(envelope(items), 300); + + expect(result.returned).toBe(7); + expect(byteLength(result)).toBe(292); + }); + + it("preserves total/limit fields after truncation", () => { + const input: ListEnvelope = { + data: [ + { id: 1, name: "a".repeat(50) }, + { id: 2, name: "b".repeat(50) }, + { id: 3, name: "c".repeat(50) }, + ], + returned: 3, + offset: 0, + total: 17, + limit: 50, + has_more: true, + next_offset: 3, + }; + const result = capListEnvelope(input, 250); + expect(result.total).toBe(17); + expect(result.limit).toBe(50); + }); + + it("property: a cut that kept rows fits the cap and advances past them", () => { + fc.assert( + fc.property( + fc.array(fc.record({ id: fc.integer(), name: fc.string() }), { + minLength: 1, + maxLength: 30, + }), + fc.integer({ min: 30, max: 20_000 }), + (items, maxBytes) => { + const input = envelope(items); + const outcome = capListEnvelope(input, maxBytes); + if (byteLength(input) <= maxBytes) { + expect(outcome).toEqual(input); + return; + } + expect(outcome.returned).toBe(outcome.data.length); + expect(outcome.truncated?.bytes).toBe(byteLength(input)); + if (outcome.returned === 0) { + return; + } + expect(byteLength(outcome)).toBeLessThanOrEqual(maxBytes); + expect(outcome.next_offset).toBe(outcome.offset + outcome.returned); + }, + ), + ); + }); + + // The window a caller is sent back to must be a window they have not already read: an offset + // that repeats or skips is worse than no offset at all, which is what a zero-row cut carries. + it("property: a next_offset always advances, and a zero-row cut carries none", () => { + fc.assert( + fc.property( + fc.array(fc.record({ id: fc.integer(), name: fc.string() }), { + minLength: 1, + maxLength: 20, + }), + fc.integer({ min: 30, max: 1_000 }), + fc.integer({ min: 0, max: 500 }), + (items, maxBytes, offset) => { + const input: ListEnvelope = { ...envelope(items), offset }; + const outcome = capListEnvelope(input, maxBytes); + if (outcome.returned === 0 && outcome.truncated !== undefined) { + expect(outcome.has_more).toBe(true); + expect(outcome.next_offset).toBeNull(); + return; + } + if (typeof outcome.next_offset === "number") { + expect(outcome.next_offset).toBeGreaterThan(outcome.offset); + } + }, + ), + ); + }); + + it("property: truncated data is always a prefix of input data", () => { + fc.assert( + fc.property( + fc.array(fc.record({ id: fc.integer(), name: fc.string() }), { + minLength: 1, + maxLength: 20, + }), + fc.integer({ min: 30, max: 1_000 }), + (items, maxBytes) => { + const outcome = capListEnvelope(envelope(items), maxBytes); + for (let index = 0; index < outcome.data.length; index += 1) { + expect(outcome.data[index]).toEqual(items[index]); + } + }, + ), + ); + }); +}); diff --git a/packages/cli/src/output/cap.ts b/packages/cli/src/output/cap.ts new file mode 100644 index 0000000..0cf0f69 --- /dev/null +++ b/packages/cli/src/output/cap.ts @@ -0,0 +1,63 @@ +import type { ListEnvelope } from "./types"; + +// How much of a list the byte cap can carry. `count` is the number of leading rows that fit — +// zero when not even one does — and `fullBytes` is what the untruncated envelope measured, so a +// caller can report the size of the answer it could not show. +export interface CapFit { + count: number; + fullBytes: number; + cut: boolean; +} + +export function fitWithinCap(envelope: ListEnvelope, maxBytes: number): CapFit { + const fullBytes = jsonByteLength(envelope); + if (maxBytes <= 0 || fullBytes <= maxBytes) { + return { count: envelope.data.length, fullBytes, cut: false }; + } + + let lo = 0; + let hi = envelope.data.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (jsonByteLength(truncate(envelope, mid, fullBytes)) <= maxBytes) { + lo = mid; + } else { + hi = mid - 1; + } + } + return { count: lo, fullBytes, cut: true }; +} + +export function capListEnvelope(envelope: ListEnvelope, maxBytes: number): ListEnvelope { + const fit = fitWithinCap(envelope, maxBytes); + // A cut that drops no row is not a cut: an empty window is already the smallest answer this + // list can give, so marking it truncated would claim rows the walk never found. + if (!fit.cut || envelope.data.length === 0) { + return envelope; + } + return truncate(envelope, fit.count, fit.fullBytes); +} + +// Dropping rows here is what makes the cap resumable: `next_offset` is recomputed against the +// rows that survive, so the caller can ask for the remainder instead of losing it. A cut that +// keeps no row has no resumption point to recompute — `offset + 0` is the offset the caller just +// sent, and an agent following it would ask the same question forever — so it reports the rows +// that remain without one. Reaching them takes a wider cap or a narrower selection. +function truncate( + envelope: ListEnvelope, + count: number, + originalBytes: number, +): ListEnvelope { + return { + ...envelope, + data: envelope.data.slice(0, count), + returned: count, + has_more: true, + next_offset: count === 0 ? null : envelope.offset + count, + truncated: { reason: "max_bytes", bytes: originalBytes }, + }; +} + +function jsonByteLength(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), "utf8"); +} diff --git a/packages/cli/src/output/error.test.ts b/packages/cli/src/output/error.test.ts new file mode 100644 index 0000000..acdf17d --- /dev/null +++ b/packages/cli/src/output/error.test.ts @@ -0,0 +1,377 @@ +import { PassThrough } from "node:stream"; + +import { TextPrompt } from "@clack/core"; +import { afterEach, assert, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { + AbortError, + ConfigError, + type ErrorCategory, + ResponseShapeError, + UnknownError, + ValidationError, +} from "@metabase/client/errors"; +import { HttpError } from "@metabase/client/http/errors"; +import { checkCapabilities } from "@metabase/client/version/capabilities"; +import { CapabilityError } from "@metabase/client/version/preflight-error"; +import { exitCodeFor, reportError } from "./error"; + +interface CapturedStreams { + stderr: string; +} + +let streams: CapturedStreams; +const originalExitCode = process.exitCode; +const originalVerbose = process.env["MB_VERBOSE"]; + +beforeEach(() => { + streams = { stderr: "" }; + process.stderr.isTTY = false; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + streams.stderr += String(chunk); + return true; + }); + process.exitCode = 0; + delete process.env["MB_VERBOSE"]; + delete process.env["METABASE_VERBOSE"]; +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = originalExitCode; + if (originalVerbose === undefined) { + delete process.env["MB_VERBOSE"]; + } else { + process.env["MB_VERBOSE"] = originalVerbose; + } +}); + +const CTRL_C_BYTE = 0x03; + +// clack's cancel value is a module-private `Symbol("clack:cancel")`, so the only way to obtain a +// genuine one is to drive a real prompt to cancellation. +async function realClackCancelValue(): Promise { + const input = new PassThrough(); + const prompt = new TextPrompt({ input, output: new PassThrough(), render: () => "" }); + const pending = prompt.prompt(); + input.write(Buffer.from([CTRL_C_BYTE])); + const value = await pending; + assert(typeof value === "symbol", "expected clack to resolve with its cancel symbol"); + return value; +} + +const CATEGORIES: ReadonlyArray = [ + "network", + "http", + "validation", + "response-shape", + "timeout", + "config", + "capability", + "abort", + "internal", + "unknown", +]; + +// Typed as a total record so a category added to the taxonomy fails to compile here as well as +// in the switch it pins. +const EXPECTED_EXIT_CODES: Record = { + network: 1, + http: 1, + validation: 1, + "response-shape": 1, + timeout: 1, + config: 2, + capability: 2, + abort: 130, + internal: 1, + unknown: 1, +}; + +describe("exitCodeFor", () => { + it("maps every category in the error taxonomy to its exit code", () => { + const actual = Object.fromEntries( + CATEGORIES.map((category) => [category, exitCodeFor(category)]), + ); + expect(actual).toEqual(EXPECTED_EXIT_CODES); + }); +}); + +describe("reportError", () => { + it("writes the user message to stderr and sets exit code 2 for ConfigError", () => { + reportError(new ConfigError("missing TTY")); + expect(streams.stderr).toBe("missing TTY\n"); + expect(process.exitCode).toBe(2); + }); + + it("sets exit code 130 for AbortError", () => { + reportError(new AbortError()); + expect(streams.stderr).toBe("aborted\n"); + expect(process.exitCode).toBe(130); + }); + + it("maps a raw clack cancel value to the abort category and exit code 130", async () => { + reportError(await realClackCancelValue(), "json"); + const expected = + JSON.stringify({ + ok: false, + error: { category: "abort", message: "aborted", exitCode: 130 }, + }) + "\n"; + expect(streams.stderr).toBe(expected); + expect(process.exitCode).toBe(130); + }); + + it("sets exit code 1 for UnknownError wrapping a generic Error", () => { + reportError(new Error("kaboom")); + expect(streams.stderr).toBe("kaboom\n(rerun with MB_VERBOSE=1 for details)\n"); + expect(process.exitCode).toBe(1); + }); + + it("appends the verbose breadcrumb (not the detail) when MB_VERBOSE is unset and detail exists", () => { + reportError(new UnknownError({ originalMessage: "boom", stack: "trace" })); + expect(streams.stderr).toBe("boom\n(rerun with MB_VERBOSE=1 for details)\n"); + }); + + it("appends developerDetail JSON when MB_VERBOSE=1 and the error carries detail", () => { + process.env["MB_VERBOSE"] = "1"; + reportError(new UnknownError({ originalMessage: "boom", stack: "trace" })); + expect(streams.stderr).toBe( + "boom\n" + JSON.stringify({ originalMessage: "boom", stack: "trace" }) + "\n", + ); + }); + + it("offers the client downgrade when the server is below the required version", () => { + const failure = checkCapabilities( + { version: { tag: "v0.58.0", major: 58, patch: 0 }, tokenFeatures: null }, + { minVersion: 61 }, + ); + assert(failure !== null); + reportError(new CapabilityError(failure)); + expect(streams.stderr).toBe( + "This operation requires Metabase v61+ (this server is v0.58.0). Upgrade Metabase to use it.\n" + + "Or install an `@metabase/cli` release that targets this server.\n" + + "(rerun with MB_VERBOSE=1 for details)\n", + ); + expect(process.exitCode).toBe(2); + }); + + it("withholds the client downgrade when a premium feature is missing, which no client version supplies", () => { + const failure = checkCapabilities( + { version: { tag: "v0.61.0", major: 61, patch: 0 }, tokenFeatures: null }, + { minVersion: 61, tokenFeature: "library" }, + ); + assert(failure !== null); + reportError(new CapabilityError(failure)); + expect(streams.stderr).toBe( + "This operation requires the 'library' premium feature (not enabled on this server).\n" + + "(rerun with MB_VERBOSE=1 for details)\n", + ); + }); + + it("carries the client downgrade into the JSON envelope, where there is no second line to print it on", () => { + const failure = checkCapabilities( + { version: { tag: "v0.58.0", major: 58, patch: 0 }, tokenFeatures: null }, + { minVersion: 61 }, + ); + assert(failure !== null); + reportError(new CapabilityError(failure), "json"); + expect(streams.stderr).toBe( + JSON.stringify({ + ok: false, + error: { + category: "capability", + message: + "This operation requires Metabase v61+ (this server is v0.58.0). Upgrade Metabase to use it.\n" + + "Or install an `@metabase/cli` release that targets this server.", + exitCode: 2, + }, + }) + "\n", + ); + }); + + function routeMissingError(): HttpError { + return new HttpError({ + status: 404, + statusText: "Not Found", + method: "GET", + url: "https://m.example.com/api/transform", + responseHeaders: { "content-type": "text/plain" }, + rawBody: "API endpoint does not exist.", + serverTag: "v0.58.7", + }); + } + + it("names the command that shows the server version when an endpoint is missing from it", () => { + reportError(routeMissingError()); + expect(streams.stderr).toBe( + "This endpoint is not available on Metabase v0.58.7: GET /api/transform. " + + "It may require a newer Metabase major version.\n" + + "Run `mb auth list` to see this server's version.\n" + + "(rerun with MB_VERBOSE=1 for details)\n", + ); + expect(process.exitCode).toBe(1); + }); + + it("carries the route-missing remedy into the JSON envelope", () => { + reportError(routeMissingError(), "json"); + expect(streams.stderr).toBe( + JSON.stringify({ + ok: false, + error: { + category: "http", + message: + "This endpoint is not available on Metabase v0.58.7: GET /api/transform. " + + "It may require a newer Metabase major version.\n" + + "Run `mb auth list` to see this server's version.", + exitCode: 1, + }, + }) + "\n", + ); + }); + + it("leaves an ordinary HTTP failure without a remedy line", () => { + reportError( + new HttpError({ + status: 500, + statusText: "Internal Server Error", + method: "GET", + url: "https://m.example.com/api/transform", + responseHeaders: { "content-type": "text/plain" }, + rawBody: null, + }), + ); + expect(streams.stderr).toBe("Metabase returned 500.\n(rerun with MB_VERBOSE=1 for details)\n"); + }); + + it("carries the rejected fields into the JSON error envelope under MB_VERBOSE=1", () => { + process.env["MB_VERBOSE"] = "1"; + const rawBody = JSON.stringify({ errors: { name: "value must be a non-blank string." } }); + reportError( + new HttpError({ + status: 400, + statusText: "Bad Request", + method: "POST", + url: "https://m.example.com/api/card", + responseHeaders: { "content-type": "application/json" }, + rawBody, + }), + "json", + ); + const expected = + JSON.stringify({ + ok: false, + error: { + category: "http", + message: "name: value must be a non-blank string.", + exitCode: 1, + detail: { + status: 400, + statusText: "Bad Request", + method: "POST", + url: "https://m.example.com/api/card", + responseHeaders: { "content-type": "application/json" }, + body: rawBody, + fieldErrors: { name: "value must be a non-blank string." }, + specificFieldErrors: null, + }, + }, + }) + "\n"; + expect(streams.stderr).toBe(expected); + }); + + it("does not append developerDetail JSON when MB_VERBOSE=1 but detail is null", () => { + process.env["MB_VERBOSE"] = "1"; + reportError(new ConfigError("nope")); + expect(streams.stderr).toBe("nope\n"); + expect(process.exitCode).toBe(2); + }); + + it("normalizes a non-MetabaseError value (string) into an UnknownError envelope", () => { + reportError("plain string"); + expect(streams.stderr).toBe("plain string\n(rerun with MB_VERBOSE=1 for details)\n"); + expect(process.exitCode).toBe(1); + }); + + it("prints the JSON-pointer issue path on the stderr line beneath the ValidationError header", () => { + const schema = z.object({ total: z.number() }); + const result = schema.safeParse({ total: null }); + assert(!result.success, "expected zod failure"); + reportError( + new ValidationError( + "https://m.example.com/api/collection/8/items: value did not match expected schema", + { + source: "https://m.example.com/api/collection/8/items", + zodIssues: result.error.issues, + }, + ), + ); + expect(streams.stderr).toBe( + "https://m.example.com/api/collection/8/items: value did not match expected schema\n" + + " /total: Invalid input: expected number, received null\n" + + "(rerun with MB_VERBOSE=1 for details)\n", + ); + expect(process.exitCode).toBe(1); + }); + + it("prints the ResponseShapeError lead and field paths and exits 1", () => { + const schema = z.object({ version: z.object({ tag: z.string() }) }); + const result = schema.safeParse({ version: {} }); + assert(!result.success, "expected zod failure"); + const error = ResponseShapeError.fromZodIssues({ + kind: "zod", + method: "GET", + url: "https://m.example.com/api/session/properties", + status: 200, + zodIssues: result.error.issues, + serverTag: null, + }); + + reportError(error); + + expect(streams.stderr).toBe( + "Metabase returned unexpected response shape:\n" + + " version.tag: Invalid input: expected string, received undefined\n" + + "(rerun with MB_VERBOSE=1 for details)\n", + ); + expect(process.exitCode).toBe(1); + }); + + it("emits a JSON error envelope to stderr (no detail) when format is json", () => { + reportError(new ConfigError("missing TTY"), "json"); + const expected = + JSON.stringify({ + ok: false, + error: { category: "config", message: "missing TTY", exitCode: 2 }, + }) + "\n"; + expect(streams.stderr).toBe(expected); + expect(process.exitCode).toBe(2); + }); + + it("omits detail from the JSON error envelope when MB_VERBOSE is unset", () => { + reportError(new UnknownError({ originalMessage: "boom", stack: "trace" }), "json"); + const expected = + JSON.stringify({ + ok: false, + error: { category: "unknown", message: "boom", exitCode: 1 }, + }) + "\n"; + expect(streams.stderr).toBe(expected); + expect(process.exitCode).toBe(1); + }); + + it("includes detail in the JSON error envelope when MB_VERBOSE=1", () => { + process.env["MB_VERBOSE"] = "1"; + reportError(new UnknownError({ originalMessage: "boom", stack: "trace" }), "json"); + const expected = + JSON.stringify({ + ok: false, + error: { + category: "unknown", + message: "boom", + exitCode: 1, + detail: { originalMessage: "boom", stack: "trace" }, + }, + }) + "\n"; + expect(streams.stderr).toBe(expected); + }); +}); diff --git a/packages/cli/src/output/error.ts b/packages/cli/src/output/error.ts new file mode 100644 index 0000000..d4a9d3c --- /dev/null +++ b/packages/cli/src/output/error.ts @@ -0,0 +1,134 @@ +import { AbortError, toMetabaseError } from "@metabase/client/errors"; +import type { ErrorCategory, MetabaseError } from "@metabase/client/errors"; +import type { PreflightFailure } from "@metabase/client/version/capabilities"; + +import { consumeLegacyEnvWarnings, ENV_VERBOSE, readEnv } from "../core/env"; +import { warn } from "./notice"; +import { isPromptCancel } from "./prompt"; +import { serializeJson } from "./render"; +import type { Format } from "./types"; + +const VERBOSE_BREADCRUMB = "(rerun with MB_VERBOSE=1 for details)"; + +// The client states the version floor; naming a release that clears it is the CLI's own business. +const DOWNGRADE_REMEDY = "Or install an `@metabase/cli` release that targets this server."; + +// The client reports which server the endpoint is missing from but may not name a command to inspect +// it with, so the CLI supplies the one that prints the version it just quoted. +const ROUTE_MISSING_REMEDY = "Run `mb auth list` to see this server's version."; + +const ROUTE_MISSING_KIND = "route-missing"; + +const FAILURE_EXIT_CODE = 1; +const USAGE_EXIT_CODE = 2; +const INTERRUPTED_EXIT_CODE = 130; + +// A process exit code is the CLI's policy, so the client's taxonomy carries none. The switch is total +// over the discriminant and has no `default`: a category added to the taxonomy stops compiling +// here until someone decides what it should exit with. +export function exitCodeFor(category: ErrorCategory): number { + switch (category) { + case "config": + case "capability": { + return USAGE_EXIT_CODE; + } + case "abort": { + return INTERRUPTED_EXIT_CODE; + } + case "network": + case "http": + case "validation": + case "response-shape": + case "timeout": + case "internal": + case "unknown": { + return FAILURE_EXIT_CODE; + } + } +} + +interface JsonErrorPayload { + category: ErrorCategory; + message: string; + exitCode: number; + detail?: unknown; +} + +interface JsonErrorEnvelope { + ok: false; + error: JsonErrorPayload; +} + +export function reportError(error: unknown, format?: Format): void { + const handled = isPromptCancel(error) ? new AbortError() : toMetabaseError(error); + const verbose = readEnv(ENV_VERBOSE) === "1"; + const exitCode = exitCodeFor(handled.category); + if (format === "json") { + writeJsonError(handled, exitCode, verbose); + } else { + writeTextError(handled, verbose); + } + for (const message of consumeLegacyEnvWarnings()) { + warn(message); + } + process.exitCode = exitCode; +} + +function isVersionTooOld(detail: unknown): detail is PreflightFailure { + return ( + typeof detail === "object" && + detail !== null && + "reason" in detail && + detail.reason === "version-too-old" + ); +} + +// `kind` belongs to `HttpError` alone, which `src/output/` may not import; no other error in the +// taxonomy carries the property, so reading it off the value is unambiguous. +function isRouteMissing(handled: MetabaseError): boolean { + return "kind" in handled && handled.kind === ROUTE_MISSING_KIND; +} + +// What the CLI adds to a message the client had to phrase without knowing who would print it. Both +// output formats carry them, so an agent reading `--json` gets the same remediation a human does. +function remediesFor(handled: MetabaseError): readonly string[] { + if (isVersionTooOld(handled.developerDetail)) { + return [DOWNGRADE_REMEDY]; + } + if (isRouteMissing(handled)) { + return [ROUTE_MISSING_REMEDY]; + } + return []; +} + +function writeTextError(handled: MetabaseError, verbose: boolean): void { + process.stderr.write(handled.userMessage + "\n"); + for (const remedy of remediesFor(handled)) { + process.stderr.write(remedy + "\n"); + } + if (handled.developerDetail === null) { + return; + } + if (verbose) { + process.stderr.write(serializeJson(handled.developerDetail, stderrPretty()) + "\n"); + } else { + process.stderr.write(VERBOSE_BREADCRUMB + "\n"); + } +} + +function stderrPretty(): boolean { + return process.stderr.isTTY === true; +} + +function writeJsonError(handled: MetabaseError, exitCode: number, verbose: boolean): void { + const payload: JsonErrorPayload = { + category: handled.category, + message: [handled.userMessage, ...remediesFor(handled)].join("\n"), + exitCode, + }; + if (verbose && handled.developerDetail !== null) { + payload.detail = handled.developerDetail; + } + const envelope: JsonErrorEnvelope = { ok: false, error: payload }; + process.stderr.write(serializeJson(envelope, stderrPretty()) + "\n"); +} diff --git a/src/output/format.test.ts b/packages/cli/src/output/format.test.ts similarity index 96% rename from src/output/format.test.ts rename to packages/cli/src/output/format.test.ts index d6f6497..ef26ab7 100644 --- a/src/output/format.test.ts +++ b/packages/cli/src/output/format.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; import { resolveFormat } from "./format"; describe("resolveFormat", () => { diff --git a/src/output/format.ts b/packages/cli/src/output/format.ts similarity index 89% rename from src/output/format.ts rename to packages/cli/src/output/format.ts index 03a7ee0..2d259bb 100644 --- a/src/output/format.ts +++ b/packages/cli/src/output/format.ts @@ -1,7 +1,7 @@ -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; import type { Format } from "./types"; -export interface FormatInputs { +interface FormatInputs { json: boolean | undefined; format: string | undefined; isTty: boolean; diff --git a/src/output/help.test.ts b/packages/cli/src/output/help.test.ts similarity index 94% rename from src/output/help.test.ts rename to packages/cli/src/output/help.test.ts index 3062bf5..6c979ef 100644 --- a/src/output/help.test.ts +++ b/packages/cli/src/output/help.test.ts @@ -3,13 +3,14 @@ import type { ArgsDef, CommandDef } from "citty"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; +import { parseJson } from "@metabase/client/json"; +import { BASELINE_CAPABILITIES } from "@metabase/client/version/capabilities"; + import { connectionFlags, outputFlags, profileFlag } from "../commands/flags"; +import { defineCommandGroup } from "../commands/group"; import { defineMetabaseCommand } from "../commands/runtime"; -import { BASELINE_CAPABILITIES } from "../runtime/capabilities"; import { setMetabaseAugment } from "../runtime/command-augment"; import { CommandHelpEntry, CommandHelpIndex } from "../runtime/command-help"; -import { parseJson } from "../runtime/json"; - import { findUnknownCommand, resolveBreadcrumb, showUsage, showUsageJson } from "./help"; describe("showUsage", () => { @@ -63,6 +64,7 @@ describe("showUsage", () => { it("appends an EXAMPLES section when defineMetabaseCommand declares examples", async () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo cmd" }, + capabilities: {}, args: {}, examples: ["mb demo --json", "mb demo --profile staging"], outputSchema: z.object({ ok: z.boolean() }), @@ -81,6 +83,7 @@ describe("showUsage", () => { it("omits the EXAMPLES section when no examples are declared", async () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo cmd" }, + capabilities: {}, args: {}, run() { return; @@ -95,6 +98,7 @@ describe("showUsage", () => { it("renders the details block after the description and before USAGE when declared", async () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "Short summary" }, + capabilities: {}, args: {}, details: "Longer per-command knowledge shown only on this page.", run() { @@ -115,6 +119,7 @@ describe("showUsage", () => { it("omits the details block when none is declared", async () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "Short summary" }, + capabilities: {}, args: {}, run() { return; @@ -163,6 +168,7 @@ describe("showUsage", () => { it("appends a machine-readable help hint pointing at --help --json on a leaf page", async () => { const cmd = defineMetabaseCommand({ meta: { name: "bar", description: "demo bar" }, + capabilities: {}, args: {}, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -194,6 +200,7 @@ describe("showUsage", () => { it("separates the EXAMPLES footer from the body with a blank line", async () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo cmd" }, + capabilities: {}, args: {}, examples: ["mb demo --json"], outputSchema: z.object({ ok: z.boolean() }), @@ -210,6 +217,7 @@ describe("showUsage", () => { it("renders multi-character flag aliases as a single working --kebab form", async () => { const cmd = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, + capabilities: {}, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -232,6 +240,7 @@ describe("showUsage", () => { it("lists -h, --help in the OPTIONS block", async () => { const cmd = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, + capabilities: {}, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -248,6 +257,7 @@ describe("showUsage", () => { it("prepends the full breadcrumb to a leaf USAGE line", async () => { const cmd = defineMetabaseCommand({ meta: { name: "bar", description: "demo bar" }, + capabilities: {}, args: { ...outputFlags }, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -281,6 +291,7 @@ describe("showUsage", () => { it("renders an AGENT SKILLS section from declared skill pointers, below the body", async () => { const cmd = defineMetabaseCommand({ meta: { name: "create", description: "demo create" }, + capabilities: {}, args: {}, skills: [ { skill: "mbql", purpose: "author the dataset_query" }, @@ -302,6 +313,7 @@ describe("showUsage", () => { it("omits the AGENT SKILLS section when no skill pointers are declared", async () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo cmd" }, + capabilities: {}, args: {}, run() { return; @@ -332,6 +344,7 @@ describe("showUsage", () => { }); const leaf = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, + capabilities: {}, args: {}, skills: [{ skill: "core", purpose: "auth and conventions" }], run() { @@ -362,6 +375,7 @@ describe("showUsage", () => { }); const leaf = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, + capabilities: {}, args: {}, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -400,6 +414,7 @@ describe("showUsageJson", () => { it("emits the full entry for a leaf command", async () => { const leaf = defineMetabaseCommand({ meta: { name: "bar", description: "demo bar" }, + capabilities: {}, args: {}, outputSchema: z.object({ ok: z.boolean() }), examples: ["mb foo bar --json"], @@ -429,9 +444,10 @@ describe("showUsageJson", () => { }); }); - it("emits a full-path index for a command group", async () => { + it("emits the group's own description and skills alongside a full-path index", async () => { const bar = defineMetabaseCommand({ meta: { name: "bar", description: "demo bar" }, + capabilities: {}, args: {}, run() { return; @@ -439,13 +455,16 @@ describe("showUsageJson", () => { }); const baz = defineMetabaseCommand({ meta: { name: "baz", description: "demo baz" }, + capabilities: {}, args: {}, run() { return; }, }); - const group = defineCommand({ - meta: { name: "foo", description: "demo group" }, + const group = defineCommandGroup({ + name: "foo", + description: "demo group", + skills: [{ skill: "mbql", purpose: "author the dataset_query" }], subCommands: { bar: () => Promise.resolve(bar), baz: () => Promise.resolve(baz), @@ -456,6 +475,8 @@ describe("showUsageJson", () => { const index = parseJson(chunks.join(""), CommandHelpIndex, { source: "help json" }); expect(index).toEqual({ + description: "demo group", + skills: [{ skill: "mbql", purpose: "author the dataset_query" }], commands: [ { command: "foo bar", description: "demo bar" }, { command: "foo baz", description: "demo baz" }, diff --git a/src/output/help.ts b/packages/cli/src/output/help.ts similarity index 98% rename from src/output/help.ts rename to packages/cli/src/output/help.ts index 4a1a725..0e13c40 100644 --- a/src/output/help.ts +++ b/packages/cli/src/output/help.ts @@ -195,10 +195,7 @@ function optionLayout(row: string): OptionLayout | null { return { flagEnd, descStart: flagEnd + gapRemainder }; } -export function renderSkillsSection( - skills: readonly SkillPointer[], - includeListPointer: boolean, -): string { +function renderSkillsSection(skills: readonly SkillPointer[], includeListPointer: boolean): string { const items = skills.map((p) => `${LINE_PREFIX}mb skills get ${p.skill} — ${p.purpose}`); if (includeListPointer) { items.push(`${LINE_PREFIX}${SKILLS_LIST_ITEM}`); diff --git a/src/output/notice.test.ts b/packages/cli/src/output/notice.test.ts similarity index 74% rename from src/output/notice.test.ts rename to packages/cli/src/output/notice.test.ts index 915e36b..f78f244 100644 --- a/src/output/notice.test.ts +++ b/packages/cli/src/output/notice.test.ts @@ -29,6 +29,18 @@ describe("listTruncationNotice", () => { "… cut at 2048 bytes; narrow the selection or raise --max-bytes", ); }); + + it("names the offset to resume from when the cut left one", () => { + expect(listTruncationNotice(2048, 12)).toBe( + "… cut at 2048 bytes; continue with --offset 12, narrow the selection or raise --max-bytes", + ); + }); + + it("replaces the generic remedy with the command-specific hint when one is supplied", () => { + expect( + listTruncationNotice(2048, null, "request one skill at a time with `mb skills get`"), + ).toBe("… cut at 2048 bytes; request one skill at a time with `mb skills get`"); + }); }); describe("itemOversizeMessage", () => { diff --git a/packages/cli/src/output/notice.ts b/packages/cli/src/output/notice.ts new file mode 100644 index 0000000..b28fefb --- /dev/null +++ b/packages/cli/src/output/notice.ts @@ -0,0 +1,23 @@ +export function warn(message: string): void { + process.stderr.write(message + "\n"); +} + +const LIST_TRUNCATION_REMEDY = "narrow the selection or raise --max-bytes"; + +// A cut the cap could not make resumable still has a way out, and on the commands that know a +// better one than the generic advice — a whole-row read like `mb skills get` — that is the remedy +// worth printing. +export function listTruncationNotice( + bytes: number, + nextOffset?: number | null, + hint?: string, +): string { + const resume = typeof nextOffset === "number" ? `continue with --offset ${nextOffset}, ` : ""; + return `… cut at ${bytes} bytes; ${resume}${hint ?? LIST_TRUNCATION_REMEDY}`; +} + +const ITEM_OVERSIZE_REMEDY = "narrow with --fields or raise the cap with --max-bytes "; + +export function itemOversizeMessage(bytes: number, maxBytes: number, hint?: string): string { + return `output is ${bytes} bytes, over the ${maxBytes}-byte --max-bytes cap; ${hint ?? ITEM_OVERSIZE_REMEDY}`; +} diff --git a/src/output/projection.test.ts b/packages/cli/src/output/projection.test.ts similarity index 98% rename from src/output/projection.test.ts rename to packages/cli/src/output/projection.test.ts index b817ac5..6aa7706 100644 --- a/src/output/projection.test.ts +++ b/packages/cli/src/output/projection.test.ts @@ -2,8 +2,8 @@ import * as fc from "fast-check"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { ConfigError } from "../core/errors"; -import type { ResourceView } from "../domain/view"; +import { ConfigError } from "@metabase/client/errors"; +import type { ResourceView } from "./view"; import { applyProjection } from "./projection"; const Card = z.object({ diff --git a/packages/cli/src/output/projection.ts b/packages/cli/src/output/projection.ts new file mode 100644 index 0000000..dd722bd --- /dev/null +++ b/packages/cli/src/output/projection.ts @@ -0,0 +1,111 @@ +import { ConfigError } from "@metabase/client/errors"; +import { isPlainObject } from "@metabase/client/predicates"; + +import type { RenderOptions } from "./types"; +import type { ResourceView } from "./view"; + +// The single answer to "what does a list emit per item under these options", shared by the +// renderer and by the byte budget that decides how many items to fetch and keep. The default +// text table renders `tableColumns`, which `--full` does not widen, so its projection is the +// compact one; every other path projects what the flags asked for. Routing all of them through +// here is what keeps a text list and its `--json` equivalent cutting at the same row. +export function projectForList(item: T, view: ResourceView, opts: RenderOptions): unknown { + const rendersTableColumns = opts.format === "text" && opts.fields === undefined; + const full = rendersTableColumns ? false : opts.full; + try { + return applyProjection(item, view, full, opts.fields); + } catch (error) { + throw enrichListFieldPathError(error, opts.fields); + } +} + +// List projections are item-relative: each path is resolved against an element of `data`, not the +// envelope. Users who write the path they see in the JSON (`data.id`) hit a dead-end "unknown +// field path" error, so point them at the item-relative form. +function enrichListFieldPathError(error: unknown, fields: string[] | undefined): unknown { + if ( + fields === undefined || + !(error instanceof ConfigError) || + !error.message.startsWith("unknown field path") + ) { + return error; + } + const prefix = "data."; + const culprit = fields.find((field) => field === "data" || field.startsWith(prefix)); + if (culprit === undefined) { + return error; + } + const suggestion = culprit.startsWith(prefix) ? culprit.slice(prefix.length) : ""; + return new ConfigError( + `${error.message} — on list commands --fields paths are relative to each item in \`data\`, not the envelope. Drop the \`data.\` prefix (e.g. use \`${suggestion}\` instead of \`${culprit}\`).`, + ); +} + +export function applyProjection( + value: T, + view: ResourceView, + full: boolean, + fields: string[] | undefined, +): unknown { + if (fields !== undefined) { + if (fields.length === 0) { + throw new ConfigError("--fields requires at least one path"); + } + return projectFields(value, fields); + } + if (full) { + return value; + } + const parsed = view.compactPick.safeParse(value); + if (parsed.success) { + return parsed.data; + } + throw new ConfigError(`compact projection failed: ${parsed.error.message}`); +} + +function projectFields(value: unknown, fields: string[]): Record { + const out: Record = {}; + for (const path of fields) { + if (path.length === 0) { + throw new ConfigError(`empty field path`); + } + const parts = path.split("."); + if (parts.some((part) => part.length === 0)) { + throw new ConfigError(`invalid field path: "${path}"`); + } + setPath(out, parts, pickPath(value, parts)); + } + return out; +} + +export function pickPath(value: unknown, parts: string[]): unknown { + let cursor: unknown = value; + for (const part of parts) { + if (!isPlainObject(cursor) || !Object.hasOwn(cursor, part)) { + throw new ConfigError(`unknown field path: "${parts.join(".")}"`); + } + cursor = Reflect.get(cursor, part); + } + return cursor; +} + +function setPath(target: Record, parts: string[], value: unknown): void { + let cursor = target; + const lastIndex = parts.length - 1; + for (const [index, part] of parts.entries()) { + if (index === lastIndex) { + cursor[part] = value; + return; + } + const existing = cursor[part]; + if (isPlainObject(existing)) { + cursor = existing; + } else { + const next: Record = {}; + cursor[part] = next; + cursor = next; + } + } +} + +export { isPlainObject }; diff --git a/src/output/prompt.test.ts b/packages/cli/src/output/prompt.test.ts similarity index 98% rename from src/output/prompt.test.ts rename to packages/cli/src/output/prompt.test.ts index 80ea97f..1e022b3 100644 --- a/src/output/prompt.test.ts +++ b/packages/cli/src/output/prompt.test.ts @@ -1,6 +1,6 @@ import { afterEach, assert, beforeEach, describe, expect, it, vi } from "vitest"; -import { AbortError, ConfigError } from "../core/errors"; +import { AbortError, ConfigError } from "@metabase/client/errors"; const hoisted = vi.hoisted(() => ({ text: vi.fn<(opts: unknown) => Promise>(), diff --git a/src/output/prompt.ts b/packages/cli/src/output/prompt.ts similarity index 86% rename from src/output/prompt.ts rename to packages/cli/src/output/prompt.ts index d66a9ba..26bbea7 100644 --- a/src/output/prompt.ts +++ b/packages/cli/src/output/prompt.ts @@ -7,11 +7,11 @@ import { type SelectOptions, } from "@clack/prompts"; -import { AbortError, ConfigError } from "../core/errors"; +import { AbortError, ConfigError } from "@metabase/client/errors"; type Validator = (value: string) => string | undefined; -export interface TextPromptOptions { +interface TextPromptOptions { message: string; placeholder?: string; initialValue?: string; @@ -19,25 +19,29 @@ export interface TextPromptOptions { validate?: Validator; } -export interface PasswordPromptOptions { +interface PasswordPromptOptions { message: string; mask?: string; validate?: Validator; } -export interface ConfirmPromptOptions { +interface ConfirmPromptOptions { message: string; initialValue?: boolean; } -export type SelectChoice = SelectOptions["options"][number]; +type SelectChoice = SelectOptions["options"][number]; -export interface SelectPromptOptions { +interface SelectPromptOptions { message: string; choices: SelectChoice[]; initialValue?: Value; } +export function isPromptCancel(value: unknown): boolean { + return isCancel(value); +} + export async function promptText(opts: TextPromptOptions): Promise { requireTty(opts.message); const value = await clackText({ diff --git a/src/output/query-result.test.ts b/packages/cli/src/output/query-result.test.ts similarity index 97% rename from src/output/query-result.test.ts rename to packages/cli/src/output/query-result.test.ts index 9f53d9e..1c274da 100644 --- a/src/output/query-result.test.ts +++ b/packages/cli/src/output/query-result.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { CardQueryResult } from "../domain/card"; +import { CardQueryResult } from "@metabase/client/domain/card"; import { formatQueryResult } from "./query-result"; diff --git a/src/output/query-result.ts b/packages/cli/src/output/query-result.ts similarity index 92% rename from src/output/query-result.ts rename to packages/cli/src/output/query-result.ts index e8bfe65..2af08ae 100644 --- a/src/output/query-result.ts +++ b/packages/cli/src/output/query-result.ts @@ -1,4 +1,4 @@ -import type { CardQueryResult } from "../domain/card"; +import type { CardQueryResult } from "@metabase/client/domain/card"; import { formatScalar, renderRows } from "./table"; diff --git a/src/output/render.test.ts b/packages/cli/src/output/render.test.ts similarity index 70% rename from src/output/render.test.ts rename to packages/cli/src/output/render.test.ts index ef20360..944c702 100644 --- a/src/output/render.test.ts +++ b/packages/cli/src/output/render.test.ts @@ -1,12 +1,21 @@ import { afterEach, assert, beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { ConfigError } from "../core/errors"; -import type { ResourceView } from "../domain/view"; -import { parseJson } from "../runtime/json"; +import { ConfigError } from "@metabase/client/errors"; +import { parseJson } from "@metabase/client/json"; + import { capListEnvelope } from "./cap"; import { renderSummary, renderItem, renderList, writeJson, writeText } from "./render"; -import { DEFAULT_MAX_BYTES, type ListEnvelope, type RenderOptions } from "./types"; +import { renderTable } from "./table"; +import { + DEFAULT_MAX_BYTES, + FULL_RANGE, + listEnvelopeSchema, + type ListEnvelope, + type RenderOptions, +} from "./types"; +import type { ResourceView } from "./view"; +import { windowList } from "./window"; const Card = z.object({ id: z.number().int(), @@ -15,6 +24,12 @@ const Card = z.object({ }); type Card = z.infer; +// What a `.loose()` domain schema hands the renderer: everything the view shows plus everything it +// does not. +interface RawCard extends Card { + dataset_query: string; +} + const cardView: ResourceView = { compactPick: Card.pick({ id: true, name: true }), tableColumns: [ @@ -30,25 +45,11 @@ const baseOpts: RenderOptions = { maxBytes: DEFAULT_MAX_BYTES, }; -const TruncatedEnvelope = z.object({ - data: z.array(z.object({ id: z.number(), name: z.string() })), - returned: z.number(), - total: z.number(), - truncated: z.object({ reason: z.literal("max_bytes"), bytes: z.number() }), -}); - const CardCompact = Card.pick({ id: true, name: true }); const CardProjected = Card.pick({ id: true, archived: true }); -const CardListEnvelope = z.object({ - data: z.array(CardCompact), - returned: z.number(), - total: z.number(), -}); -const CardProjectedListEnvelope = z.object({ - data: z.array(CardProjected), - returned: z.number(), - total: z.number(), -}); +const TruncatedEnvelope = listEnvelopeSchema(CardCompact); +const CardListEnvelope = listEnvelopeSchema(CardCompact); +const CardProjectedListEnvelope = listEnvelopeSchema(CardProjected); interface Streams { stdout: string; @@ -139,7 +140,6 @@ describe("renderItem", () => { expect(error.message).toBe( `output is ${expectedBytes} bytes, over the 50-byte --max-bytes cap; narrow with --fields or raise the cap with --max-bytes `, ); - expect(error.exitCode).toBe(2); expect(streams.stdout).toBe(""); }); @@ -231,14 +231,13 @@ describe("renderSummary", () => { describe("renderList — JSON format", () => { it("emits a list envelope with compact items", () => { - const envelope: ListEnvelope = { - data: [ + const envelope = windowList( + [ { id: 1, name: "Sales", archived: false }, { id: 2, name: "Ops", archived: true }, ], - returned: 2, - total: 2, - }; + FULL_RANGE, + ); renderList(envelope, cardView, baseOpts); expect(parseJson(streams.stdout, CardListEnvelope)).toEqual({ data: [ @@ -246,16 +245,22 @@ describe("renderList — JSON format", () => { { id: 2, name: "Ops" }, ], returned: 2, + offset: 0, total: 2, + has_more: false, + next_offset: null, }); }); it("renders an empty list as an empty envelope", () => { - renderList({ data: [], returned: 0, total: 0 }, cardView, baseOpts); + renderList(windowList([], FULL_RANGE), cardView, baseOpts); expect(parseJson(streams.stdout, CardListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -265,23 +270,44 @@ describe("renderList — JSON format", () => { name: `card-${"x".repeat(40)}-${index}`, archived: false, })); - const envelope: ListEnvelope = { - data: items, - returned: items.length, - total: items.length, - }; + const envelope = windowList(items, FULL_RANGE); renderList(envelope, cardView, { ...baseOpts, maxBytes: 500 }); const projectedItems = items.map(({ id, name }) => ({ id, name })); - const expectedCapped = capListEnvelope( - { data: projectedItems, returned: items.length, total: items.length }, - 500, - ); + const expectedCapped = capListEnvelope(windowList(projectedItems, FULL_RANGE), 500); assert(expectedCapped.truncated !== undefined, "fixture should produce truncation"); expect(parseJson(streams.stdout, TruncatedEnvelope)).toEqual(expectedCapped); expect(streams.stderr).toBe( - `… cut at ${expectedCapped.truncated.bytes} bytes; narrow the selection or raise --max-bytes\n`, + `… cut at ${expectedCapped.truncated.bytes} bytes; continue with --offset ${expectedCapped.next_offset}, narrow the selection or raise --max-bytes\n`, + ); + }); + + it("emits an empty window and the command-supplied hint when no row fits the cap", () => { + const item = { id: 1, name: "x".repeat(400), archived: false }; + const envelope = windowList([item], FULL_RANGE); + const fullBytes = Buffer.byteLength( + JSON.stringify(windowList([{ id: item.id, name: item.name }], FULL_RANGE)), + "utf8", + ); + + renderList(envelope, cardView, { + ...baseOpts, + maxBytes: 100, + oversizeHint: "pass --max-bytes 0 to print it whole", + }); + + expect(parseJson(streams.stdout, TruncatedEnvelope)).toEqual({ + data: [], + returned: 0, + offset: 0, + total: 1, + has_more: true, + next_offset: null, + truncated: { reason: "max_bytes", bytes: fullBytes }, + }); + expect(streams.stderr).toBe( + `… cut at ${fullBytes} bytes; pass --max-bytes 0 to print it whole\n`, ); }); @@ -291,32 +317,28 @@ describe("renderList — JSON format", () => { name: `card-${"x".repeat(40)}-${index}`, archived: false, })); - renderList({ data: items, returned: items.length, total: items.length }, cardView, { + renderList(windowList(items, FULL_RANGE), cardView, { ...baseOpts, maxBytes: 500, }); const projectedItems = items.map(({ id, name }) => ({ id, name })); - const expectedCapped = capListEnvelope( - { data: projectedItems, returned: items.length, total: items.length }, - 500, - ); + const expectedCapped = capListEnvelope(windowList(projectedItems, FULL_RANGE), 500); assert(expectedCapped.truncated !== undefined, "fixture should produce truncation"); - const prefix = `{"returned":${expectedCapped.returned},"total":${expectedCapped.total},"truncated":{"reason":"max_bytes","bytes":${expectedCapped.truncated.bytes}},"data":[`; + const prefix = `{"returned":${expectedCapped.returned},"offset":${expectedCapped.offset},"total":${expectedCapped.total},"has_more":${expectedCapped.has_more},"next_offset":${expectedCapped.next_offset},"truncated":{"reason":"max_bytes","bytes":${expectedCapped.truncated.bytes}},"data":[`; expect(streams.stdout.slice(0, prefix.length)).toBe(prefix); }); }); describe("renderList — text format", () => { it("renders a table when data is non-empty", () => { - const envelope: ListEnvelope = { - data: [ + const envelope = windowList( + [ { id: 1, name: "Sales", archived: false }, { id: 2, name: "Ops", archived: true }, ], - returned: 2, - total: 2, - }; + FULL_RANGE, + ); renderList(envelope, cardView, { ...baseOpts, format: "text" }); expect(streams.stdout).toContain("ID"); expect(streams.stdout).toContain("Name"); @@ -325,20 +347,19 @@ describe("renderList — text format", () => { }); it("emits a single '(no results)' line when empty", () => { - renderList({ data: [], returned: 0, total: 0 }, cardView, { ...baseOpts, format: "text" }); + renderList(windowList([], FULL_RANGE), cardView, { ...baseOpts, format: "text" }); expect(streams.stdout).toBe("(no results)\n"); }); it("renders a projected table (columns = the requested field paths) when fields is set in text mode", () => { renderList( - { - data: [ + windowList( + [ { id: 1, name: "Sales", archived: false }, { id: 2, name: "Ops", archived: true }, ], - returned: 2, - total: 2, - }, + FULL_RANGE, + ), cardView, { ...baseOpts, format: "text", fields: ["id", "archived"] }, ); @@ -356,14 +377,13 @@ describe("renderList — text format", () => { it("emits the projected list as JSON when fields is set under --json", () => { renderList( - { - data: [ + windowList( + [ { id: 1, name: "Sales", archived: false }, { id: 2, name: "Ops", archived: true }, ], - returned: 2, - total: 2, - }, + FULL_RANGE, + ), cardView, { ...baseOpts, fields: ["id", "archived"] }, ); @@ -373,7 +393,10 @@ describe("renderList — text format", () => { { id: 2, archived: true }, ], returned: 2, + offset: 0, total: 2, + has_more: false, + next_offset: null, }); }); @@ -383,28 +406,70 @@ describe("renderList — text format", () => { name: `card-${"x".repeat(40)}-${index}`, archived: false, })); - const envelope: ListEnvelope = { - data: items, - returned: items.length, - total: items.length, - }; + const envelope = windowList(items, FULL_RANGE); renderList(envelope, cardView, { ...baseOpts, format: "text", maxBytes: 500 }); - const expectedCapped = capListEnvelope(envelope, 500); + const projectedItems = items.map(({ id, name }) => ({ id, name })); + const expectedCapped = capListEnvelope(windowList(projectedItems, FULL_RANGE), 500); assert(expectedCapped.truncated !== undefined, "fixture should produce truncation"); expect(streams.stdout).toContain("ID"); expect(streams.stderr).toBe( - `… cut at ${expectedCapped.truncated.bytes} bytes; narrow the selection or raise --max-bytes\n`, + `… cut at ${expectedCapped.truncated.bytes} bytes; continue with --offset ${expectedCapped.next_offset}, narrow the selection or raise --max-bytes\n`, + ); + }); + + it("prints a header-only table and reports the cut when not even one row fits the cap", () => { + const item: Card = { id: 1, name: "x".repeat(400), archived: false }; + const envelope = windowList([item], FULL_RANGE); + + renderList(envelope, cardView, { ...baseOpts, format: "text", maxBytes: 100 }); + + const fullBytes = Buffer.byteLength( + JSON.stringify(windowList([{ id: item.id, name: item.name }], FULL_RANGE)), + "utf8", + ); + expect(streams.stdout).toBe(renderTable([], cardView.tableColumns) + "\n"); + expect(streams.stderr).toBe( + `… cut at ${fullBytes} bytes; narrow the selection or raise --max-bytes\n`, + ); + }); + + it("spends the budget on the projection, so bulk the table never prints costs no rows", () => { + const items: RawCard[] = Array.from({ length: 3 }, (_, index) => ({ + id: index, + name: `card-${index}`, + archived: false, + dataset_query: "q".repeat(5_000), + })); + const envelope: ListEnvelope = windowList(items, FULL_RANGE); + + renderList(envelope, cardView, { ...baseOpts, format: "text", maxBytes: 500 }); + + expect(streams.stdout).toBe(renderTable(items, cardView.tableColumns) + "\n"); + expect(streams.stderr).toBe(""); + }); + + it("cuts a text table at the same row its --json equivalent cuts, rendering the raw items", () => { + const items: Card[] = Array.from({ length: 50 }, (_, index) => ({ + id: index, + name: `card-${"x".repeat(40)}-${index}`, + archived: false, + })); + const envelope = windowList(items, FULL_RANGE); + + renderList(envelope, cardView, { ...baseOpts, maxBytes: 500 }); + const jsonEnvelope = parseJson(streams.stdout, TruncatedEnvelope); + streams.stdout = ""; + renderList(envelope, cardView, { ...baseOpts, format: "text", maxBytes: 500 }); + + expect(streams.stdout).toBe( + renderTable(items.slice(0, jsonEnvelope.returned), cardView.tableColumns) + "\n", ); }); }); describe("renderList — --fields path errors", () => { - const envelope: ListEnvelope = { - data: [{ id: 1, name: "Sales", archived: false }], - returned: 1, - total: 1, - }; + const envelope = windowList([{ id: 1, name: "Sales", archived: false }], FULL_RANGE); function renderListError(opts: RenderOptions): ConfigError { try { diff --git a/src/output/render.ts b/packages/cli/src/output/render.ts similarity index 66% rename from src/output/render.ts rename to packages/cli/src/output/render.ts index e29b6a5..dd9afdd 100644 --- a/src/output/render.ts +++ b/packages/cli/src/output/render.ts @@ -1,9 +1,9 @@ -import { ConfigError } from "../core/errors"; -import type { ColumnDef, ResourceView } from "../domain/view"; +import { ConfigError } from "@metabase/client/errors"; +import type { ColumnDef, ResourceView } from "./view"; -import { capListEnvelope } from "./cap"; +import { type CapFit, capListEnvelope, fitWithinCap } from "./cap"; import { itemOversizeMessage, listTruncationNotice, warn } from "./notice"; -import { applyProjection, isPlainObject, pickPath } from "./projection"; +import { applyProjection, isPlainObject, pickPath, projectForList } from "./projection"; import { formatCell, formatScalar, renderRows, renderTable } from "./table"; import type { ListEnvelope, RenderOptions } from "./types"; @@ -78,67 +78,49 @@ export function renderList( } if (opts.fields !== undefined) { - renderProjectedTable(envelope, view, opts.fields, opts.maxBytes); + renderProjectedTable(envelope, view, opts.fields, opts); return; } - const capped = capListEnvelope(envelope, opts.maxBytes); - process.stdout.write(renderTable(capped.data, view.tableColumns) + "\n"); - if (capped.truncated !== undefined) { - warn(listTruncationNotice(capped.truncated.bytes)); + // The budget is spent on the projection, not on the raw `.loose()` item: a `Card` carries its + // `dataset_query` and `result_metadata` into the cap while the table renders two columns of it. + // Rows are then rendered from the raw items the cut kept, since the columns read the raw shape. + const fit = fitWithinCap(projectedEnvelope(envelope, view, opts), opts.maxBytes); + const kept = envelope.data.slice(0, fit.count); + process.stdout.write(renderTable(kept, view.tableColumns) + "\n"); + warnIfCut(fit, envelope.offset, opts.oversizeHint); +} + +// A window the cap emptied has no offset to resume from, so the notice reports the cut without +// one: the table prints its header and the remedy says how to ask for less. +function warnIfCut(fit: CapFit, offset: number, hint?: string): void { + if (!fit.cut) { + return; } + warn(listTruncationNotice(fit.fullBytes, fit.count === 0 ? null : offset + fit.count, hint)); } function renderProjectedTable( envelope: ListEnvelope, view: ResourceView, fields: string[], - maxBytes: number, + opts: RenderOptions, ): void { - const projectedItems = projectListItems(envelope.data, view, false, fields); - const capped = capListEnvelope({ ...envelope, data: projectedItems }, maxBytes); - const rows = capped.data.map((item) => - fields.map((path) => formatScalar(pickPath(item, path.split(".")))), - ); + const projected = projectedEnvelope(envelope, view, opts); + const fit = fitWithinCap(projected, opts.maxBytes); + const rows = projected.data + .slice(0, fit.count) + .map((item) => fields.map((path) => formatScalar(pickPath(item, path.split("."))))); process.stdout.write(renderRows(fields, rows) + "\n"); - if (capped.truncated !== undefined) { - warn(listTruncationNotice(capped.truncated.bytes)); - } + warnIfCut(fit, envelope.offset, opts.oversizeHint); } -// List projections are item-relative: each path is resolved against an element of `data`, -// not the envelope. Users who write the path they see in the JSON (`data.id`) hit a dead-end -// "unknown field path" error. Catch that here and point them at the item-relative form. -function projectListItems( - items: readonly T[], +function projectedEnvelope( + envelope: ListEnvelope, view: ResourceView, - full: boolean, - fields: string[] | undefined, -): unknown[] { - try { - return items.map((item) => applyProjection(item, view, full, fields)); - } catch (error) { - throw enrichListFieldPathError(error, fields); - } -} - -function enrichListFieldPathError(error: unknown, fields: string[] | undefined): unknown { - if ( - fields === undefined || - !(error instanceof ConfigError) || - !error.message.startsWith("unknown field path") - ) { - return error; - } - const prefix = "data."; - const culprit = fields.find((field) => field === "data" || field.startsWith(prefix)); - if (culprit === undefined) { - return error; - } - const suggestion = culprit.startsWith(prefix) ? culprit.slice(prefix.length) : ""; - return new ConfigError( - `${error.message} — on list commands --fields paths are relative to each item in \`data\`, not the envelope. Drop the \`data.\` prefix (e.g. use \`${suggestion}\` instead of \`${culprit}\`).`, - ); + opts: RenderOptions, +): ListEnvelope { + return { ...envelope, data: envelope.data.map((item) => projectForList(item, view, opts)) }; } function renderJsonEnvelope( @@ -146,21 +128,22 @@ function renderJsonEnvelope( view: ResourceView, opts: RenderOptions, ): void { - const projectedItems = projectListItems(envelope.data, view, opts.full, opts.fields); - const projectedEnvelope: ListEnvelope = { ...envelope, data: projectedItems }; - const capped = capListEnvelope(projectedEnvelope, opts.maxBytes); + const capped = capListEnvelope(projectedEnvelope(envelope, view, opts), opts.maxBytes); // Metadata precedes `data` so counts and the truncation marker survive when a downstream // consumer (an agent harness, a pager) cuts the tail of the output. const ordered: ListEnvelope = { returned: capped.returned, - total: capped.total, + offset: capped.offset, limit: capped.limit, + total: capped.total, + has_more: capped.has_more, + next_offset: capped.next_offset, truncated: capped.truncated, data: capped.data, }; process.stdout.write(serializeJson(ordered, stdoutPretty()) + "\n"); if (capped.truncated !== undefined) { - warn(listTruncationNotice(capped.truncated.bytes)); + warn(listTruncationNotice(capped.truncated.bytes, capped.next_offset, opts.oversizeHint)); } } diff --git a/src/output/skill-list.test.ts b/packages/cli/src/output/skill-list.test.ts similarity index 100% rename from src/output/skill-list.test.ts rename to packages/cli/src/output/skill-list.test.ts diff --git a/src/output/skill-list.ts b/packages/cli/src/output/skill-list.ts similarity index 100% rename from src/output/skill-list.ts rename to packages/cli/src/output/skill-list.ts diff --git a/src/output/stream.test.ts b/packages/cli/src/output/stream.test.ts similarity index 100% rename from src/output/stream.test.ts rename to packages/cli/src/output/stream.test.ts diff --git a/src/output/stream.ts b/packages/cli/src/output/stream.ts similarity index 100% rename from src/output/stream.ts rename to packages/cli/src/output/stream.ts diff --git a/src/output/table.test.ts b/packages/cli/src/output/table.test.ts similarity index 97% rename from src/output/table.test.ts rename to packages/cli/src/output/table.test.ts index a55cfe7..0cf8f91 100644 --- a/src/output/table.test.ts +++ b/packages/cli/src/output/table.test.ts @@ -1,6 +1,6 @@ import { assert, describe, expect, it } from "vitest"; -import type { ColumnDef } from "../domain/view"; +import type { ColumnDef } from "./view"; import { ANSI_ESC } from "./help"; import { renderTable } from "./table"; diff --git a/src/output/table.ts b/packages/cli/src/output/table.ts similarity index 77% rename from src/output/table.ts rename to packages/cli/src/output/table.ts index 51330ff..23da9a9 100644 --- a/src/output/table.ts +++ b/packages/cli/src/output/table.ts @@ -1,6 +1,6 @@ import Table from "cli-table3"; -import type { ColumnDef } from "../domain/view"; +import type { ColumnDef } from "./view"; // cli-table3 colorizes headers (red) and borders (grey) by default; empty arrays disable that so // output stays plain ASCII — composable in pipes, free of ANSI for agents, deterministic in tests. @@ -43,6 +43,14 @@ export function formatCell(row: T, column: ColumnDef): string { return formatScalar(value); } +// A cell whose value failed its schema parse. Absence renders blank (see formatScalar), so drift +// between a domain schema and the server needs a rendering that cannot be read as "no value". +export const MALFORMED_CELL = ""; + +// The auth summary tables render one fixed row per profile, where a blank cell reads as a broken +// renderer rather than as missing data, so they place this glyph instead. +export const EMPTY_CELL = "—"; + export function formatScalar(value: unknown): string { if (value === null || value === undefined) { return ""; diff --git a/packages/cli/src/output/types.test.ts b/packages/cli/src/output/types.test.ts new file mode 100644 index 0000000..af60be3 --- /dev/null +++ b/packages/cli/src/output/types.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { listEnvelopeSchema, type ListEnvelope } from "./types"; + +const Person = z.object({ id: z.number().int(), name: z.string() }).strict(); + +const JsonSchemaProperties = z + .object({ + properties: z.record(z.string(), z.object({ description: z.string().optional() }).loose()), + }) + .loose(); + +describe("listEnvelopeSchema", () => { + it("accepts a minimal envelope with only the required position fields", () => { + const schema = listEnvelopeSchema(Person); + const envelope = { data: [{ id: 1, name: "x" }], returned: 1, offset: 0, has_more: false }; + expect(schema.parse(envelope)).toEqual(envelope); + }); + + it("accepts an envelope with all optional fields populated", () => { + const schema = listEnvelopeSchema(Person); + const envelope: ListEnvelope<{ id: number; name: string }> = { + data: [{ id: 1, name: "x" }], + returned: 1, + offset: 25, + limit: 25, + total: 50, + has_more: true, + next_offset: 26, + truncated: { reason: "max_bytes", bytes: 4096 }, + }; + expect(schema.parse(envelope)).toEqual(envelope); + }); + + it("describes every envelope field, and the descriptions survive JSON Schema conversion", () => { + const { properties } = JsonSchemaProperties.parse(z.toJSONSchema(listEnvelopeSchema(Person))); + const descriptions = Object.fromEntries( + Object.entries(properties).map(([field, property]) => [field, property.description]), + ); + expect(descriptions).toEqual({ + data: "The items in this window.", + returned: "Number of items in `data`; a short window is not proof the walk is over.", + offset: "Index of the first item in `data` within the full result set.", + limit: "Echoes `--limit` when you passed one, and is absent otherwise.", + total: + "The server's count on endpoints that report one and `null` on those that don't, so it is a display value rather than a loop bound.", + has_more: + "True when more items exist beyond this window; this alone decides whether to keep going, never a comparison of counts. True with a `null` next_offset means the remaining items are reached by widening `--max-bytes` or narrowing the selection, not by paging.", + next_offset: + "Pass back as `--offset` to fetch the next window; it is past the offset you sent, and `null` when there is nowhere to resume from — either the walk is over or `--max-bytes` left no room for a single row, which `truncated` tells apart.", + truncated: + "Present when the `--max-bytes` cap cut the output, not when the data ran out; its `bytes` is what the untruncated answer would have measured, so narrow rows with `--fields` rather than raising the cap.", + }); + }); + + it("rejects an envelope whose items fail the item schema", () => { + const schema = listEnvelopeSchema(Person); + const result = schema.safeParse({ + data: [{ id: "not-a-number", name: "x" }], + returned: 1, + offset: 0, + has_more: false, + }); + expect(result.success).toBe(false); + }); + + it("rejects a negative returned count", () => { + const schema = listEnvelopeSchema(Person); + const result = schema.safeParse({ data: [], returned: -1, offset: 0, has_more: false }); + expect(result.success).toBe(false); + }); + + it("rejects a negative offset", () => { + const schema = listEnvelopeSchema(Person); + const result = schema.safeParse({ data: [], returned: 0, offset: -1, has_more: false }); + expect(result.success).toBe(false); + }); + + it("rejects a missing has_more", () => { + const schema = listEnvelopeSchema(Person); + const result = schema.safeParse({ data: [], returned: 0, offset: 0 }); + expect(result.success).toBe(false); + }); + + it("rejects a non-integer total", () => { + const schema = listEnvelopeSchema(Person); + const result = schema.safeParse({ + data: [], + returned: 0, + offset: 0, + has_more: false, + total: 1.5, + }); + expect(result.success).toBe(false); + }); + + it("rejects truncated.reason that is not the literal 'max_bytes'", () => { + const schema = listEnvelopeSchema(Person); + const result = schema.safeParse({ + data: [], + returned: 0, + offset: 0, + has_more: false, + truncated: { reason: "wrong-reason", bytes: 100 }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/cli/src/output/types.ts b/packages/cli/src/output/types.ts new file mode 100644 index 0000000..52edd4b --- /dev/null +++ b/packages/cli/src/output/types.ts @@ -0,0 +1,102 @@ +import { z, type ZodType } from "zod"; + +export type Format = "json" | "text"; + +// Sized to stay under agent-harness tool-output truncation (~30k chars in Claude Code's Bash +// tool): the CLI's own cap must fire, with its teaching error, before the transport silently +// cuts JSON mid-object. +export const DEFAULT_MAX_BYTES = 24576; + +// `bytes` is what the envelope would have serialized to had nothing been dropped — the size of +// the answer being asked for, measured over the same projection the output uses. It is deliberately +// not the size of what came back, which is bounded by `--max-bytes` and so says nothing new. +interface TruncationInfo { + reason: "max_bytes"; + bytes: number; +} + +export interface ListEnvelope { + data: T[]; + returned: number; + offset: number; + limit?: number | undefined; + total?: number | null | undefined; + has_more: boolean; + next_offset?: number | null | undefined; + truncated?: TruncationInfo | undefined; +} + +export function listEnvelopeSchema(item: ZodType): ZodType> { + return z.object({ + data: z.array(item).describe("The items in this window."), + returned: z + .number() + .int() + .nonnegative() + .describe("Number of items in `data`; a short window is not proof the walk is over."), + offset: z + .number() + .int() + .nonnegative() + .describe("Index of the first item in `data` within the full result set."), + limit: z + .number() + .int() + .nonnegative() + .optional() + .describe("Echoes `--limit` when you passed one, and is absent otherwise."), + total: z + .number() + .int() + .nonnegative() + .nullable() + .optional() + .describe( + "The server's count on endpoints that report one and `null` on those that don't, so it is a display value rather than a loop bound.", + ), + has_more: z + .boolean() + .describe( + "True when more items exist beyond this window; this alone decides whether to keep going, never a comparison of counts. True with a `null` next_offset means the remaining items are reached by widening `--max-bytes` or narrowing the selection, not by paging.", + ), + next_offset: z + .number() + .int() + .nonnegative() + .nullable() + .optional() + .describe( + "Pass back as `--offset` to fetch the next window; it is past the offset you sent, and `null` when there is nowhere to resume from — either the walk is over or `--max-bytes` left no room for a single row, which `truncated` tells apart.", + ), + truncated: z + .object({ + reason: z.literal("max_bytes"), + bytes: z.number().int().nonnegative(), + }) + .optional() + .describe( + "Present when the `--max-bytes` cap cut the output, not when the data ran out; its `bytes` is what the untruncated answer would have measured, so narrow rows with `--fields` rather than raising the cap.", + ), + }); +} + +// The window a list command was asked to return; it flows through to server-side paging when the +// endpoint supports it, and is applied client-side when it does not. +export interface ListRange { + offset: number; + limit: number | undefined; +} + +export const FULL_RANGE: ListRange = { offset: 0, limit: undefined }; + +export interface RenderOptions { + format: Format; + full: boolean; + fields: string[] | undefined; + maxBytes: number; + oversizeHint?: string | undefined; +} + +export interface ListOptions extends RenderOptions { + range: ListRange; +} diff --git a/src/domain/view.ts b/packages/cli/src/output/view.ts similarity index 93% rename from src/domain/view.ts rename to packages/cli/src/output/view.ts index e87c947..13a17cf 100644 --- a/src/domain/view.ts +++ b/packages/cli/src/output/view.ts @@ -7,7 +7,7 @@ export interface ColumnDef { format?: (value: unknown) => string; } -export type DeepPartial = +type DeepPartial = T extends ReadonlyArray ? ReadonlyArray> : T extends object diff --git a/packages/cli/src/output/views.test.ts b/packages/cli/src/output/views.test.ts new file mode 100644 index 0000000..71b775b --- /dev/null +++ b/packages/cli/src/output/views.test.ts @@ -0,0 +1,74 @@ +import { readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; +import { z, ZodObject } from "zod"; + +const VIEWS_DIR = join(dirname(fileURLToPath(import.meta.url)), "views"); +const TS_SUFFIX = ".ts"; +const TEST_SUFFIX = ".test.ts"; + +const ModuleNamespace = z.record(z.string(), z.unknown()); + +const ViewBinding = z.object({ + compactPick: z.unknown(), + tableColumns: z.array(z.object({ key: z.string() })), +}); + +interface DiscoveredView { + module: string; + name: string; + binding: z.infer; +} + +function viewModuleNames(): string[] { + return readdirSync(VIEWS_DIR) + .filter((name) => name.endsWith(TS_SUFFIX) && !name.endsWith(TEST_SUFFIX)) + .map((name) => name.slice(0, -TS_SUFFIX.length)) + .toSorted(); +} + +async function discoverViews(): Promise { + const found: DiscoveredView[] = []; + for (const module of viewModuleNames()) { + const namespace = ModuleNamespace.parse(await import(`./views/${module}.ts`)); + for (const [name, value] of Object.entries(namespace)) { + if (typeof value !== "object" || value === null || !("compactPick" in value)) { + continue; + } + found.push({ module, name, binding: ViewBinding.parse(value) }); + } + } + return found; +} + +const MODULE_NAMES = viewModuleNames(); +const VIEWS = await discoverViews(); + +describe("output/views bindings", () => { + it("finds a view binding in every views/ module", () => { + const barren = MODULE_NAMES.filter((module) => !VIEWS.some((view) => view.module === module)); + expect(barren).toEqual([]); + }); + + // The check is one-directional on purpose. A compactPick key with no column is legitimate — many + // views carry fields in `--json` that the terminal table has no room for. The reverse is the bug: + // `render` builds the table from the raw item but projects `--json` through `compactPick`, so a + // column outside the projection renders in the table and silently vanishes from `--json`. + for (const view of VIEWS) { + it(`${view.name} projects every table column through compactPick`, () => { + const pick = view.binding.compactPick; + if (!(pick instanceof ZodObject)) { + throw new Error( + `${view.name}.compactPick is not a ZodObject, so its projected keys cannot be read`, + ); + } + const projected = Object.keys(pick.shape); + const unprojected = view.binding.tableColumns + .map((column) => column.key) + .filter((key) => !projected.includes(key)); + expect(unprojected).toEqual([]); + }); + } +}); diff --git a/packages/cli/src/output/views/card.ts b/packages/cli/src/output/views/card.ts new file mode 100644 index 0000000..8c48963 --- /dev/null +++ b/packages/cli/src/output/views/card.ts @@ -0,0 +1,26 @@ +import { + type Card, + CardCompact, + type CardQueryResult, + CardQueryResultCompact, +} from "@metabase/client/domain/card"; + +import type { ResourceView } from "../view"; + +export const cardView: ResourceView = { + compactPick: CardCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "type", label: "Type" }, + { key: "display", label: "Display" }, + { key: "database_id", label: "DB" }, + { key: "collection_id", label: "Collection" }, + { key: "archived", label: "Archived" }, + ], +}; + +export const cardQueryView: ResourceView = { + compactPick: CardQueryResultCompact, + tableColumns: [{ key: "status", label: "Status" }], +}; diff --git a/packages/cli/src/output/views/collection.ts b/packages/cli/src/output/views/collection.ts new file mode 100644 index 0000000..fb8ae07 --- /dev/null +++ b/packages/cli/src/output/views/collection.ts @@ -0,0 +1,31 @@ +import { + type Collection, + CollectionCompact, + type CollectionItem, + CollectionItemCompact, +} from "@metabase/client/domain/collection"; + +import type { ResourceView } from "../view"; + +export const collectionView: ResourceView = { + compactPick: CollectionCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "location", label: "Location" }, + { key: "type", label: "Type" }, + { key: "authority_level", label: "Authority" }, + { key: "archived", label: "Archived" }, + ], +}; + +export const collectionItemView: ResourceView = { + compactPick: CollectionItemCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "model", label: "Model" }, + { key: "name", label: "Name" }, + { key: "collection_id", label: "Collection" }, + { key: "archived", label: "Archived" }, + ], +}; diff --git a/packages/cli/src/output/views/dashboard.ts b/packages/cli/src/output/views/dashboard.ts new file mode 100644 index 0000000..9d78487 --- /dev/null +++ b/packages/cli/src/output/views/dashboard.ts @@ -0,0 +1,31 @@ +import { + type Dashboard, + DashboardCompact, + type Dashcard, + DashcardCompact, +} from "@metabase/client/domain/dashboard"; + +import type { ResourceView } from "../view"; + +export const dashcardView: ResourceView = { + compactPick: DashcardCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "card_id", label: "Card" }, + { key: "dashboard_tab_id", label: "Tab" }, + { key: "row", label: "Row" }, + { key: "col", label: "Col" }, + { key: "size_x", label: "W" }, + { key: "size_y", label: "H" }, + ], +}; + +export const dashboardView: ResourceView = { + compactPick: DashboardCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "collection_id", label: "Collection" }, + { key: "archived", label: "Archived" }, + ], +}; diff --git a/packages/cli/src/output/views/database.ts b/packages/cli/src/output/views/database.ts new file mode 100644 index 0000000..e09a5a5 --- /dev/null +++ b/packages/cli/src/output/views/database.ts @@ -0,0 +1,25 @@ +import { + type Database, + DatabaseCompact, + DatabaseSyncResult, +} from "@metabase/client/domain/database"; + +import type { ResourceView } from "../view"; + +export const databaseView: ResourceView = { + compactPick: DatabaseCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "engine", label: "Engine" }, + ], +}; + +export const databaseSyncResultView: ResourceView = { + compactPick: DatabaseSyncResult, + tableColumns: [ + { key: "id", label: "Database" }, + { key: "status", label: "Status" }, + { key: "initial_sync_status", label: "Initial sync" }, + ], +}; diff --git a/packages/cli/src/output/views/document.ts b/packages/cli/src/output/views/document.ts new file mode 100644 index 0000000..805bbb8 --- /dev/null +++ b/packages/cli/src/output/views/document.ts @@ -0,0 +1,14 @@ +import { type Document, DocumentCompact } from "@metabase/client/domain/document"; + +import type { ResourceView } from "../view"; + +export const documentView: ResourceView = { + compactPick: DocumentCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "collection_id", label: "Collection" }, + { key: "creator_id", label: "Creator" }, + { key: "archived", label: "Archived" }, + ], +}; diff --git a/packages/cli/src/output/views/eid-translation.ts b/packages/cli/src/output/views/eid-translation.ts new file mode 100644 index 0000000..b8e089f --- /dev/null +++ b/packages/cli/src/output/views/eid-translation.ts @@ -0,0 +1,11 @@ +import { + type EidTranslateResult, + EidTranslateResultCompact, +} from "@metabase/client/domain/eid-translation"; + +import type { ResourceView } from "../view"; + +export const eidTranslateView: ResourceView = { + compactPick: EidTranslateResultCompact, + tableColumns: [{ key: "entity_ids", label: "Translated" }], +}; diff --git a/packages/cli/src/output/views/field.ts b/packages/cli/src/output/views/field.ts new file mode 100644 index 0000000..5f61c43 --- /dev/null +++ b/packages/cli/src/output/views/field.ts @@ -0,0 +1,48 @@ +import { + type Field, + FieldCompact, + FieldSummary, + type FieldValues, + FieldValuesCompact, +} from "@metabase/client/domain/field"; + +import type { ResourceView } from "../view"; + +function formatTypeTag(value: unknown): string { + return typeof value === "string" ? value.replace(/^type\//, "") : ""; +} + +function formatFkTarget(value: unknown): string { + return typeof value === "number" ? `field ${value}` : ""; +} + +export const fieldView: ResourceView = { + compactPick: FieldCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "display_name", label: "Display Name" }, + { key: "base_type", label: "Base Type", format: formatTypeTag }, + { key: "semantic_type", label: "Semantic Type", format: formatTypeTag }, + { key: "fk_target_field_id", label: "FK Target", format: formatFkTarget }, + { key: "description", label: "Description" }, + ], +}; + +export const fieldValuesView: ResourceView = { + compactPick: FieldValuesCompact, + tableColumns: [ + { key: "field_id", label: "Field" }, + { key: "has_more_values", label: "Has More" }, + { key: "values", label: "Values" }, + ], +}; + +export const fieldSummaryView: ResourceView = { + compactPick: FieldSummary, + tableColumns: [ + { key: "field_id", label: "Field" }, + { key: "count", label: "Count" }, + { key: "distincts", label: "Distinct" }, + ], +}; diff --git a/packages/cli/src/output/views/git-sync.ts b/packages/cli/src/output/views/git-sync.ts new file mode 100644 index 0000000..0fcc768 --- /dev/null +++ b/packages/cli/src/output/views/git-sync.ts @@ -0,0 +1,88 @@ +import { + SyncBranchCreated, + type SyncDirtyItem, + SyncDirtyItemCompact, + SyncExportResult, + SyncImportResult, + SyncRemoteChanges, + SyncSettingsUpdateResult, + SyncStashResult, + type SyncTask, + SyncTaskCompact, +} from "@metabase/client/domain/git-sync"; + +import type { ResourceView } from "../view"; + +export const syncTaskView: ResourceView = { + compactPick: SyncTaskCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "sync_task_type", label: "Type" }, + { key: "status", label: "Status" }, + { key: "progress", label: "Progress" }, + { key: "version", label: "Version" }, + { key: "error_message", label: "Error" }, + ], +}; + +export const syncDirtyItemView: ResourceView = { + compactPick: SyncDirtyItemCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "model", label: "Model" }, + { key: "name", label: "Name" }, + { key: "sync_status", label: "Status" }, + { key: "collection_id", label: "Collection" }, + ], +}; + +export const syncRemoteChangesView: ResourceView = { + compactPick: SyncRemoteChanges, + tableColumns: [ + { key: "has_changes", label: "Has changes" }, + { key: "remote_version", label: "Remote" }, + { key: "local_version", label: "Local" }, + { key: "cached", label: "Cached" }, + ], +}; + +export const syncBranchCreatedView: ResourceView = { + compactPick: SyncBranchCreated, + tableColumns: [ + { key: "status", label: "Status" }, + { key: "message", label: "Message" }, + ], +}; + +export const syncSettingsUpdateView: ResourceView = { + compactPick: SyncSettingsUpdateResult, + tableColumns: [ + { key: "success", label: "Success" }, + { key: "task_id", label: "Task ID" }, + ], +}; + +export const syncImportView: ResourceView = { + compactPick: SyncImportResult, + tableColumns: [ + { key: "task_id", label: "Task ID" }, + { key: "message", label: "Message" }, + ], +}; + +export const syncExportView: ResourceView = { + compactPick: SyncExportResult, + tableColumns: [ + { key: "task_id", label: "Task ID" }, + { key: "message", label: "Message" }, + ], +}; + +export const syncStashView: ResourceView = { + compactPick: SyncStashResult, + tableColumns: [ + { key: "task_id", label: "Task ID" }, + { key: "status", label: "Status" }, + { key: "message", label: "Message" }, + ], +}; diff --git a/packages/cli/src/output/views/library.ts b/packages/cli/src/output/views/library.ts new file mode 100644 index 0000000..26fb4d8 --- /dev/null +++ b/packages/cli/src/output/views/library.ts @@ -0,0 +1,12 @@ +import { type Library, LibraryCompact } from "@metabase/client/domain/library"; + +import type { ResourceView } from "../view"; + +export const libraryView: ResourceView = { + compactPick: LibraryCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "type", label: "Type" }, + ], +}; diff --git a/packages/cli/src/output/views/measure.ts b/packages/cli/src/output/views/measure.ts new file mode 100644 index 0000000..3590460 --- /dev/null +++ b/packages/cli/src/output/views/measure.ts @@ -0,0 +1,14 @@ +import { type Measure, MeasureCompact } from "@metabase/client/domain/measure"; + +import type { ResourceView } from "../view"; + +export const measureView: ResourceView = { + compactPick: MeasureCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + { key: "table_id", label: "Table" }, + { key: "archived", label: "Archived" }, + ], +}; diff --git a/packages/cli/src/output/views/notification.test.ts b/packages/cli/src/output/views/notification.test.ts new file mode 100644 index 0000000..98ee46d --- /dev/null +++ b/packages/cli/src/output/views/notification.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import type { Notification } from "@metabase/client/domain/notification"; + +import { MALFORMED_CELL } from "../table"; +import { notificationView } from "./notification"; + +function renderCell(key: keyof Notification & string, value: unknown): string { + const column = notificationView.tableColumns.find((candidate) => candidate.key === key); + if (column?.format === undefined) { + throw new Error(`notificationView declares no formatter for "${key}"`); + } + return column.format(value); +} + +describe("notificationView payload cell", () => { + it("renders a card payload as its id, send condition, and send-once flag", () => { + expect( + renderCell("payload", { card_id: 7, send_condition: "goal_above", send_once: true }), + ).toBe("7 (goal_above, once)"); + }); + + it("renders the null payload of a system-event notification as a blank cell", () => { + expect(renderCell("payload", null)).toBe(""); + }); + + it("renders a payload that fails its schema as the malformed marker", () => { + expect( + renderCell("payload", { + card_id: "seven", + send_condition: "goal_above", + send_once: true, + }), + ).toBe(MALFORMED_CELL); + }); +}); + +describe("notificationView subscriptions cell", () => { + it("renders a subscription list that fails its schema as the malformed marker", () => { + expect(renderCell("subscriptions", [{ type: "notification-subscription/hourly" }])).toBe( + MALFORMED_CELL, + ); + }); +}); + +describe("notificationView handlers cell", () => { + it("renders a handler list that fails its schema as the malformed marker", () => { + expect(renderCell("handlers", [{ channel_type: "channel/webhook" }])).toBe(MALFORMED_CELL); + }); +}); diff --git a/packages/cli/src/output/views/notification.ts b/packages/cli/src/output/views/notification.ts new file mode 100644 index 0000000..d77b3d6 --- /dev/null +++ b/packages/cli/src/output/views/notification.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; + +import { + type Notification, + NotificationCardPayload, + NotificationCompact, + NotificationHandler, + type NotificationRecipient, + NotificationSubscription, +} from "@metabase/client/domain/notification"; + +import { MALFORMED_CELL } from "../table"; +import type { ResourceView } from "../view"; + +const NullableCardPayload = NotificationCardPayload.nullable(); +const NotificationSubscriptionList = z.array(NotificationSubscription); +const NotificationHandlerList = z.array(NotificationHandler); + +function formatPayload(value: unknown): string { + const parsed = NullableCardPayload.safeParse(value); + if (!parsed.success) { + return MALFORMED_CELL; + } + if (parsed.data === null) { + return ""; + } + const { card_id, send_condition, send_once } = parsed.data; + const once = send_once ? ", once" : ""; + return `${card_id} (${send_condition}${once})`; +} + +function formatSubscriptions(value: unknown): string { + const parsed = NotificationSubscriptionList.safeParse(value); + if (!parsed.success) { + return MALFORMED_CELL; + } + return parsed.data + .map((subscription) => subscription.cron_schedule ?? subscription.event_name ?? "") + .filter((label) => label !== "") + .join("; "); +} + +function describeRecipient(recipient: NotificationRecipient): string { + switch (recipient.type) { + case "notification-recipient/user": { + return `user:${recipient.user_id}`; + } + case "notification-recipient/group": { + return `group:${recipient.permissions_group_id}`; + } + case "notification-recipient/raw-value": { + return recipient.details?.value ?? ""; + } + case "notification-recipient/template": { + return "template"; + } + } +} + +function describeHandler(handler: NotificationHandler): string { + const channel = handler.channel_type.replace("channel/", ""); + const recipients = (handler.recipients ?? []).map(describeRecipient).join(", "); + return recipients === "" ? channel : `${channel} → ${recipients}`; +} + +function formatHandlers(value: unknown): string { + const parsed = NotificationHandlerList.safeParse(value); + if (!parsed.success) { + return MALFORMED_CELL; + } + return parsed.data.map(describeHandler).join("; "); +} + +export const notificationView: ResourceView = { + compactPick: NotificationCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "payload", label: "Card", format: formatPayload }, + { key: "subscriptions", label: "Schedule", format: formatSubscriptions }, + { key: "handlers", label: "Delivery", format: formatHandlers }, + { key: "active", label: "Active" }, + ], +}; diff --git a/packages/cli/src/output/views/parameter.ts b/packages/cli/src/output/views/parameter.ts new file mode 100644 index 0000000..5859ffd --- /dev/null +++ b/packages/cli/src/output/views/parameter.ts @@ -0,0 +1,8 @@ +import { type ParameterValues, ParameterValuesCompact } from "@metabase/client/domain/parameter"; + +import type { ResourceView } from "../view"; + +export const parameterValuesView: ResourceView = { + compactPick: ParameterValuesCompact, + tableColumns: [{ key: "has_more_values", label: "More available" }], +}; diff --git a/packages/cli/src/output/views/pulse.test.ts b/packages/cli/src/output/views/pulse.test.ts new file mode 100644 index 0000000..26929a1 --- /dev/null +++ b/packages/cli/src/output/views/pulse.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import type { Pulse } from "@metabase/client/domain/pulse"; + +import { MALFORMED_CELL } from "../table"; +import { pulseView } from "./pulse"; + +function renderCell(key: keyof Pulse & string, value: unknown): string { + const column = pulseView.tableColumns.find((candidate) => candidate.key === key); + if (column?.format === undefined) { + throw new Error(`pulseView declares no formatter for "${key}"`); + } + return column.format(value); +} + +describe("pulseView delivery cell", () => { + it("renders a channel as its type, schedule, and audience", () => { + expect( + renderCell("channels", [ + { + channel_type: "email", + enabled: true, + schedule_type: "weekly", + schedule_hour: 8, + schedule_day: "mon", + schedule_frame: null, + recipients: [{ id: 3, email: "ops@example.com" }], + }, + ]), + ).toBe("email weekly mon 8:00 → ops@example.com"); + }); + + // A channel type this Metabase delivers to and the CLI does not know fails the enum. Blanking + // the cell would read as "this subscription delivers nowhere". + it("renders a channel list that fails its schema as the malformed marker", () => { + expect( + renderCell("channels", [ + { + channel_type: "teams", + enabled: true, + schedule_type: "daily", + schedule_hour: 8, + schedule_day: null, + schedule_frame: null, + recipients: [], + }, + ]), + ).toBe(MALFORMED_CELL); + }); +}); diff --git a/packages/cli/src/output/views/pulse.ts b/packages/cli/src/output/views/pulse.ts new file mode 100644 index 0000000..fa290d1 --- /dev/null +++ b/packages/cli/src/output/views/pulse.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +import { type Pulse, PulseChannel, PulseCompact } from "@metabase/client/domain/pulse"; + +import { MALFORMED_CELL } from "../table"; +import type { ResourceView } from "../view"; + +const PulseChannelList = z.array(PulseChannel); + +function describeSchedule(channel: PulseChannel): string { + const parts: string[] = [channel.schedule_type]; + if (channel.schedule_frame !== null) { + parts.push(channel.schedule_frame); + } + if (channel.schedule_day !== null) { + parts.push(channel.schedule_day); + } + if (channel.schedule_hour !== null) { + parts.push(`${channel.schedule_hour}:00`); + } + return parts.join(" "); +} + +function describeAudience(channel: PulseChannel): string | null { + if (channel.recipients.length > 0) { + return channel.recipients.map((recipient) => recipient.email).join(", "); + } + const slackChannel = channel.details?.channel; + return slackChannel === undefined ? null : slackChannel; +} + +function describeChannel(channel: PulseChannel): string { + const parts: string[] = [channel.channel_type, describeSchedule(channel)]; + const audience = describeAudience(channel); + if (audience !== null) { + parts.push(`→ ${audience}`); + } + if (!channel.enabled) { + parts.push("(disabled)"); + } + return parts.join(" "); +} + +function formatChannels(value: unknown): string { + const parsed = PulseChannelList.safeParse(value); + if (!parsed.success) { + return MALFORMED_CELL; + } + return parsed.data.map(describeChannel).join("; "); +} + +export const pulseView: ResourceView = { + compactPick: PulseCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "dashboard_id", label: "Dashboard" }, + { key: "channels", label: "Delivery", format: formatChannels }, + { key: "archived", label: "Archived" }, + ], +}; diff --git a/packages/cli/src/output/views/search.ts b/packages/cli/src/output/views/search.ts new file mode 100644 index 0000000..d27546b --- /dev/null +++ b/packages/cli/src/output/views/search.ts @@ -0,0 +1,12 @@ +import { type SearchResult, SearchResultCompact } from "@metabase/client/domain/search"; + +import type { ResourceView } from "../view"; + +export const searchResultView: ResourceView = { + compactPick: SearchResultCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "model", label: "Model" }, + { key: "name", label: "Name" }, + ], +}; diff --git a/packages/cli/src/output/views/segment.ts b/packages/cli/src/output/views/segment.ts new file mode 100644 index 0000000..3cfc045 --- /dev/null +++ b/packages/cli/src/output/views/segment.ts @@ -0,0 +1,14 @@ +import { type Segment, SegmentCompact } from "@metabase/client/domain/segment"; + +import type { ResourceView } from "../view"; + +export const segmentView: ResourceView = { + compactPick: SegmentCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + { key: "table_id", label: "Table" }, + { key: "archived", label: "Archived" }, + ], +}; diff --git a/packages/cli/src/output/views/setting.ts b/packages/cli/src/output/views/setting.ts new file mode 100644 index 0000000..14a3569 --- /dev/null +++ b/packages/cli/src/output/views/setting.ts @@ -0,0 +1,21 @@ +import { type Setting, SettingCompact, SettingValue } from "@metabase/client/domain/setting"; + +import type { ResourceView } from "../view"; + +export const settingView: ResourceView = { + compactPick: SettingCompact, + tableColumns: [ + { key: "key", label: "Key" }, + { key: "value", label: "Value" }, + { key: "is_env_setting", label: "From env" }, + { key: "env_name", label: "Env name" }, + ], +}; + +export const settingValueView: ResourceView = { + compactPick: SettingValue, + tableColumns: [ + { key: "key", label: "Key" }, + { key: "value", label: "Value" }, + ], +}; diff --git a/packages/cli/src/output/views/setup.ts b/packages/cli/src/output/views/setup.ts new file mode 100644 index 0000000..02f0a64 --- /dev/null +++ b/packages/cli/src/output/views/setup.ts @@ -0,0 +1,8 @@ +import { type SetupResult, SetupResultCompact } from "@metabase/client/domain/setup"; + +import type { ResourceView } from "../view"; + +export const setupResultView: ResourceView = { + compactPick: SetupResultCompact, + tableColumns: [{ key: "id", label: "Session" }], +}; diff --git a/packages/cli/src/output/views/snippet.ts b/packages/cli/src/output/views/snippet.ts new file mode 100644 index 0000000..fce822f --- /dev/null +++ b/packages/cli/src/output/views/snippet.ts @@ -0,0 +1,14 @@ +import { type Snippet, SnippetCompact } from "@metabase/client/domain/snippet"; + +import type { ResourceView } from "../view"; + +export const snippetView: ResourceView = { + compactPick: SnippetCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + { key: "collection_id", label: "Collection" }, + { key: "archived", label: "Archived" }, + ], +}; diff --git a/packages/cli/src/output/views/table.ts b/packages/cli/src/output/views/table.ts new file mode 100644 index 0000000..446ba71 --- /dev/null +++ b/packages/cli/src/output/views/table.ts @@ -0,0 +1,16 @@ +import { type Table, TableCompact } from "@metabase/client/domain/table"; + +import type { ResourceView } from "../view"; + +export const tableView: ResourceView = { + compactPick: TableCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "db_id", label: "DB" }, + { key: "schema", label: "Schema" }, + { key: "name", label: "Name" }, + { key: "display_name", label: "Display Name" }, + { key: "description", label: "Description" }, + { key: "is_published", label: "Published" }, + ], +}; diff --git a/packages/cli/src/output/views/timeline.ts b/packages/cli/src/output/views/timeline.ts new file mode 100644 index 0000000..f81b8b5 --- /dev/null +++ b/packages/cli/src/output/views/timeline.ts @@ -0,0 +1,33 @@ +import { + type Timeline, + TimelineCompact, + type TimelineEvent, + TimelineEventCompact, +} from "@metabase/client/domain/timeline"; + +import type { ResourceView } from "../view"; + +export const timelineEventView: ResourceView = { + compactPick: TimelineEventCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "timestamp", label: "Timestamp" }, + { key: "icon", label: "Icon" }, + { key: "timeline_id", label: "Timeline" }, + { key: "archived", label: "Archived" }, + ], +}; + +export const timelineView: ResourceView = { + compactPick: TimelineCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + { key: "icon", label: "Icon" }, + { key: "collection_id", label: "Collection" }, + { key: "default", label: "Default" }, + { key: "archived", label: "Archived" }, + ], +}; diff --git a/packages/cli/src/output/views/transform-job.ts b/packages/cli/src/output/views/transform-job.ts new file mode 100644 index 0000000..ec064b1 --- /dev/null +++ b/packages/cli/src/output/views/transform-job.ts @@ -0,0 +1,16 @@ +import { type TransformJob, TransformJobCompact } from "@metabase/client/domain/transform-job"; + +import type { ResourceView } from "../view"; + +export const transformJobView: ResourceView = { + compactPick: TransformJobCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "schedule", label: "Schedule" }, + { key: "ui_display_type", label: "Display" }, + { key: "active", label: "Active" }, + { key: "built_in_type", label: "Built-in" }, + { key: "description", label: "Description" }, + ], +}; diff --git a/packages/cli/src/output/views/transform-tag.ts b/packages/cli/src/output/views/transform-tag.ts new file mode 100644 index 0000000..1ba2afb --- /dev/null +++ b/packages/cli/src/output/views/transform-tag.ts @@ -0,0 +1,12 @@ +import { type TransformTag, TransformTagCompact } from "@metabase/client/domain/transform-tag"; + +import type { ResourceView } from "../view"; + +export const transformTagView: ResourceView = { + compactPick: TransformTagCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "built_in_type", label: "Built-in" }, + ], +}; diff --git a/packages/cli/src/output/views/transform.test.ts b/packages/cli/src/output/views/transform.test.ts new file mode 100644 index 0000000..27217fd --- /dev/null +++ b/packages/cli/src/output/views/transform.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import type { Transform } from "@metabase/client/domain/transform"; + +import { MALFORMED_CELL } from "../table"; +import { transformView } from "./transform"; + +function renderCell(key: keyof Transform & string, value: unknown): string { + const column = transformView.tableColumns.find((candidate) => candidate.key === key); + if (column?.format === undefined) { + throw new Error(`transformView declares no formatter for "${key}"`); + } + return column.format(value); +} + +describe("transformView target cell", () => { + it("renders a schema-qualified table target as schema.name", () => { + expect(renderCell("target", { type: "table", schema: "public", name: "orders_daily" })).toBe( + "public.orders_daily", + ); + }); + + it("renders a target carrying no schema as the bare table name", () => { + expect(renderCell("target", { type: "table", schema: null, name: "orders_daily" })).toBe( + "orders_daily", + ); + }); + + // A target type this Metabase writes and the CLI does not know misses the discriminated union. + // Blanking the cell would read as "this transform writes nowhere". + it("renders a target that fails its schema as the malformed marker", () => { + expect( + renderCell("target", { type: "materialized-view", schema: "public", name: "orders_daily" }), + ).toBe(MALFORMED_CELL); + }); +}); diff --git a/packages/cli/src/output/views/transform.ts b/packages/cli/src/output/views/transform.ts new file mode 100644 index 0000000..8cadb28 --- /dev/null +++ b/packages/cli/src/output/views/transform.ts @@ -0,0 +1,44 @@ +import { + type Transform, + TransformCompact, + type TransformRun, + TransformRunCompact, + TransformTarget, +} from "@metabase/client/domain/transform"; + +import { MALFORMED_CELL } from "../table"; +import type { ResourceView } from "../view"; + +function formatTarget(value: unknown): string { + const parsed = TransformTarget.safeParse(value); + if (!parsed.success) { + return MALFORMED_CELL; + } + const { schema, name } = parsed.data; + return schema ? `${schema}.${name}` : name; +} + +export const transformRunView: ResourceView = { + compactPick: TransformRunCompact, + tableColumns: [ + { key: "id", label: "Run ID" }, + { key: "transform_id", label: "Transform" }, + { key: "status", label: "Status" }, + { key: "run_method", label: "Method" }, + { key: "start_time", label: "Started" }, + { key: "end_time", label: "Ended" }, + { key: "message", label: "Message" }, + ], +}; + +export const transformView: ResourceView = { + compactPick: TransformCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "source_type", label: "Source" }, + { key: "target", label: "Target", format: (value) => formatTarget(value) }, + { key: "target_db_id", label: "Target DB" }, + { key: "description", label: "Description" }, + ], +}; diff --git a/packages/cli/src/output/views/upload.ts b/packages/cli/src/output/views/upload.ts new file mode 100644 index 0000000..6609f4e --- /dev/null +++ b/packages/cli/src/output/views/upload.ts @@ -0,0 +1,19 @@ +import { UploadResult, UploadUpdateResult } from "@metabase/client/domain/upload"; + +import type { ResourceView } from "../view"; + +export const uploadResultView: ResourceView = { + compactPick: UploadResult, + tableColumns: [ + { key: "model_id", label: "Model ID" }, + { key: "table_id", label: "Table ID" }, + ], +}; + +export const uploadUpdateResultView: ResourceView = { + compactPick: UploadUpdateResult, + tableColumns: [ + { key: "table_id", label: "Table ID" }, + { key: "action", label: "Action" }, + ], +}; diff --git a/packages/cli/src/output/views/user.test.ts b/packages/cli/src/output/views/user.test.ts new file mode 100644 index 0000000..d321291 --- /dev/null +++ b/packages/cli/src/output/views/user.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { userView } from "./user"; + +describe("userView", () => { + it("declares id, email, name, and admin columns", () => { + expect(userView.tableColumns).toEqual([ + { key: "id", label: "ID" }, + { key: "email", label: "Email" }, + { key: "common_name", label: "Name" }, + { key: "is_superuser", label: "Admin" }, + ]); + }); +}); diff --git a/packages/cli/src/output/views/user.ts b/packages/cli/src/output/views/user.ts new file mode 100644 index 0000000..e91b5a7 --- /dev/null +++ b/packages/cli/src/output/views/user.ts @@ -0,0 +1,13 @@ +import { type CurrentUser, CurrentUserCompact } from "@metabase/client/domain/user"; + +import type { ResourceView } from "../view"; + +export const userView: ResourceView = { + compactPick: CurrentUserCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "email", label: "Email" }, + { key: "common_name", label: "Name" }, + { key: "is_superuser", label: "Admin" }, + ], +}; diff --git a/packages/cli/src/output/window.test.ts b/packages/cli/src/output/window.test.ts new file mode 100644 index 0000000..2004a0c --- /dev/null +++ b/packages/cli/src/output/window.test.ts @@ -0,0 +1,494 @@ +import { assert, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { ConfigError } from "@metabase/client/errors"; +import { + type ClientCredentials, + createTransport, + type Transport, +} from "@metabase/client/http/transport"; +import { type Page, paginatePages } from "@metabase/client/paginate"; +import { jsonResponse, TEST_USER_AGENT } from "@metabase/client/testing/fetch-capture"; + +import { projectForList } from "./projection"; +import { DEFAULT_MAX_BYTES, FULL_RANGE, type ListOptions, type ListRange } from "./types"; +import type { ResourceView } from "./view"; +import { + collectForOutput, + type PageRequest, + type PageSource, + windowList, + windowServerPage, +} from "./window"; + +interface Item { + id: number; + name: string; +} + +// Metabase's own list default, and what `paginatePages` falls back to when a caller sizes no page. +const SOURCE_PAGE_SIZE = 50; + +const itemView: ResourceView = { + compactPick: z.object({ id: z.number().int(), name: z.string() }).strip(), + tableColumns: [{ key: "id" }, { key: "name" }], +}; + +function makeItems(count: number, start = 0): Item[] { + return Array.from({ length: count }, (_, index) => ({ + id: start + index, + name: `item-${start + index}`, + })); +} + +function opts(range: ListRange, maxBytes = DEFAULT_MAX_BYTES): ListOptions { + return { format: "json", full: false, fields: undefined, maxBytes, range }; +} + +// The pull stops at the end of whichever page holds the row that spends the budget, so the page +// count follows from the projected row size. Measuring it here through the same `projectForList` +// production measures with keeps a change to the fixture's strings from silently retargeting the +// expectation, which a bare literal would. +function pagesToSpendBudget( + items: readonly Item[], + listOpts: ListOptions, + pageSize: number, +): number { + let bytes = 0; + let consumed = 0; + for (const item of items) { + bytes += Buffer.byteLength(JSON.stringify(projectForList(item, itemView, listOpts)), "utf8"); + consumed += 1; + if (bytes > listOpts.maxBytes) { + break; + } + } + return Math.ceil(consumed / pageSize); +} + +describe("windowList", () => { + it("returns the whole array and reports no continuation", () => { + expect(windowList(makeItems(3), FULL_RANGE)).toEqual({ + data: makeItems(3), + returned: 3, + offset: 0, + total: 3, + has_more: false, + next_offset: null, + }); + }); + + it("slices to the limit and points at the next item", () => { + expect(windowList(makeItems(10), { offset: 0, limit: 4 })).toEqual({ + data: makeItems(4), + returned: 4, + offset: 0, + limit: 4, + total: 10, + has_more: true, + next_offset: 4, + }); + }); + + it("resumes from an offset and reports exhaustion at the tail", () => { + expect(windowList(makeItems(10), { offset: 8, limit: 4 })).toEqual({ + data: makeItems(2, 8), + returned: 2, + offset: 8, + limit: 4, + total: 10, + has_more: false, + next_offset: null, + }); + }); + + it("ends the walk on an empty window rather than pointing back at the same offset", () => { + expect(windowList(makeItems(10), { offset: 3, limit: 0 })).toEqual({ + data: [], + returned: 0, + offset: 3, + limit: 0, + total: 10, + has_more: false, + next_offset: null, + }); + }); + + it("reports the server's count and a continuation when the endpoint held back rows", () => { + expect(windowList(makeItems(3), FULL_RANGE, 100)).toEqual({ + data: makeItems(3), + returned: 3, + offset: 0, + total: 100, + has_more: true, + next_offset: 3, + }); + }); + + it("keeps pointing at the rows in hand when the server's count is smaller than the array", () => { + expect(windowList(makeItems(10), { offset: 0, limit: 4 }, 3)).toEqual({ + data: makeItems(4), + returned: 4, + offset: 0, + limit: 4, + total: 3, + has_more: true, + next_offset: 4, + }); + }); + + it("reads a null count as the endpoint reporting no count at all", () => { + const items = makeItems(10); + const range: ListRange = { offset: 0, limit: 4 }; + expect(windowList(items, range, null)).toEqual(windowList(items, range)); + }); + + it("returns nothing when the offset is past the end", () => { + expect(windowList(makeItems(3), { offset: 99, limit: undefined })).toEqual({ + data: [], + returned: 0, + offset: 99, + total: 3, + has_more: false, + next_offset: null, + }); + }); +}); + +describe("windowServerPage", () => { + it("trusts the server total to decide whether more remain", () => { + expect(windowServerPage(makeItems(20), 137, { offset: 20, limit: 20 })).toEqual({ + data: makeItems(20), + returned: 20, + offset: 20, + limit: 20, + total: 137, + has_more: true, + next_offset: 40, + }); + }); + + it("ends the walk when the server reports a total but hands back no rows", () => { + expect(windowServerPage([], 137, { offset: 40, limit: 20 })).toEqual({ + data: [], + returned: 0, + offset: 40, + limit: 20, + total: 137, + has_more: false, + next_offset: null, + }); + }); + + it("reports no continuation once the window reaches the total", () => { + expect(windowServerPage(makeItems(7), 27, { offset: 20, limit: 20 })).toEqual({ + data: makeItems(7), + returned: 7, + offset: 20, + limit: 20, + total: 27, + has_more: false, + next_offset: null, + }); + }); +}); + +interface SourceHandle { + source: PageSource; + pagesPulled: () => number; + requested: () => PageRequest | undefined; +} + +// One request per page, each clamped to the requested `pageSize` and the whole walk clamped to +// `max`, so a request count measured here is a request count over the wire. What it does not model +// is `paginatePages` ending a walk on the server's own count; a property that turns on that ending +// belongs in the composition suite below, driven by the real walker. +function pagedSource(items: Item[], total: number | null, defaultPageSize: number): SourceHandle { + let pulled = 0; + let request: PageRequest | undefined; + const source: PageSource = (incoming) => { + request = incoming; + const pageSize = incoming.pageSize ?? defaultPageSize; + return (async function* stream() { + const cap = Math.min(items.length, incoming.max ?? items.length); + for (let offset = 0; offset < cap; offset += pageSize) { + pulled += 1; + yield { items: items.slice(offset, Math.min(offset + pageSize, cap)), total }; + } + })(); + }; + return { source, pagesPulled: () => pulled, requested: () => request }; +} + +describe("collectForOutput", () => { + it("drains a small source and reports no continuation", async () => { + const handle = pagedSource(makeItems(3), 3, 50); + + const envelope = await collectForOutput(handle.source, itemView, opts(FULL_RANGE)); + + expect(envelope).toEqual({ + data: makeItems(3), + returned: 3, + offset: 0, + total: 3, + has_more: false, + next_offset: null, + }); + }); + + it("stops pulling pages once the output budget is spent instead of draining 200 pages", async () => { + const items = makeItems(10_000); + const listOpts = opts(FULL_RANGE); + const handle = pagedSource(items, items.length, SOURCE_PAGE_SIZE); + const expectedPages = pagesToSpendBudget(items, listOpts, SOURCE_PAGE_SIZE); + const pulled = items.slice(0, expectedPages * SOURCE_PAGE_SIZE); + + const envelope = await collectForOutput(handle.source, itemView, listOpts); + + expect(handle.pagesPulled()).toBe(expectedPages); + expect(envelope).toEqual({ + data: pulled, + returned: pulled.length, + offset: 0, + total: items.length, + has_more: true, + next_offset: pulled.length, + }); + }); + + it("asks the source for one item beyond the limit to prove more exist", async () => { + const handle = pagedSource(makeItems(100), null, 50); + + const envelope = await collectForOutput(handle.source, itemView, opts({ offset: 0, limit: 4 })); + + expect(handle.requested()).toEqual({ offset: 0, max: 5, pageSize: 5 }); + expect(envelope).toEqual({ + data: makeItems(4), + returned: 4, + offset: 0, + limit: 4, + total: null, + has_more: true, + next_offset: 4, + }); + }); + + it("reports no continuation when an unbounded source runs dry under the limit", async () => { + const handle = pagedSource(makeItems(3), null, 50); + + const envelope = await collectForOutput( + handle.source, + itemView, + opts({ offset: 0, limit: 10 }), + ); + + expect(envelope).toEqual({ + data: makeItems(3), + returned: 3, + offset: 0, + limit: 10, + total: null, + has_more: false, + next_offset: null, + }); + }); + + it("carries the requested offset into the envelope and the continuation point", async () => { + const handle = pagedSource(makeItems(10), 500, 50); + + const envelope = await collectForOutput( + handle.source, + itemView, + opts({ offset: 200, limit: 5 }), + ); + + expect(envelope).toEqual({ + data: makeItems(5), + returned: 5, + offset: 200, + limit: 5, + total: 500, + has_more: true, + next_offset: 205, + }); + }); + + it("drains without a byte budget when the cap is disabled", async () => { + const handle = pagedSource(makeItems(500), null, SOURCE_PAGE_SIZE); + + const envelope = await collectForOutput(handle.source, itemView, opts(FULL_RANGE, 0)); + + expect(envelope).toEqual({ + data: makeItems(500), + returned: 500, + offset: 0, + total: null, + has_more: false, + next_offset: null, + }); + }); + + it("reports a continuation when the byte budget stops a source that reports no count", async () => { + const handle = pagedSource(makeItems(100), null, 10); + + const envelope = await collectForOutput(handle.source, itemView, opts(FULL_RANGE, 100)); + + expect(envelope).toEqual({ + data: makeItems(10), + returned: 10, + offset: 0, + total: null, + has_more: true, + next_offset: 10, + }); + }); + + it("adopts a total first reported by a later page", async () => { + const source = (): AsyncIterable> => + (async function* stream() { + yield { items: makeItems(2), total: null }; + yield { items: makeItems(2, 2), total: 4 }; + })(); + + const envelope = await collectForOutput(source, itemView, opts(FULL_RANGE)); + + expect(envelope).toEqual({ + data: makeItems(4), + returned: 4, + offset: 0, + total: 4, + has_more: false, + next_offset: null, + }); + }); + + it("returns an empty envelope when the source yields no pages at all", async () => { + const handle = pagedSource([], null, 50); + + const envelope = await collectForOutput(handle.source, itemView, opts(FULL_RANGE)); + + expect(envelope).toEqual({ + data: [], + returned: 0, + offset: 0, + total: null, + has_more: false, + next_offset: null, + }); + }); + + it("fetches a page-size-multiple limit and its probe item in a single request", async () => { + const handle = pagedSource(makeItems(1000), null, 50); + + await collectForOutput(handle.source, itemView, opts({ offset: 0, limit: 50 })); + + expect(handle.pagesPulled()).toBe(1); + }); + + it("keeps a very large limit arriving in several pages rather than one", async () => { + const handle = pagedSource(makeItems(1000), null, 50); + + await collectForOutput(handle.source, itemView, opts({ offset: 0, limit: 500 }, 0)); + + expect(handle.pagesPulled()).toBe(3); + }); + + it("enriches an envelope-relative --fields path with the item-relative hint", async () => { + const handle = pagedSource(makeItems(3), 3, 50); + const listOpts: ListOptions = { ...opts(FULL_RANGE), fields: ["data.id"] }; + + const error = await collectForOutput(handle.source, itemView, listOpts).catch( + (thrown: unknown) => thrown, + ); + + assert(error instanceof ConfigError, "expected a ConfigError"); + expect(error.message).toBe( + 'unknown field path: "data.id" — on list commands --fields paths are relative to each item in `data`, not the envelope. Drop the `data.` prefix (e.g. use `id` instead of `data.id`).', + ); + }); +}); + +const CREDENTIALS: ClientCredentials = { + url: "https://m.example.com", + credential: { kind: "apiKey", apiKey: "mb_test_key" }, +}; + +const ItemSchema = z.object({ id: z.number().int(), name: z.string() }); + +function requestUrl(input: Parameters[0]): string { + if (typeof input === "string") { + return input; + } + return input instanceof URL ? input.toString() : input.url; +} + +// A list endpoint that honours `limit` and `offset` and answers `reportedTotal` as its count. An +// honest server reports the rows it holds; a smaller number is a count its own rows disprove. +function pagingTransport(items: Item[], reportedTotal: number): Transport { + const fetchImpl: typeof fetch = async (input) => { + const params = new URL(requestUrl(input)).searchParams; + const offset = Number(params.get("offset")); + const limit = Number(params.get("limit")); + return jsonResponse({ data: items.slice(offset, offset + limit), total: reportedTotal }); + }; + return createTransport(CREDENTIALS, { userAgent: TEST_USER_AGENT, fetchImpl }); +} + +function pageSourceOver(transport: Transport): PageSource { + return (request) => paginatePages(transport, "/api/item", ItemSchema, request); +} + +// The continuation an agent follows is a property of the composition, not of `collectForOutput` +// alone: `paginatePages` decides when a walk is over, and a fake standing in for it decides that +// question in the test's favour. These drive the real walker over a scripted endpoint. +describe("collectForOutput over paginatePages", () => { + it("keeps the continuation the walk proved when the server total under-counts the rows", async () => { + const source = pageSourceOver(pagingTransport(makeItems(6), 3)); + + const envelope = await collectForOutput(source, itemView, opts({ offset: 0, limit: 5 })); + + expect(envelope).toEqual({ + data: makeItems(5), + returned: 5, + offset: 0, + limit: 5, + total: 3, + has_more: true, + next_offset: 5, + }); + }); + + // The request `collectForOutput` hands a source carries the window's start, so forwarding it + // whole is what makes `--offset` reach the endpoint. A source that rebuilds the request field by + // field can drop it and silently re-serve the first page as if it were the resumption point. + it("resumes the walk from the requested offset", async () => { + const source = pageSourceOver(pagingTransport(makeItems(10), 10)); + + const envelope = await collectForOutput(source, itemView, opts({ offset: 3, limit: 2 })); + + expect(envelope).toEqual({ + data: makeItems(2, 3), + returned: 2, + offset: 3, + limit: 2, + total: 10, + has_more: true, + next_offset: 5, + }); + }); + + it("hands back every row the walk found when the server total stopped short of them", async () => { + const source = pageSourceOver(pagingTransport(makeItems(120), 3)); + + const envelope = await collectForOutput(source, itemView, opts(FULL_RANGE)); + + expect(envelope).toEqual({ + data: makeItems(120), + returned: 120, + offset: 0, + total: 3, + has_more: false, + next_offset: null, + }); + }); +}); diff --git a/packages/cli/src/output/window.ts b/packages/cli/src/output/window.ts new file mode 100644 index 0000000..eeaa50e --- /dev/null +++ b/packages/cli/src/output/window.ts @@ -0,0 +1,133 @@ +import type { Page, PaginateOptions } from "@metabase/client/paginate"; + +import { projectForList } from "./projection"; +import type { ListEnvelope, ListOptions, ListRange } from "./types"; +import type { ResourceView } from "./view"; + +// What `collectForOutput` asks a page stream for. `max` is the item ceiling — one beyond the +// caller's window, because getting that item back is what proves more items exist. `pageSize` is +// how many to fetch per request; an absent one leaves the stream's own default in place. The shape +// is the client's own page options minus what the caller of a stream supplies, so a source forwards +// the request whole rather than copying fields across and stranding rows by dropping `offset`. +export interface PageRequest extends Omit { + offset: number; +} + +export type PageSource = (request: PageRequest) => AsyncIterable>; + +// A caller-sized window is fetched in one request, probe item included: at the 50-row default +// page size, `--limit 50` otherwise spends a whole round trip on the single row that proves more +// remain, and `--limit 100`/`--limit 150` do the same. The ceiling keeps a very large `--limit` +// from arriving as one page the byte budget can no longer stop part-way through. +const MAX_REQUEST_PAGE_SIZE = 200; + +// The endpoint returned everything, so the window is a client-side slice. `serverTotal` is the +// count the endpoint reported alongside those rows: an endpoint that reports more rows than it +// handed over has handed over a partial result, and without the count the slice would report +// itself complete. `null` and an omitted argument both say the endpoint reports no count, so a +// caller holding an optional count can forward it as it stands. +export function windowList( + items: T[], + range: ListRange, + serverTotal?: number | null, +): ListEnvelope { + const held = items.length; + const start = Math.min(range.offset, held); + const end = range.limit === undefined ? held : Math.min(start + range.limit, held); + const total = serverTotal ?? held; + // Rows past the window are counted in the array, rows the endpoint withheld only in its count. + // Reading both off `total` would compare an array index against a server-wide number, and a + // count smaller than the rows in hand would then bury the tail of the array. + const beyondWindow = end < held; + const withheldByServer = held < total; + return buildEnvelope(items.slice(start, end), range, total, beyondWindow || withheldByServer); +} + +// For endpoints that applied the window themselves: `data` is already the requested slice, and +// `total` is the server's count across the whole result set. An endpoint that pages without +// reporting a count cannot use this helper — it has no way to know whether more remain, so it +// belongs on `collectForOutput`, which learns the answer by pulling one item past the window. +export function windowServerPage(data: T[], total: number, range: ListRange): ListEnvelope { + return buildEnvelope(data, range, total, range.offset + data.length < total); +} + +// Pulls only as far as the output budget can display, so a 10k-item collection stops within a +// page of the cap rather than draining a full 200 pages the cap then discards. A window the +// caller sized is fetched in one request; without a `--limit` the walk still advances a page at a +// time, because only the fetched rows reveal how many of them the budget buys — so an unbounded +// listing costs roughly `budget / page bytes` requests. Items beyond the budget are still handed +// to the cap, which trims them and reports the resumption point. The budget is measured over rows +// alone, which under-counts the rendered envelope: stopping a page early is free, since the cap +// re-trims and recomputes the resumption point anyway. +export async function collectForOutput( + source: PageSource, + view: ResourceView, + opts: ListOptions, +): Promise> { + const { range } = opts; + const budget = opts.maxBytes <= 0 ? Number.POSITIVE_INFINITY : opts.maxBytes; + const fetchMax = range.limit === undefined ? undefined : range.limit + 1; + const request: PageRequest = { + offset: range.offset, + ...(fetchMax !== undefined && { + max: fetchMax, + pageSize: Math.min(fetchMax, MAX_REQUEST_PAGE_SIZE), + }), + }; + + const items: T[] = []; + let total: number | null = null; + let bytes = 0; + let exhausted = true; + + for await (const page of source(request)) { + total = page.total ?? total; + for (const item of page.items) { + items.push(item); + bytes += measure(item, view, opts); + } + const budgetSpent = bytes > budget; + const capReached = fetchMax !== undefined && items.length >= fetchMax; + if (budgetSpent || capReached) { + exhausted = false; + break; + } + } + + const limit = range.limit; + const data = limit !== undefined && items.length > limit ? items.slice(0, limit) : items; + // Only a stream that ran dry proves there is nothing left. Breaking early — on the byte budget + // or on the extra item pulled past the window — means more may remain no matter what `total` + // claims, and a server count that disagrees is the count that is wrong. + return buildEnvelope(data, range, total, !exhausted); +} + +// `moreAvailable` is the caller's finding, not a derivation from `total`: over-reporting costs one +// wasted request, while under-reporting strands rows the caller can never ask for again. +function buildEnvelope( + data: T[], + range: ListRange, + total: number | null, + moreAvailable: boolean, +): ListEnvelope { + const returned = data.length; + // Without a row in hand there is nowhere to resume from: `next_offset` would equal `offset` + // and the caller would re-request the window it just got. An empty window ends the walk. + const hasMore = returned > 0 && moreAvailable; + return { + data, + returned, + offset: range.offset, + ...(range.limit !== undefined && { limit: range.limit }), + total, + has_more: hasMore, + next_offset: hasMore ? range.offset + returned : null, + }; +} + +// Measured through the same projection the renderer will apply, `--json` and text alike: a full +// API object can be ten times its compact view, and estimating on the raw shape would stop the +// pull long before the output budget is actually spent. +function measure(item: T, view: ResourceView, opts: ListOptions): number { + return Buffer.byteLength(JSON.stringify(projectForList(item, view, opts)), "utf8"); +} diff --git a/src/runtime/body.test.ts b/packages/cli/src/runtime/body.test.ts similarity index 98% rename from src/runtime/body.test.ts rename to packages/cli/src/runtime/body.test.ts index 7957012..1235230 100644 --- a/src/runtime/body.test.ts +++ b/packages/cli/src/runtime/body.test.ts @@ -6,7 +6,7 @@ import { Readable } from "node:stream"; import { afterAll, afterEach, assert, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { z } from "zod"; -import { ConfigError, ValidationError } from "../core/errors"; +import { ConfigError, ValidationError } from "@metabase/client/errors"; import { readBody } from "./body"; interface MockStdin { diff --git a/src/runtime/body.ts b/packages/cli/src/runtime/body.ts similarity index 85% rename from src/runtime/body.ts rename to packages/cli/src/runtime/body.ts index 90f4262..c511bff 100644 --- a/src/runtime/body.ts +++ b/packages/cli/src/runtime/body.ts @@ -1,11 +1,11 @@ import type { ZodType } from "zod"; -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; +import { parseJson } from "@metabase/client/json"; import { DEFAULT_FLAG_NAME, readInput, type InputSources } from "./input"; -import { parseJson } from "./json"; -export interface BodySources extends InputSources { +interface BodySources extends InputSources { source?: string | undefined; } diff --git a/src/runtime/citty.test.ts b/packages/cli/src/runtime/citty.test.ts similarity index 100% rename from src/runtime/citty.test.ts rename to packages/cli/src/runtime/citty.test.ts diff --git a/src/runtime/citty.ts b/packages/cli/src/runtime/citty.ts similarity index 93% rename from src/runtime/citty.ts rename to packages/cli/src/runtime/citty.ts index 9b3606e..9f75ba7 100644 --- a/src/runtime/citty.ts +++ b/packages/cli/src/runtime/citty.ts @@ -1,6 +1,6 @@ import type { ArgsDef, CommandDef, CommandMeta, Resolvable, SubCommandsDef } from "citty"; -export type CittyValue = CommandMeta | ArgsDef | SubCommandsDef | CommandDef; +type CittyValue = CommandMeta | ArgsDef | SubCommandsDef | CommandDef; export async function resolveCitty( value: Resolvable | undefined, diff --git a/src/runtime/command-augment.test.ts b/packages/cli/src/runtime/command-augment.test.ts similarity index 97% rename from src/runtime/command-augment.test.ts rename to packages/cli/src/runtime/command-augment.test.ts index 2e9ffd1..dd702fd 100644 --- a/src/runtime/command-augment.test.ts +++ b/packages/cli/src/runtime/command-augment.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { BASELINE_CAPABILITIES } from "../core/version/capabilities"; +import { BASELINE_CAPABILITIES } from "@metabase/client/version/capabilities"; import { getMetabaseAugment, setMetabaseAugment, type MetabaseAugment } from "./command-augment"; diff --git a/src/runtime/command-augment.ts b/packages/cli/src/runtime/command-augment.ts similarity index 89% rename from src/runtime/command-augment.ts rename to packages/cli/src/runtime/command-augment.ts index 997249e..959f02c 100644 --- a/src/runtime/command-augment.ts +++ b/packages/cli/src/runtime/command-augment.ts @@ -1,6 +1,6 @@ import type { ZodType } from "zod"; -import type { Capabilities } from "./capabilities"; +import type { Capabilities } from "@metabase/client/version/capabilities"; export interface SkillPointer { skill: string; diff --git a/src/runtime/command-help.test.ts b/packages/cli/src/runtime/command-help.test.ts similarity index 83% rename from src/runtime/command-help.test.ts rename to packages/cli/src/runtime/command-help.test.ts index 4cca4eb..4faecb4 100644 --- a/src/runtime/command-help.test.ts +++ b/packages/cli/src/runtime/command-help.test.ts @@ -2,10 +2,10 @@ import { defineCommand } from "citty"; import { describe, expect, it } from "vitest"; import { z } from "zod"; +import { BASELINE_CAPABILITIES } from "@metabase/client/version/capabilities"; + import { defineMetabaseCommand } from "../commands/runtime"; import main from "../main"; - -import { BASELINE_CAPABILITIES } from "./capabilities"; import { buildHelpEntry, buildHelpIndex, resolveCommandPath } from "./command-help"; import type { CommandHelpEntry } from "./command-help"; @@ -13,6 +13,7 @@ describe("buildHelpIndex", () => { it("walks lazy subCommands into full-path index entries", async () => { const leaf = defineMetabaseCommand({ meta: { name: "leaf", description: "a leaf" }, + capabilities: {}, args: {}, run() { return; @@ -30,6 +31,8 @@ describe("buildHelpIndex", () => { const index = await buildHelpIndex(root, []); expect(index).toEqual({ + description: "root", + skills: [], commands: [{ command: "group leaf", description: "a leaf" }], }); }); @@ -37,6 +40,7 @@ describe("buildHelpIndex", () => { it("skips commands marked meta.hidden = true (and their subtrees)", async () => { const visible = defineMetabaseCommand({ meta: { name: "visible", description: "visible" }, + capabilities: {}, args: {}, run() { return; @@ -44,6 +48,7 @@ describe("buildHelpIndex", () => { }); const hidden = defineMetabaseCommand({ meta: { name: "hidden", description: "hidden", hidden: true }, + capabilities: {}, args: {}, run() { return; @@ -60,15 +65,40 @@ describe("buildHelpIndex", () => { const index = await buildHelpIndex(root, []); expect(index).toEqual({ + description: null, + skills: [], commands: [{ command: "visible", description: "visible" }], }); }); + + it("lists a leaf that declares no description as null rather than an empty sentence", async () => { + const leaf = defineCommand({ + meta: { name: "leaf" }, + args: {}, + run() { + return; + }, + }); + const root = defineCommand({ + meta: { name: "root", description: "root" }, + subCommands: { leaf: () => Promise.resolve(leaf) }, + }); + + const index = await buildHelpIndex(root, []); + + expect(index).toEqual({ + description: "root", + skills: [], + commands: [{ command: "leaf", description: null }], + }); + }); }); describe("buildHelpEntry", () => { it("converts citty args to entries with type, required, default, alias, options", async () => { const leaf = defineMetabaseCommand({ meta: { name: "leaf", description: "leaf" }, + capabilities: {}, args: { flag: { type: "string", description: "a flag", alias: "f" }, toggle: { type: "boolean", description: "toggle", default: false }, @@ -116,6 +146,7 @@ describe("buildHelpEntry", () => { it("emits the JSON Schema of outputSchema and forwards examples for metabase commands", async () => { const leaf = defineMetabaseCommand({ meta: { name: "leaf", description: "leaf" }, + capabilities: {}, args: {}, outputSchema: z.object({ ok: z.boolean(), name: z.string() }), examples: ["root leaf --json"], @@ -150,6 +181,7 @@ describe("buildHelpEntry", () => { it("emits the JSON Schema of inputSchema for commands that declare a body contract", async () => { const leaf = defineMetabaseCommand({ meta: { name: "create", description: "create" }, + capabilities: {}, args: {}, inputSchema: z.object({ name: z.string() }), run() { @@ -173,6 +205,7 @@ describe("buildHelpEntry", () => { it("forwards details for metabase commands that declare it and omits the key otherwise", async () => { const withDetails = defineMetabaseCommand({ meta: { name: "with", description: "short" }, + capabilities: {}, args: {}, details: "the long per-command knowledge", run() { @@ -181,6 +214,7 @@ describe("buildHelpEntry", () => { }); const without = defineMetabaseCommand({ meta: { name: "without", description: "short" }, + capabilities: {}, args: {}, run() { return; @@ -197,6 +231,7 @@ describe("buildHelpEntry", () => { it("forwards declared skill pointers and defaults to an empty array otherwise", async () => { const withSkills = defineMetabaseCommand({ meta: { name: "with", description: "short" }, + capabilities: {}, args: {}, skills: [{ skill: "mbql", purpose: "author the dataset_query" }], run() { @@ -205,6 +240,7 @@ describe("buildHelpEntry", () => { }); const without = defineMetabaseCommand({ meta: { name: "without", description: "short" }, + capabilities: {}, args: {}, run() { return; @@ -240,6 +276,29 @@ describe("buildHelpEntry", () => { capabilities: null, }); }); + + it("reports a leaf that declares no description as null rather than an empty sentence", async () => { + const leaf = defineCommand({ + meta: { name: "leaf" }, + args: {}, + run() { + return; + }, + }); + + const entry = await buildHelpEntry(leaf, ["leaf"]); + + expect(entry).toEqual({ + command: "leaf", + description: null, + skills: [], + examples: [], + args: [], + inputSchema: null, + outputSchema: null, + capabilities: null, + }); + }); }); const ALL_COMMANDS = [ @@ -406,6 +465,12 @@ describe("command tree contract", () => { expect(index.commands.map((entry) => entry.command)).toEqual(ALL_COMMANDS); }); + it("every leaf declares a description", async () => { + for (const entry of await allEntries()) { + expect(entry.description, `missing description for ${entry.command}`).not.toBeNull(); + } + }); + it("every leaf declares examples and an output schema", async () => { for (const entry of await allEntries()) { expect(entry.examples.length, `missing examples for ${entry.command}`).toBeGreaterThan(0); @@ -474,13 +539,36 @@ describe("command tree contract", () => { }); }); - it("reports null capabilities for local commands that never touch a Metabase server", async () => { + it("advertises a --limit default on search alone, so the shared flag description stays true", async () => { const entries = await allEntries(); - const localCapabilities = Object.fromEntries( + const withDefault = Object.fromEntries( entries - .filter((entry) => entry.command === "uuid" || entry.command === "upgrade") - .map((entry) => [entry.command, entry.capabilities]), + .map((entry) => [entry.command, entry.args.find((arg) => arg.name === "limit")] as const) + .filter(([, limit]) => limit?.default !== undefined), ); - expect(localCapabilities).toEqual({ uuid: null, upgrade: null }); + expect(withDefault).toEqual({ + search: { + name: "limit", + type: "string", + required: false, + description: "Max items to return", + default: "20", + }, + }); + }); + + it("reports null capabilities for exactly the commands that never touch a Metabase server", async () => { + const entries = await allEntries(); + const local = entries + .filter((entry) => entry.capabilities === null) + .map((entry) => entry.command); + expect(local.toSorted()).toEqual([ + "auth status", + "skills get", + "skills list", + "skills path", + "upgrade", + "uuid", + ]); }); }); diff --git a/src/runtime/command-help.ts b/packages/cli/src/runtime/command-help.ts similarity index 81% rename from src/runtime/command-help.ts rename to packages/cli/src/runtime/command-help.ts index abcb89b..43b081e 100644 --- a/src/runtime/command-help.ts +++ b/packages/cli/src/runtime/command-help.ts @@ -1,11 +1,11 @@ import type { ArgDef, ArgsDef, CommandDef, CommandMeta } from "citty"; import { z } from "zod"; -import { Capabilities } from "./capabilities"; +import { Capabilities } from "@metabase/client/version/capabilities"; import { resolveCitty, toAliasArray } from "./citty"; import { getMetabaseAugment, type MetabaseAugment } from "./command-augment"; -export const CommandHelpArg = z.object({ +const CommandHelpArg = z.object({ name: z.string(), type: z.enum(["string", "boolean", "positional", "enum"]), required: z.boolean(), @@ -14,17 +14,17 @@ export const CommandHelpArg = z.object({ alias: z.array(z.string()).optional(), options: z.array(z.string()).optional(), }); -export type CommandHelpArg = z.infer; +type CommandHelpArg = z.infer; -export const CommandHelpSkill = z.object({ +const CommandHelpSkill = z.object({ skill: z.string(), purpose: z.string(), }); -export type CommandHelpSkill = z.infer; +type CommandHelpSkill = z.infer; export const CommandHelpEntry = z.object({ command: z.string(), - description: z.string(), + description: z.string().nullable(), details: z.string().optional(), skills: z.array(CommandHelpSkill), examples: z.array(z.string()), @@ -35,13 +35,15 @@ export const CommandHelpEntry = z.object({ }); export type CommandHelpEntry = z.infer; -export const CommandIndexEntry = z.object({ +const CommandIndexEntry = z.object({ command: z.string(), - description: z.string(), + description: z.string().nullable(), }); -export type CommandIndexEntry = z.infer; +type CommandIndexEntry = z.infer; export const CommandHelpIndex = z.object({ + description: z.string().nullable(), + skills: z.array(CommandHelpSkill), commands: z.array(CommandIndexEntry), }); export type CommandHelpIndex = z.infer; @@ -82,7 +84,13 @@ export async function buildHelpIndex( cmd: CommandDef, path: string[], ): Promise { - return { commands: await walk(cmd, path) }; + const meta = await resolveCitty(cmd.meta); + const augment = getMetabaseAugment(cmd) ?? EMPTY_AUGMENT; + return { + description: readDescription(meta), + skills: Array.from(augment.skills), + commands: await walk(cmd, path), + }; } export async function resolveCommandPath( @@ -124,11 +132,11 @@ async function walk( return [{ command: path.join(" "), description: readDescription(meta) }]; } -function readDescription(meta: CommandMeta | undefined): string { - if (meta === undefined) { - return ""; - } - return typeof meta.description === "string" ? meta.description : ""; +// citty leaves `description` optional, so a node can legitimately have nothing to say. Absence is +// `null`: an empty sentence reads as one the command declared, and a reader cannot tell the two +// apart. +function readDescription(meta: CommandMeta | undefined): string | null { + return typeof meta?.description === "string" ? meta.description : null; } function convertArgs(args: ArgsDef): CommandHelpArg[] { diff --git a/src/runtime/csv.test.ts b/packages/cli/src/runtime/csv.test.ts similarity index 100% rename from src/runtime/csv.test.ts rename to packages/cli/src/runtime/csv.test.ts diff --git a/src/runtime/csv.ts b/packages/cli/src/runtime/csv.ts similarity index 96% rename from src/runtime/csv.ts rename to packages/cli/src/runtime/csv.ts index 448193b..80570b4 100644 --- a/src/runtime/csv.ts +++ b/packages/cli/src/runtime/csv.ts @@ -1,6 +1,6 @@ import type { ZodEnum } from "zod"; -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; export function parseCsv(raw: string): string[] { return raw diff --git a/src/runtime/input.test.ts b/packages/cli/src/runtime/input.test.ts similarity index 91% rename from src/runtime/input.test.ts rename to packages/cli/src/runtime/input.test.ts index b778211..90899bd 100644 --- a/src/runtime/input.test.ts +++ b/packages/cli/src/runtime/input.test.ts @@ -5,7 +5,7 @@ import { Readable } from "node:stream"; import { afterAll, afterEach, assert, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; import { readInput } from "./input"; interface MockStdin { @@ -163,15 +163,6 @@ describe("readInput precedence", () => { ); }); - it("names the concrete body flag in the required error when flagName is provided", async () => { - const error = await readInput({ flagName: "--value" }).catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(ConfigError); - assert(error instanceof ConfigError, "expected ConfigError"); - expect(error.message).toBe( - "input required: provide one of --value, --file, stdin, or a positional argument", - ); - }); - it("returns empty string when not required and all sources are empty", async () => { expect(await readInput({ required: false })).toBe(""); }); diff --git a/src/runtime/input.ts b/packages/cli/src/runtime/input.ts similarity index 86% rename from src/runtime/input.ts rename to packages/cli/src/runtime/input.ts index 5a9b4d1..a6a0b76 100644 --- a/src/runtime/input.ts +++ b/packages/cli/src/runtime/input.ts @@ -1,21 +1,18 @@ import { readFile } from "node:fs/promises"; import { setTimeout as delay } from "node:timers/promises"; -import { ConfigError, isNotFoundError } from "../core/errors"; +import { ConfigError, isFileNotFoundError } from "@metabase/client/errors"; export interface InputSources { flag?: string | undefined; file?: string | undefined; positional?: string | undefined; required?: boolean | undefined; - flagName?: string | undefined; } export const DEFAULT_FLAG_NAME = "--body"; -function sourceList(flagName: string | undefined): string { - return `${flagName ?? DEFAULT_FLAG_NAME}, --file, stdin, or a positional argument`; -} +const SOURCE_LIST = `${DEFAULT_FLAG_NAME}, --file, stdin, or a positional argument`; export async function readInput(sources: InputSources): Promise { if (sources.flag) { @@ -38,7 +35,7 @@ export async function readInput(sources: InputSources): Promise { const required = sources.required ?? true; if (required) { - throw new ConfigError(`input required: provide one of ${sourceList(sources.flagName)}`); + throw new ConfigError(`input required: provide one of ${SOURCE_LIST}`); } return ""; } @@ -50,7 +47,7 @@ async function readFileSource(path: string): Promise { try { return await readFile(path, "utf8"); } catch (error) { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { throw fileNotFoundError(path); } throw error; diff --git a/packages/cli/src/runtime/interrupt.test.ts b/packages/cli/src/runtime/interrupt.test.ts new file mode 100644 index 0000000..e2c469f --- /dev/null +++ b/packages/cli/src/runtime/interrupt.test.ts @@ -0,0 +1,97 @@ +import { afterEach, assert, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AbortError } from "@metabase/client/errors"; + +import { abortOnInterrupt, INTERRUPT_EXIT_CODE, interruptSignal } from "./interrupt"; + +describe("abortOnInterrupt", () => { + it("aborts the shared signal with the taxonomy's interrupt reason", () => { + expect(interruptSignal.aborted).toBe(false); + + abortOnInterrupt(); + + expect(interruptSignal.aborted).toBe(true); + const reason: unknown = interruptSignal.reason; + assert(reason instanceof AbortError, "expected AbortError"); + expect([reason.message, reason.category]).toEqual(["interrupted", "abort"]); + }); +}); + +describe("INTERRUPT_EXIT_CODE", () => { + it("is the code the CLI maps the interrupt reason's own category to", () => { + expect(INTERRUPT_EXIT_CODE).toBe(130); + }); +}); + +describe("installInterruptHandler", () => { + const FORCED_EXIT_GRACE_MS = 2_000; + + interface InstalledHandler { + listener: NodeJS.SignalsListener; + signal: AbortSignal; + exits: number[]; + } + + let preexisting: readonly unknown[] = []; + + function addedSigintListeners(): NodeJS.SignalsListener[] { + return process + .listeners("SIGINT") + .filter((listener): listener is NodeJS.SignalsListener => !preexisting.includes(listener)); + } + + // The interrupt module is reloaded per test so each starts on an unaborted signal — the + // process-wide singleton the other suites use latches on the first abort and never resets. + async function install(): Promise { + vi.resetModules(); + const fresh = await import("./interrupt"); + const exits: number[] = []; + fresh.installInterruptHandler((code) => exits.push(code)); + const added = addedSigintListeners(); + expect(added).toHaveLength(1); + const [listener] = added; + assert(listener !== undefined, "expected a SIGINT listener"); + return { listener, signal: fresh.interruptSignal, exits }; + } + + beforeEach(() => { + vi.useFakeTimers(); + preexisting = process.listeners("SIGINT"); + }); + + afterEach(() => { + for (const listener of addedSigintListeners()) { + process.removeListener("SIGINT", listener); + } + vi.useRealTimers(); + }); + + it("aborts the shared signal on the first SIGINT, leaving the command to unwind itself", async () => { + const { listener, signal, exits } = await install(); + + listener("SIGINT"); + + expect(signal.aborted).toBe(true); + expect(exits).toEqual([]); + }); + + it("ends the process once the grace period expires with the command still running", async () => { + const { listener, exits } = await install(); + + listener("SIGINT"); + vi.advanceTimersByTime(FORCED_EXIT_GRACE_MS - 1); + expect(exits).toEqual([]); + vi.advanceTimersByTime(1); + + expect(exits).toEqual([130]); + }); + + it("ends the process on the second SIGINT rather than waiting out the grace period", async () => { + const { listener, exits } = await install(); + + listener("SIGINT"); + listener("SIGINT"); + + expect(exits).toEqual([130]); + }); +}); diff --git a/packages/cli/src/runtime/interrupt.ts b/packages/cli/src/runtime/interrupt.ts new file mode 100644 index 0000000..ed04b84 --- /dev/null +++ b/packages/cli/src/runtime/interrupt.ts @@ -0,0 +1,38 @@ +import { AbortError } from "@metabase/client/errors"; + +import { exitCodeFor } from "../output/error"; + +const INTERRUPT_REASON = new AbortError("interrupted"); + +export const INTERRUPT_EXIT_CODE = exitCodeFor(INTERRUPT_REASON.category); + +const controller = new AbortController(); + +// One controller for the whole process: every client request, poll loop and registry lookup +// composes this signal, so N concurrent operations share a single Ctrl-C rather than each +// arranging their own. +export const interruptSignal: AbortSignal = controller.signal; + +export function abortOnInterrupt(): void { + if (!controller.signal.aborted) { + controller.abort(INTERRUPT_REASON); + } +} + +// How long a command gets to unwind its own abort and report `interrupted` before the process is +// ended for it. Without the deadline a Ctrl-C arriving in a phase that watches no signal — the +// OAuth loopback server, a keyring read — is swallowed, because installing any SIGINT listener +// removes Node's default terminate-on-Ctrl-C. +const FORCED_EXIT_GRACE_MS = 2_000; + +// `exit` is the caller's: ending the process belongs to the CLI entry, not to this module. +export function installInterruptHandler(exit: (code: number) => void): void { + process.on("SIGINT", () => { + if (interruptSignal.aborted) { + exit(INTERRUPT_EXIT_CODE); + return; + } + abortOnInterrupt(); + setTimeout(() => exit(INTERRUPT_EXIT_CODE), FORCED_EXIT_GRACE_MS).unref(); + }); +} diff --git a/src/runtime/process.test.ts b/packages/cli/src/runtime/process.test.ts similarity index 100% rename from src/runtime/process.test.ts rename to packages/cli/src/runtime/process.test.ts diff --git a/src/runtime/process.ts b/packages/cli/src/runtime/process.ts similarity index 94% rename from src/runtime/process.ts rename to packages/cli/src/runtime/process.ts index 3f8f952..dd0918d 100644 --- a/src/runtime/process.ts +++ b/packages/cli/src/runtime/process.ts @@ -1,8 +1,8 @@ import { spawn } from "node:child_process"; -import { isNotFoundError } from "../core/errors"; +import { isFileNotFoundError } from "@metabase/client/errors"; -export interface ProcessRunOptions { +interface ProcessRunOptions { env?: NodeJS.ProcessEnv; cwd?: string; stdin?: string | Uint8Array; @@ -10,17 +10,17 @@ export interface ProcessRunOptions { shell?: boolean; } -export interface ProcessStreamOptions { +interface ProcessStreamOptions { shell?: boolean; } -export interface ProcessResult { +interface ProcessResult { stdout: string; stderr: string; exitCode: number | null; } -export interface ProcessBinaryResult { +interface ProcessBinaryResult { stdout: Uint8Array; stderr: string; exitCode: number | null; @@ -35,7 +35,7 @@ export class ProcessNotFoundError extends Error { } } -export class ProcessTimeoutError extends Error { +class ProcessTimeoutError extends Error { readonly command: string; readonly timeoutMs: number; constructor(command: string, timeoutMs: number) { @@ -75,7 +75,7 @@ function spawnAndCollect( stderr += chunk.toString("utf8"); }); child.on("error", (error: unknown) => { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { reject(new ProcessNotFoundError(command)); return; } @@ -140,7 +140,7 @@ export function streamProcess( ...(options.shell === true ? { shell: true } : {}), }); child.on("error", (error: unknown) => { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { reject(new ProcessNotFoundError(command)); return; } diff --git a/src/runtime/upload.test.ts b/packages/cli/src/runtime/upload.test.ts similarity index 72% rename from src/runtime/upload.test.ts rename to packages/cli/src/runtime/upload.test.ts index 2d72a0a..4dd28e3 100644 --- a/src/runtime/upload.test.ts +++ b/packages/cli/src/runtime/upload.test.ts @@ -4,9 +4,9 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { ConfigError } from "../core/errors"; +import { ConfigError } from "@metabase/client/errors"; -import { buildCsvFormData, readCsvFile, requireUploadFilePath } from "./upload"; +import { readCsvFile, requireUploadFilePath } from "./upload"; describe("requireUploadFilePath", () => { it("returns the path when present", () => { @@ -51,18 +51,3 @@ describe("readCsvFile", () => { await expect(readCsvFile(path)).rejects.toThrow(`--file not found: ${path}`); }); }); - -describe("buildCsvFormData", () => { - it("attaches the bytes under the file field with the given filename", () => { - const form = buildCsvFormData({ filename: "x.csv", bytes: new Uint8Array([104, 105]) }); - const entry = form.get("file"); - if (!(entry instanceof File)) { - throw new Error("expected a File part under 'file'"); - } - expect({ name: entry.name, size: entry.size, type: entry.type }).toEqual({ - name: "x.csv", - size: 2, - type: "text/csv", - }); - }); -}); diff --git a/src/runtime/upload.ts b/packages/cli/src/runtime/upload.ts similarity index 60% rename from src/runtime/upload.ts rename to packages/cli/src/runtime/upload.ts index e7a3b4f..26dae00 100644 --- a/src/runtime/upload.ts +++ b/packages/cli/src/runtime/upload.ts @@ -1,17 +1,11 @@ import { readFile } from "node:fs/promises"; import { basename } from "node:path"; -import { ConfigError, isNotFoundError } from "../core/errors"; +import { ConfigError, isFileNotFoundError } from "@metabase/client/errors"; +import type { CsvFile } from "@metabase/client/resources/csv-upload"; import { fileNotFoundError } from "./input"; -const CSV_CONTENT_TYPE = "text/csv"; - -export interface CsvFile { - filename: string; - bytes: Uint8Array; -} - export function requireUploadFilePath(file: string | undefined): string { if (typeof file !== "string" || file.trim() === "") { throw new ConfigError("provide the CSV file to upload with --file "); @@ -24,15 +18,9 @@ export async function readCsvFile(path: string): Promise { const bytes = await readFile(path); return { filename: basename(path), bytes }; } catch (error) { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { throw fileNotFoundError(path); } throw error; } } - -export function buildCsvFormData(file: CsvFile): FormData { - const form = new FormData(); - form.append("file", new Blob([file.bytes], { type: CSV_CONTENT_TYPE }), file.filename); - return form; -} diff --git a/src/runtime/yaml.test.ts b/packages/cli/src/runtime/yaml.test.ts similarity index 97% rename from src/runtime/yaml.test.ts rename to packages/cli/src/runtime/yaml.test.ts index f87a672..883ad27 100644 --- a/src/runtime/yaml.test.ts +++ b/packages/cli/src/runtime/yaml.test.ts @@ -1,7 +1,7 @@ import { assert, describe, expect, it } from "vitest"; import { z } from "zod"; -import { ConfigError, ValidationError } from "../core/errors"; +import { ConfigError, ValidationError } from "@metabase/client/errors"; import { parseYaml, parseYamlResult, stringifyYaml } from "./yaml"; const Envelope = z.object({ diff --git a/src/runtime/yaml.ts b/packages/cli/src/runtime/yaml.ts similarity index 90% rename from src/runtime/yaml.ts rename to packages/cli/src/runtime/yaml.ts index 96f71b2..ff26e53 100644 --- a/src/runtime/yaml.ts +++ b/packages/cli/src/runtime/yaml.ts @@ -1,13 +1,13 @@ import { parse, stringify, YAMLParseError } from "yaml"; import type { ZodType } from "zod"; -import { ConfigError, errorMessage, ValidationError } from "../core/errors"; +import { ConfigError, errorMessage, ValidationError } from "@metabase/client/errors"; -export interface ParseYamlOptions { +interface ParseYamlOptions { source?: string; } -export type ParseYamlResult = +type ParseYamlResult = | { ok: true; value: T } | { ok: false; error: ConfigError | ValidationError }; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..d3c1f7d --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*", "tsdown.config.ts"] +} diff --git a/tsdown.config.ts b/packages/cli/tsdown.config.ts similarity index 88% rename from tsdown.config.ts rename to packages/cli/tsdown.config.ts index 0cf5b03..ffe55fc 100644 --- a/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -2,8 +2,8 @@ import { defineConfig } from "tsdown"; export default defineConfig({ entry: ["src/cli.ts"], + noExternal: ["@metabase/client"], format: "esm", - dts: true, clean: true, shims: true, target: "node20.6", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md new file mode 100644 index 0000000..303eeaf --- /dev/null +++ b/packages/client/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +Notable changes to `@metabase/client`. + +The package is workspace-internal (`private: true`) and reaches no registry, so it carries no +released versions. `@metabase/cli` inlines it at build time, and the CLI's own release notes are what +a user of the CLI reads. Entries here accumulate against the first published release. + +## Unreleased + +The whole surface. Until a first release there is no baseline to diff against, so a change to this +package is recorded in its commit rather than here. diff --git a/packages/client/LICENSE b/packages/client/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/packages/client/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000..a450b28 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,428 @@ +# @metabase/client + +The Metabase API client behind [`@metabase/cli`](../../README.md). It owns the HTTP boundary, a Zod +schema per Metabase resource, the OAuth login flow, and the server version/capability model. + +The package is workspace-internal (`private: true`). `@metabase/cli` consumes it from source and +inlines it at build time via tsdown's `noExternal`, so the published CLI has no runtime dependency +on it. + +`tsdown` here emits one ESM module and one declaration file per `exports` entry into `dist/`, and the +map names those rather than the source. Installing the packed tarball therefore gets JavaScript Node +can load and declarations `moduleResolution: node16` can follow, with no `@types/node` required. + +## Dependency budget + +Runtime dependencies are `zod` and `semver`. `zod` is a **peer** dependency: every schema on this +surface is a zod schema, so the consumer's zod is part of the contract and a resolver must be told to +reconcile the two rather than left free to add a second copy. A production module under `src/` imports +nothing beyond those two and `node:` builtins; test files may reach the root devDependencies. + +The package is also free of process I/O: it does not touch `process` at all — no filesystem access, +no subprocesses, no writes to the process streams, no `process.exit`, no signal handlers, no +interactive prompting. Cancellation, credential storage, prompting, and rendering belong to the host +application. `../../CLAUDE.md` carries this abstention and the package's internal layering — one +module per cross-cutting boundary — along with the reasoning behind each. + +That abstention extends to the process TLS trust store, and it is a behavioural difference worth +knowing about. Node trusts only its bundled Mozilla CA list unless told otherwise, so a Metabase +served behind a corporate proxy or a local development CA fails certificate verification. The CLI +merges the OS trust store into Node's defaults at startup; an embedder of this package gets no such +merge, because a library must not reconfigure its host's trust store. Run the host process with +`NODE_USE_SYSTEM_CA=1`, pass `NODE_EXTRA_CA_CERTS=`, or call `tls.setDefaultCACertificates()` +yourself if you need the same reach. + +## Authentication + +A `Credential` is either an API key or an OAuth grant, and a client takes one at construction. An API +key is the shortest path: mint one in Metabase under Settings → Authentication → API keys, and hand +it over as an `ApiKeyCredential`. + +```ts +import { createClient } from "@metabase/client"; + +const mb = createClient( + { url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } }, + { userAgent: "my-app/1.0.0" }, +); + +const me = await mb.user.current(); +console.log(me.email, me.is_superuser); +``` + +`userAgent` is required and is the caller's whole string — the client never prefixes, wraps or composes +it, so what an application declares is what Metabase sees on the wire. + +OAuth authenticates as a person rather than as a service account. `oauthLogin` runs the +authorization-code flow with PKCE against a loopback redirect: `openBrowser` and `onAuthorizeUrl` are +the caller's, so the client never decides how a URL reaches a user, and `clientName` is what dynamic +client registration persists on the user's Metabase. Access tokens expire and refresh tokens rotate, +so a long-lived client passes `refreshCredential` and keeps the newest grant. + +Binding a loopback listener and waiting on a browser is not something a harness can drive unattended, +so the block below is fenced `illustrative`: the extractor compiles it and stops there. + +```ts illustrative +import { createClient, oauthLogin, refreshOAuthCredential } from "@metabase/client"; + +const baseUrl = "https://metabase.example.com"; +const userAgent = "my-app/1.0.0"; + +let credential = await oauthLogin( + { baseUrl, userAgent, clientName: "My App" }, + { + openBrowser: async () => false, + onAuthorizeUrl: (url) => console.log(`Authorize this app at ${url}`), + now: () => Date.now(), + }, +); + +const mb = createClient( + { url: baseUrl, credential }, + { + userAgent, + refreshCredential: async () => { + credential = await refreshOAuthCredential(baseUrl, credential, Date.now(), userAgent); + return credential; + }, + }, +); +``` + +`revokeOAuthCredential(baseUrl, credential, userAgent)` ends the grant server-side, and answers +whether the server advertised a revocation endpoint at all. + +## Usage + +`createClient` is the entry point, and what it returns carries one namespace per Metabase resource — +each named after what Metabase calls the thing — reached as `mb..(…)`. Path +parameters come first, then Metabase's own field names, then the transport options. + +```ts +import { createClient } from "@metabase/client"; + +const mb = createClient( + { url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } }, + { userAgent: "my-app/1.0.0" }, +); + +const card = await mb.card.get(42); +console.log(card.id, card.name, card.type); +``` + +Every resource method is a `requestParsed(schema, path, opts)` call against a `domain/` schema, so a +response is a typed value or a throw from the error taxonomy below; `### Endpoints the client does not +model` reaches that method directly, for a path no namespace covers. + +### Paging a list endpoint + +Metabase list endpoints come in two shapes and the difference is per-endpoint, not uniform — check +the one you are calling before assuming either. `/api/card` is a bare array; `/api/database` and +`/api/collection/{id}/items` wrap their rows in `{ data, total }`. + +A method over an endpoint that answers in one response returns a `ListResult` — `{ data, total }` +whichever shape the endpoint sent, with `total` the server's own count where it reports one and +`null` where the rows in hand are all there are. + +```ts +import { createClient } from "@metabase/client"; + +const mb = createClient( + { url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } }, + { userAgent: "my-app/1.0.0" }, +); + +const cards = await mb.card.list({ f: "mine" }); +console.log(cards.data.length, cards.total); +``` + +A resource method whose endpoint pages answers an `AsyncIterable>` — `mb.collection.itemPages` +and `mb.transform.runPages` are the two — requesting `limit`/`offset` pages and yielding a `Page` +(`{ items: T[], total: number | null }`) per request, so the server's count reaches you instead of +being spent on loop control. + +```ts +import { createClient } from "@metabase/client"; + +const mb = createClient( + { url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } }, + { userAgent: "my-app/1.0.0" }, +); + +for await (const page of mb.collection.itemPages("root")) { + for (const item of page.items) { + console.log(item.id, item.name, page.total); + } +} +``` + +The walk behind them is `paginatePages` at `@metabase/client/paginate`, for an endpoint no resource +method covers. It requires one that both wraps its rows and honors `offset`: pointed at a bare array +it throws `ResponseShapeError`, and at an endpoint that ignores `offset` it throws `ConfigError` +rather than looping forever. + +### Writing + +A write method takes Metabase's own field names, described by the `CreateInput` or +`UpdateInput` schema sitting in the same `domain/` module as the resource. `create` answers +the full created resource, `update` patches only the fields its body carries, and `archive` is the +soft-delete Metabase models as an update rather than as its own endpoint. + +```ts +import { createClient, type CardCreateInput } from "@metabase/client"; + +const mb = createClient( + { url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } }, + { userAgent: "my-app/1.0.0" }, +); + +const body: CardCreateInput = { + name: "Orders per month", + type: "question", + display: "line", + visualization_settings: {}, + collection_id: 4, + dataset_query: { + "lib/type": "mbql/query", + database: 1, + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": 7, + aggregation: [["count", {}]], + breakout: [["field", { "temporal-unit": "month" }, 22]], + }, + ], + }, +}; + +const card = await mb.card.create(body); +await mb.card.update(card.id, { description: "Order count, grouped by month." }); +await mb.card.archive(card.id); +``` + +A write is never replayed on a failed status: the transport retries GET/HEAD/OPTIONS on a retryable +status code, and every other method only on a network or timeout failure. Where the endpoint behind a +write genuinely tolerates a resend, say so with `idempotent: true` in the trailing options. + +### Handling errors + +Everything the client throws is either a `MetabaseError` or a Node error it deliberately leaves alone. +`toMetabaseError` normalizes an unknown throw into the taxonomy; `isHttpNotFound` answers whether one +came off the wire as a 404; `isFileNotFoundError` answers the unrelated question of a file that is not +on disk. + +```ts +import { readFile } from "node:fs/promises"; + +import { + CardCreateInput, + createClient, + isFileNotFoundError, + isHttpNotFound, + parseJson, + toMetabaseError, +} from "@metabase/client"; + +const mb = createClient( + { url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } }, + { userAgent: "my-app/1.0.0" }, +); + +try { + const source = await readFile("./card.json", "utf8"); + await mb.card.create(parseJson(source, CardCreateInput, { source: "card.json" })); +} catch (error) { + if (isFileNotFoundError(error)) { + console.error("no such file: ./card.json"); + } else if (isHttpNotFound(error)) { + console.error("the collection this card names is gone"); + } else { + const failure = toMetabaseError(error); + console.error(failure.category, failure.userMessage, failure.isRetryable); + } +} +``` + +The two checks answer different questions and neither substitutes for the other: `isFileNotFoundError` +tests for a Node `ENOENT`, while an HTTP 404 arrives as an `HttpError` carrying `status: 404`. That +`HttpError` also carries a `kind` separating a route this Metabase does not serve from a row that is +gone, and `fieldErrors` for a 400 the server attributed to named request fields. + +### Cancellation + +The client registers no signal handler and reads no process state, so cancellation reaches it as an +`AbortSignal` the host owns. Three places take one and they compose: `ClientOptions.signal` cancels +every request a client makes, `RequestOptions.signal` cancels a single request, and +`PollOptions.signal` stops a wait loop. Each is folded together with the request timeout, so whichever +fires first ends the work. + +```ts +import { createClient, isTransformRunTerminal, pollUntil } from "@metabase/client"; + +const shutdown = new AbortController(); +process.on("SIGINT", () => shutdown.abort()); + +const mb = createClient( + { url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } }, + { userAgent: "my-app/1.0.0", signal: shutdown.signal }, +); + +const cards = await mb.card.list({ f: "mine" }, { signal: AbortSignal.timeout(5_000) }); + +const finished = await pollUntil( + async (signal) => mb.transform.getRun(42, { signal }), + (run) => isTransformRunTerminal(run.status), + { intervalMs: 1_000, backoff: "exponential", signal: shutdown.signal }, +); + +console.log(cards.data.length, finished.status); +``` + +A poll that ends because the caller's signal aborted rethrows that signal's own reason, so a +cancellation is never reported back as a polling `TimeoutError`. + +### Endpoints the client does not model + +`createClient` forwards `requestParsed`, `requestRaw` and `requestStream` off the transport it builds, +so an endpoint with no resource method behind it costs a schema and a path rather than a second HTTP +client. `requestParsed(schema, path, opts)` takes the schema first, and the path is joined to the +instance URL by concatenation, so a Metabase served under a subpath needs nothing extra. + +```ts +import { z } from "zod"; + +import { createClient } from "@metabase/client"; + +const PermissionsGroup = z + .object({ id: z.number().int(), name: z.string(), member_count: z.number().int() }) + .loose(); + +const mb = createClient( + { url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } }, + { userAgent: "my-app/1.0.0" }, +); + +const groups = await mb.requestParsed(z.array(PermissionsGroup), "/api/permissions/group"); +for (const group of groups) { + console.log(group.id, group.name, group.member_count); +} +``` + +`requestRaw` answers the `Response` for a caller that wants a header or a status code, and +`requestStream` a `ReadableStream` for a download. All three take the same `TransportRequestOptions`, +so `method`, `query`, `body` and `expectContentType` are set the way a resource method sets them. + +`@metabase/client` re-exports the curated surface below, and the samples above import from it. The +package manifest's `exports` map names what is also reachable directly at `@metabase/client/` — +`@metabase/client/http/transport`, `@metabase/client/testing/fake-client`, every file under `domain/` +and `resources/` — for what the barrel does not carry, which is how `paginatePages` is reached at +`@metabase/client/paginate`. A module the map does not name is package-internal and free to move. + +## Public surface + +### Client and transport + +`createClient(credentials, options)` is the entry point: it builds a transport and returns a +`MetabaseClient` forwarding the three request methods, so an endpoint the client does not model still +costs a consumer nothing. `MetabaseClient` is `ReturnType` rather than a written +interface, so it tracks the factory instead of freezing it. + +`createTransport(credentials, options)` returns the `Transport` underneath, with the same three +methods: `requestParsed(schema, path, opts)` (the only typed-JSON path), `requestRaw`, and +`requestStream`. The caller supplies its own `userAgent`; the client never invents a wire identity. +Retries are idempotency-aware — GET/HEAD/OPTIONS retry on retryable status codes, other methods only +on network and timeout failures, and `RequestOptions.idempotent` overrides either default. + +`TransportRequestOptions` is the whole wire request the three methods accept. `RequestOptions` is the +`{ signal, timeoutMs, retries, idempotent }` subset a caller hands to a higher-level method, leaving +`method`, `query`, `body` and `expectContentType` to that method. `ClientOptions.getServerTag` (a +`ServerTagResolver`) lets the host name the Metabase version a `ResponseShapeError` reports. + +Types: `MetabaseClient`, `Transport`, `ClientCredentials`, `ClientOptions`, `RequestOptions`, +`TransportRequestOptions`, `ServerTagResolver`, `HttpMethod`, `ExpectedContentType`, `QueryValue`, +`ListResult` (the `{ data, total }` a non-paginated list method returns). + +### Errors + +`MetabaseError` is the abstract root of the taxonomy: `NetworkError`, `TimeoutError`, +`ValidationError`, `ResponseShapeError`, `ConfigError`, `InternalError`, `AbortError`, +`ChainedRequestError`, `UnknownError`, and `HttpError`. Every one carries a `category` +(`ErrorCategory`), an `isRetryable`, a `userMessage`, and a `developerDetail` typed per class — +`NetworkErrorDetail`, `TimeoutErrorDetail` (the `HttpTimeoutDetail | PollingTimeoutDetail` union), +`ValidationErrorDetail`, `ResponseShapeErrorDetail` (the +`ZodResponseShapeDetail | DecodedResponseShapeDetail` union), `UnknownErrorDetail`, +`HttpErrorDetail`, and `null` where the class has nothing to add. + +`ResponseShapeError` covers every 2xx whose payload the client cannot read: a schema parse failure +carries the request context and the Zod issues, and a hand-decoded body or header — a bare scalar, a +`metabase-…` response header — carries the source it was read from and the raw value. + +`ConfigError` and `InternalError` split blame: the first is input a caller could correct, the second +is a caller that violated a function's contract, which is a bug in the calling code. `HttpError` +carries the status and response body, redacted of known secrets at construction, plus a `kind` +(`HttpErrorKind`) separating a route Metabase does not serve from a row that is gone; +`isHttpNotFound(value)` answers the coarser question of whether a thrown value is an `HttpError` with +status 404. `ChainedRequestError` wraps a cause and delegates its category and retryability to it. +`toMetabaseError(unknown)` normalizes a thrown value into the taxonomy. + +Two helpers read raw thrown values away from the HTTP boundary. `isFileNotFoundError(value)` reports a +Node `ENOENT` filesystem error — a missing file on disk, never an HTTP 404, which arrives as an +`HttpError` with `status: 404`. It answers `boolean` rather than narrowing to `NodeJS.ErrnoException`, +which would put `@types/node` on every consumer's dependency list. `errorMessage(value)` reads a +message off an unknown throw. + +### Auth + +`oauthLogin(input, deps)` runs the loopback-redirect authorization-code flow with PKCE and returns +an `OAuthCredential`. `refreshOAuthCredential` exchanges the rotating refresh token; +`revokeOAuthCredential` revokes both tokens server-side and reports whether the server advertised a +revocation endpoint. Types: `Credential` (the `ApiKeyCredential | OAuthCredential` union), +`OAuthLoginInput`, `OAuthLoginDeps`. + +### Version and capabilities + +`probeServer(client)` reads `/api/session/properties` and returns a `ServerInfo` — the parsed +version tag and the token-feature map, each `null` when the server does not report it. +`checkCapabilities(info, required)` returns a `PreflightFailure` or `null`. `Capabilities` is the +`{ minVersion, tokenFeature? }` schema, `ParsedVersion` the `{ tag, major, patch }` schema a probed +version tag parses to, `BASELINE_CAPABILITIES` the minimum supported server, and `mergeCapabilities` +fills a partial declaration from it. `CapabilityError` is what a caller throws on a failed +preflight. Types: `ServerInfo`, `PreflightFailure`. + +### Resource schemas + +Every Metabase resource exports a full schema and a compact projection: `Card`/`CardCompact`, +`Collection`/`CollectionCompact`, `Dashboard`/`DashboardCompact`, `Database`/`DatabaseCompact`, +`Document`, `Field`, `FieldValues`, `Library`, `Measure`, `Notification`, `ParameterValues`, `Pulse`, +`SearchResult`, `Segment`, `Setting`, `Snippet`, `Table`, `Timeline`, `TimelineEvent`, `Transform`, +`TransformRun`, `TransformJob`, `TransformTag`, `CurrentUser`, `CardQueryResult`, +`EidTranslateResult`, `SetupResult`, `SyncTask`, `SyncDirtyItem`, `DashboardTab`, and the nested +shapes they compose (`Dashcard`, `CollectionItem`, `PulseChannel`, `NotificationHandler`, …). + +The full schema is `.loose()`, so server-side additions do not break parsing. The compact projection +is `.pick(…).strip()` — the agent-facing contract, and the shape list commands render. Schemas carry +what drives query writing and content selection; sync flags, fingerprints, and timestamps are +deliberately absent. + +A second class of schema describes a single response shape that has no compact pair: +`DashboardDetail`, `DatabaseSyncResult`, `CollectionTreeNode`, `FieldSummary`, `SettingValue`, +`TableQueryMetadata`, `SessionProperties`, and `TokenFeatures`. + +Request bodies (`CreateInput`, `UpdateInput`) and the domain vocabulary enums +(`FieldBaseType`, `SearchModel`, `CollectionItemModel`, …) live in the same modules and are reached +at `@metabase/client/domain/`. + +### Utilities + +`Page` is `{ items: T[], total: number | null }`, one page of a paged resource method's walk — +`total` is the server's count when the endpoint reports one. `PaginateOptions` carries `query`, +`pageSize` (default 50, Metabase's own server default), `offset`, `max`, and `signal`; a paged +method's own options type is that one minus the `query` it builds itself. Both come from +`@metabase/client/paginate`, alongside the `paginatePages(client, path, itemSchema, opts)` walk the +methods are built on. + +`pollUntil(fn, done, opts)` is the only wait loop. `parseJson(text, schema, opts)` is the only +`JSON.parse`. `normalizeUrl`, `displayUrl`, and `assertEndpointOrigin` are the URL helpers; base URLs +may carry a subpath, so request paths are joined by concatenation and an instance URL is never +reduced to its origin. diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..83b2dff --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,64 @@ +{ + "name": "@metabase/client", + "version": "0.1.0", + "private": true, + "description": "Metabase API client", + "license": "AGPL-3.0", + "repository": { + "type": "git", + "url": "git+https://github.com/metabase/mb-cli.git", + "directory": "packages/client" + }, + "files": [ + "dist", + "CHANGELOG.md" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./client": "./dist/client.js", + "./errors": "./dist/errors.js", + "./json": "./dist/json.js", + "./json-pointer": "./dist/json-pointer.js", + "./list": "./dist/list.js", + "./paginate": "./dist/paginate.js", + "./poll": "./dist/poll.js", + "./predicates": "./dist/predicates.js", + "./signal": "./dist/signal.js", + "./url": "./dist/url.js", + "./auth/credential": "./dist/auth/credential.js", + "./auth/oauth-login": "./dist/auth/oauth-login.js", + "./auth/oauth-session": "./dist/auth/oauth-session.js", + "./http/errors": "./dist/http/errors.js", + "./http/oauth": "./dist/http/oauth.js", + "./http/retry": "./dist/http/retry.js", + "./http/transport": "./dist/http/transport.js", + "./version/capabilities": "./dist/version/capabilities.js", + "./version/preflight-error": "./dist/version/preflight-error.js", + "./version/probe": "./dist/version/probe.js", + "./version/tag": "./dist/version/tag.js", + "./testing/fake-client": "./dist/testing/fake-client.js", + "./testing/fetch-capture": "./dist/testing/fetch-capture.js", + "./domain/*.test": null, + "./domain/*": "./dist/domain/*.js", + "./resources/*.test": null, + "./resources/*": "./dist/resources/*.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsdown", + "prepack": "tsdown" + }, + "dependencies": { + "semver": "^7.8.0" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "engines": { + "node": ">=20.3" + } +} diff --git a/packages/client/src/architecture.test.ts b/packages/client/src/architecture.test.ts new file mode 100644 index 0000000..be86df2 --- /dev/null +++ b/packages/client/src/architecture.test.ts @@ -0,0 +1,210 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const SRC_ROOT = resolve(fileURLToPath(import.meta.url), ".."); + +const DEPENDENCY_BUDGET = ["zod", "semver"]; + +interface SourceFile { + relPath: string; + content: string; +} + +type RuleScope = "all" | "domain"; + +interface StructureRule { + description: string; + pattern: RegExp; + allowedIn: string[]; + scope: RuleScope; +} + +const RULES: StructureRule[] = [ + { + description: "JSON.parse must only appear in src/json.ts", + pattern: /JSON\.parse\(/, + allowedIn: ["json.ts"], + scope: "all", + }, + { + description: "direct fetch calls must only appear in src/http/", + pattern: /\bfetch\s*\(|globalThis\.fetch/, + allowedIn: ["http/"], + scope: "all", + }, + { + description: "new URL() must only appear in src/url.ts or src/http/", + pattern: /\bnew URL\(/, + allowedIn: ["url.ts", "http/"], + scope: "all", + }, + { + description: "wait loops must only appear in src/poll.ts or src/http/retry.ts", + pattern: /setTimeout\([^)]*\bresolve\b|from\s+["']node:timers/, + allowedIn: ["poll.ts", "http/retry.ts"], + scope: "all", + }, + { + description: "the client must not touch process-global state", + pattern: /\bprocess\.[A-Za-z]/, + allowedIn: [], + scope: "all", + }, + { + description: "node:tls is forbidden (TLS trust is the host application's to configure)", + pattern: /["']node:tls["']/, + allowedIn: [], + scope: "all", + }, + { + description: "src/resources/ may only be imported by src/client.ts and its own siblings", + pattern: /from\s+["'][./]*resources\//, + allowedIn: ["client.ts", "resources/"], + scope: "all", + }, + { + description: "import paths must not include the .ts extension", + pattern: /from\s+["'][^"']+\.ts["']/, + allowedIn: [], + scope: "all", + }, +]; + +function listSourceFiles(): SourceFile[] { + const out: SourceFile[] = []; + function walk(dir: string): void { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) { + walk(full); + continue; + } + if (!name.endsWith(".ts")) { + continue; + } + if (name.endsWith(".test.ts")) { + continue; + } + const relPath = relative(SRC_ROOT, full).split(sep).join("/"); + out.push({ relPath, content: readFileSync(full, "utf8") }); + } + } + walk(SRC_ROOT); + return out; +} + +function isAllowed(relPath: string, allowedIn: string[]): boolean { + return allowedIn.some((entry) => + entry.endsWith("/") ? relPath.startsWith(entry) : relPath === entry, + ); +} + +function inScope(relPath: string, scope: RuleScope): boolean { + if (scope === "domain") { + return relPath.startsWith("domain/"); + } + return true; +} + +function bareSpecifiers(content: string): string[] { + const specifiers: string[] = []; + for (const match of content.matchAll(/from\s+["']([^"']+)["']/g)) { + const specifier = match[1]; + if (specifier === undefined || specifier.startsWith(".")) { + continue; + } + specifiers.push(specifier); + } + return specifiers; +} + +function withinBudget(specifier: string): boolean { + if (specifier.startsWith("node:")) { + return true; + } + return DEPENDENCY_BUDGET.some((pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`)); +} + +function unstrippedPicks(content: string): number[] { + const offsets: number[] = []; + let cursor = content.indexOf(".pick("); + while (cursor !== -1) { + let depth = 0; + let index = cursor + ".pick".length; + for (; index < content.length; index++) { + if (content[index] === "(") { + depth++; + } else if (content[index] === ")") { + depth--; + if (depth === 0) { + index++; + break; + } + } + } + if (!/^\s*\.strip\(\)/.test(content.slice(index))) { + offsets.push(cursor); + } + cursor = content.indexOf(".pick(", index); + } + return offsets; +} + +function lineOf(content: string, offset: number): number { + return content.slice(0, offset).split("\n").length; +} + +describe("layering policy", () => { + const files = listSourceFiles(); + + it("walks the client source tree", () => { + const roots = files.filter((file) => !file.relPath.includes("/")).map((file) => file.relPath); + expect(roots.toSorted()).toEqual([ + "client.ts", + "errors.ts", + "index.ts", + "json-pointer.ts", + "json.ts", + "list.ts", + "paginate.ts", + "poll.ts", + "predicates.ts", + "signal.ts", + "url.ts", + ]); + }); + + for (const rule of RULES) { + it(rule.description, () => { + const offenders = files + .filter((file) => inScope(file.relPath, rule.scope)) + .filter((file) => !isAllowed(file.relPath, rule.allowedIn)) + .filter((file) => rule.pattern.test(file.content)) + .map((file) => file.relPath); + expect(offenders).toEqual([]); + }); + } + + it("imports nothing beyond zod, semver and node: builtins", () => { + const offenders = files.flatMap((file) => + bareSpecifiers(file.content) + .filter((specifier) => !withinBudget(specifier)) + .map((specifier) => `${file.relPath}: ${specifier}`), + ); + expect(offenders).toEqual([]); + }); + + it("every .pick() in src/domain/ chains .strip()", () => { + const offenders = files + .filter((file) => inScope(file.relPath, "domain")) + .flatMap((file) => + unstrippedPicks(file.content).map( + (offset) => `${file.relPath}:${lineOf(file.content, offset)}`, + ), + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/core/auth/callback-server.test.ts b/packages/client/src/auth/callback-server.test.ts similarity index 100% rename from src/core/auth/callback-server.test.ts rename to packages/client/src/auth/callback-server.test.ts diff --git a/src/core/auth/callback-server.ts b/packages/client/src/auth/callback-server.ts similarity index 98% rename from src/core/auth/callback-server.ts rename to packages/client/src/auth/callback-server.ts index 3cd1907..5aa64a8 100644 --- a/src/core/auth/callback-server.ts +++ b/packages/client/src/auth/callback-server.ts @@ -118,7 +118,7 @@ export function startCallbackServer( deliver(new ConfigError("authorization callback missing code")); return; } - respond(res, 200, "Login complete", "Metabase CLI is now authorized."); + respond(res, 200, "Login complete", "Authorization complete. You can close this window."); deliver({ code: query.code, state: query.state }); }); diff --git a/src/core/auth/credential.test.ts b/packages/client/src/auth/credential.test.ts similarity index 100% rename from src/core/auth/credential.test.ts rename to packages/client/src/auth/credential.test.ts diff --git a/src/core/auth/credential.ts b/packages/client/src/auth/credential.ts similarity index 65% rename from src/core/auth/credential.ts rename to packages/client/src/auth/credential.ts index f30a7da..7138d69 100644 --- a/src/core/auth/credential.ts +++ b/packages/client/src/auth/credential.ts @@ -1,3 +1,4 @@ +import { ConfigError } from "../errors"; import type { OAuthTokens } from "../http/oauth"; export interface ApiKeyCredential { @@ -40,6 +41,37 @@ export function credentialAuthHeader(credential: Credential): AuthHeader { return { name: AUTHORIZATION_HEADER, value: BEARER_PREFIX + credential.accessToken }; } +// Header field values are Latin-1 octets, so a credential carrying anything above U+00FF cannot be +// sent at all. Without this check the first request dies inside `Headers.set` with a bare TypeError +// that names an index into a value it does not name, outside the taxonomy a caller catches. +const MAX_HEADER_CODE_UNIT = 0xff; + +interface CredentialSecret { + field: string; + value: string; +} + +function headerBearingSecret(credential: Credential): CredentialSecret { + if (credential.kind === "apiKey") { + return { field: "apiKey", value: credential.apiKey }; + } + return { field: "accessToken", value: credential.accessToken }; +} + +export function assertCredentialHeaderSafe(credential: Credential): void { + const { field, value } = headerBearingSecret(credential); + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit > MAX_HEADER_CODE_UNIT) { + const codePoint = codeUnit.toString(16).toUpperCase().padStart(4, "0"); + throw new ConfigError( + `Credential field "${field}" has a character at index ${index} (U+${codePoint}) that cannot be sent in an HTTP header. ` + + "Header values are limited to Latin-1; a smart quote, ellipsis, or dash substituted during copy-paste is the usual cause.", + ); + } + } +} + export function credentialSecrets(credential: Credential): string[] { if (credential.kind === "apiKey") { return [credential.apiKey]; diff --git a/src/core/auth/oauth-login.test.ts b/packages/client/src/auth/oauth-login.test.ts similarity index 78% rename from src/core/auth/oauth-login.test.ts rename to packages/client/src/auth/oauth-login.test.ts index b3a9fe3..e5972b7 100644 --- a/src/core/auth/oauth-login.test.ts +++ b/packages/client/src/auth/oauth-login.test.ts @@ -3,12 +3,13 @@ import { createHash } from "node:crypto"; import { afterEach, assert, describe, expect, it, vi } from "vitest"; import { ConfigError } from "../errors"; -import type { CodeExchange, OAuthTokens } from "../http/oauth"; +import type { ClientRegistration, CodeExchange, OAuthTokens } from "../http/oauth"; const hoisted = vi.hoisted<{ tokens: OAuthTokens; metadata: OAuthServerMetadata; registerCalls: number; + registration: ClientRegistration | null; discoverCalls: number; exchange: CodeExchange | null; }>(() => ({ @@ -25,6 +26,7 @@ const hoisted = vi.hoisted<{ registration_endpoint: "https://mb.example.com/oauth/register", }, registerCalls: 0, + registration: null, discoverCalls: 0, exchange: null, })); @@ -37,8 +39,9 @@ vi.mock("../http/oauth", async (importOriginal) => { hoisted.discoverCalls += 1; return hoisted.metadata; }, - registerClient: async () => { + registerClient: async (input: ClientRegistration) => { hoisted.registerCalls += 1; + hoisted.registration = input; return { client_id: "client-xyz" }; }, exchangeCode: async (input: CodeExchange) => { @@ -48,7 +51,9 @@ vi.mock("../http/oauth", async (importOriginal) => { }; }); +import { TEST_USER_AGENT } from "../testing/fetch-capture"; import { OAuthServerMetadata } from "../http/oauth"; + import { oauthLogin } from "./oauth-login"; const DEFAULT_TOKENS: OAuthTokens = { @@ -65,6 +70,8 @@ const DEFAULT_METADATA: OAuthServerMetadata = { registration_endpoint: "https://mb.example.com/oauth/register", }; +const TEST_CLIENT_NAME = "Test OAuth Transport"; + const NOW = Date.parse("2026-06-08T12:00:00.000Z"); // Simulates the browser: parses the authorize URL the CLI would open and hits the loopback redirect. @@ -95,6 +102,7 @@ describe("oauthLogin", () => { hoisted.tokens = { ...DEFAULT_TOKENS }; hoisted.metadata = { ...DEFAULT_METADATA }; hoisted.registerCalls = 0; + hoisted.registration = null; hoisted.discoverCalls = 0; hoisted.exchange = null; }); @@ -102,7 +110,11 @@ describe("oauthLogin", () => { it("completes the PKCE loopback flow and assembles an OAuth credential", async () => { const announced: string[] = []; const credential = await oauthLogin( - { baseUrl: "https://mb.example.com" }, + { + baseUrl: "https://mb.example.com", + userAgent: TEST_USER_AGENT, + clientName: TEST_CLIENT_NAME, + }, { openBrowser: browserDriver(), onAuthorizeUrl: (url) => announced.push(url), @@ -133,6 +145,7 @@ describe("oauthLogin", () => { code: "test-code", redirectUri, clientId: "client-xyz", + userAgent: TEST_USER_AGENT, }); expect(createHash("sha256").update(codeVerifier).digest("base64url")).toBe( authorizeParams.get("code_challenge"), @@ -146,7 +159,11 @@ describe("oauthLogin", () => { }; const announced: string[] = []; await oauthLogin( - { baseUrl: "https://mb.example.com" }, + { + baseUrl: "https://mb.example.com", + userAgent: TEST_USER_AGENT, + clientName: TEST_CLIENT_NAME, + }, { openBrowser: browserDriver(), onAuthorizeUrl: (url) => announced.push(url), @@ -159,6 +176,8 @@ describe("oauthLogin", () => { it("uses caller-provided metadata without re-running discovery", async () => { const credential = await oauthLogin( { + userAgent: TEST_USER_AGENT, + clientName: TEST_CLIENT_NAME, baseUrl: "https://mb.example.com", metadata: OAuthServerMetadata.parse(DEFAULT_METADATA), }, @@ -170,7 +189,11 @@ describe("oauthLogin", () => { it("ignores a forged wrong-state callback and completes on the genuine redirect", async () => { const credential = await oauthLogin( - { baseUrl: "https://mb.example.com" }, + { + baseUrl: "https://mb.example.com", + userAgent: TEST_USER_AGENT, + clientName: TEST_CLIENT_NAME, + }, { openBrowser: forgingThenGenuineBrowser(), onAuthorizeUrl: () => undefined, @@ -193,7 +216,11 @@ describe("oauthLogin", () => { expires_in: 3600, }; const error = await oauthLogin( - { baseUrl: "https://mb.example.com" }, + { + baseUrl: "https://mb.example.com", + userAgent: TEST_USER_AGENT, + clientName: TEST_CLIENT_NAME, + }, { openBrowser: browserDriver(), onAuthorizeUrl: () => undefined, now: () => NOW }, ).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(ConfigError); @@ -201,10 +228,39 @@ describe("oauthLogin", () => { expect(error.message).toContain("did not return a refresh token"); }); + it("registers under the caller-supplied client name", async () => { + const announced: string[] = []; + await oauthLogin( + { + baseUrl: "https://mb.example.com", + userAgent: TEST_USER_AGENT, + clientName: TEST_CLIENT_NAME, + }, + { + openBrowser: browserDriver(), + onAuthorizeUrl: (url) => announced.push(url), + now: () => NOW, + }, + ); + const redirectUri = new URL(announced[0] ?? "").searchParams.get("redirect_uri"); + assert(redirectUri !== null, "expected redirect_uri in the authorize URL"); + expect(hoisted.registration).toEqual({ + registrationEndpoint: "https://mb.example.com/oauth/register", + redirectUri, + clientName: TEST_CLIENT_NAME, + userAgent: TEST_USER_AGENT, + }); + }); + it("uses a provided client id and skips dynamic registration", async () => { const announced: string[] = []; const credential = await oauthLogin( - { baseUrl: "https://mb.example.com", clientId: "preset-client" }, + { + baseUrl: "https://mb.example.com", + clientId: "preset-client", + userAgent: TEST_USER_AGENT, + clientName: TEST_CLIENT_NAME, + }, { openBrowser: browserDriver(), onAuthorizeUrl: (url) => announced.push(url), @@ -220,7 +276,11 @@ describe("oauthLogin", () => { const { registration_endpoint: _omit, ...withoutRegistration } = DEFAULT_METADATA; hoisted.metadata = withoutRegistration; const error = await oauthLogin( - { baseUrl: "https://mb.example.com" }, + { + baseUrl: "https://mb.example.com", + userAgent: TEST_USER_AGENT, + clientName: TEST_CLIENT_NAME, + }, { openBrowser: browserDriver(), onAuthorizeUrl: () => undefined, now: () => NOW }, ).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(ConfigError); diff --git a/src/core/auth/oauth-login.ts b/packages/client/src/auth/oauth-login.ts similarity index 86% rename from src/core/auth/oauth-login.ts rename to packages/client/src/auth/oauth-login.ts index 1295270..a1e2a41 100644 --- a/src/core/auth/oauth-login.ts +++ b/packages/client/src/auth/oauth-login.ts @@ -11,10 +11,12 @@ import { startCallbackServer } from "./callback-server"; import { oauthCredentialFromTokens, type OAuthCredential } from "./credential"; import { generatePkce, randomState } from "./pkce"; -const CLIENT_NAME = "Metabase CLI"; - export interface OAuthLoginInput { baseUrl: string; + userAgent: string; + // Sent as `client_name` during dynamic client registration; the user's Metabase persists it as + // the name of the registered OAuth client. + clientName: string; // Discovery document already fetched by the caller (login probes it to pick the auth method); // when omitted it is discovered here. metadata?: OAuthServerMetadata; @@ -35,22 +37,23 @@ function buildAuthorizeUrl(authorizationEndpoint: string, params: Record { - if (provided !== undefined) { - return provided; + if (input.clientId !== undefined) { + return input.clientId; } if (registrationEndpoint === undefined) { throw new ConfigError( - "this Metabase has dynamic client registration disabled; pass --client-id with a pre-registered native client", + "this Metabase has dynamic client registration disabled; pass clientId with a pre-registered native client", ); } const registered = await registerClient({ registrationEndpoint, redirectUri, - clientName: CLIENT_NAME, + clientName: input.clientName, + userAgent: input.userAgent, }); return registered.client_id; } @@ -59,16 +62,16 @@ export async function oauthLogin( input: OAuthLoginInput, deps: OAuthLoginDeps, ): Promise { - const metadata = input.metadata ?? (await discoverMetadata(input.baseUrl)); + const metadata = input.metadata ?? (await discoverMetadata(input.baseUrl, input.userAgent)); const pkce = generatePkce(); const state = randomState(); // The server validates state in-handler, so a forged callback can't consume the slot. const server = await startCallbackServer(state, input.timeoutMs); try { const clientId = await resolveClientId( + input, metadata.registration_endpoint, server.redirectUri, - input.clientId, ); const authorizeUrl = buildAuthorizeUrl(metadata.authorization_endpoint, { response_type: "code", @@ -91,6 +94,7 @@ export async function oauthLogin( redirectUri: server.redirectUri, clientId, codeVerifier: pkce.verifier, + userAgent: input.userAgent, }); if (tokens.refresh_token === undefined) { diff --git a/src/core/auth/oauth-session.test.ts b/packages/client/src/auth/oauth-session.test.ts similarity index 81% rename from src/core/auth/oauth-session.test.ts rename to packages/client/src/auth/oauth-session.test.ts index c094369..498590e 100644 --- a/src/core/auth/oauth-session.test.ts +++ b/packages/client/src/auth/oauth-session.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { captureFetch, jsonResponse, type FetchScript } from "../http/fetch-capture"; +import { + captureFetch, + jsonResponse, + TEST_USER_AGENT, + type FetchScript, +} from "../testing/fetch-capture"; import type { OAuthServerMetadata } from "../http/oauth"; import type { OAuthCredential } from "./credential"; @@ -41,7 +46,12 @@ describe("refreshOAuthCredential", () => { refresh_token: "ref-2", }), ]); - const refreshed = await refreshOAuthCredential("https://m.example.com", CREDENTIAL, NOW); + const refreshed = await refreshOAuthCredential( + "https://m.example.com", + CREDENTIAL, + NOW, + TEST_USER_AGENT, + ); expect(refreshed).toEqual({ kind: "oauth", accessToken: "acc-2", @@ -56,7 +66,12 @@ describe("refreshOAuthCredential", () => { jsonResponse(METADATA), jsonResponse({ access_token: "acc-2", token_type: "Bearer", expires_in: 3600 }), ]); - const refreshed = await refreshOAuthCredential("https://m.example.com", CREDENTIAL, NOW); + const refreshed = await refreshOAuthCredential( + "https://m.example.com", + CREDENTIAL, + NOW, + TEST_USER_AGENT, + ); expect(refreshed).toEqual({ kind: "oauth", accessToken: "acc-2", diff --git a/src/core/auth/oauth-session.ts b/packages/client/src/auth/oauth-session.ts similarity index 89% rename from src/core/auth/oauth-session.ts rename to packages/client/src/auth/oauth-session.ts index 4d73840..b2d263b 100644 --- a/src/core/auth/oauth-session.ts +++ b/packages/client/src/auth/oauth-session.ts @@ -8,12 +8,14 @@ export async function refreshOAuthCredential( baseUrl: string, credential: OAuthCredential, nowMs: number, + userAgent: string, ): Promise { - const metadata = await discoverMetadata(baseUrl); + const metadata = await discoverMetadata(baseUrl, userAgent); const tokens = await refreshTokens({ tokenEndpoint: metadata.token_endpoint, refreshToken: credential.refreshToken, clientId: credential.clientId, + userAgent, }); return oauthCredentialFromTokens( tokens, @@ -31,8 +33,9 @@ export async function refreshOAuthCredential( export async function revokeOAuthCredential( baseUrl: string, credential: OAuthCredential, + userAgent: string, ): Promise { - const metadata = await discoverMetadata(baseUrl); + const metadata = await discoverMetadata(baseUrl, userAgent); const revocationEndpoint = metadata.revocation_endpoint; if (revocationEndpoint === undefined) { return false; @@ -41,11 +44,13 @@ export async function revokeOAuthCredential( revocationEndpoint, token: credential.refreshToken, clientId: credential.clientId, + userAgent, }); await revokeToken({ revocationEndpoint, token: credential.accessToken, clientId: credential.clientId, + userAgent, }); return true; } diff --git a/src/core/auth/pkce.test.ts b/packages/client/src/auth/pkce.test.ts similarity index 100% rename from src/core/auth/pkce.test.ts rename to packages/client/src/auth/pkce.test.ts diff --git a/src/core/auth/pkce.ts b/packages/client/src/auth/pkce.ts similarity index 100% rename from src/core/auth/pkce.ts rename to packages/client/src/auth/pkce.ts diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts new file mode 100644 index 0000000..6301dac --- /dev/null +++ b/packages/client/src/client.ts @@ -0,0 +1,68 @@ +import { type ClientCredentials, type ClientOptions, createTransport } from "./http/transport"; +import { cardResource } from "./resources/card"; +import { collectionResource } from "./resources/collection"; +import { dashboardResource } from "./resources/dashboard"; +import { databaseResource } from "./resources/database"; +import { datasetResource } from "./resources/dataset"; +import { documentResource } from "./resources/document"; +import { eidTranslationResource } from "./resources/eid-translation"; +import { fieldResource } from "./resources/field"; +import { gitSyncResource } from "./resources/git-sync"; +import { libraryResource } from "./resources/library"; +import { measureResource } from "./resources/measure"; +import { notificationResource } from "./resources/notification"; +import { pulseResource } from "./resources/pulse"; +import { searchResource } from "./resources/search"; +import { segmentResource } from "./resources/segment"; +import { settingResource } from "./resources/setting"; +import { setupResource } from "./resources/setup"; +import { snippetResource } from "./resources/snippet"; +import { tableResource } from "./resources/table"; +import { timelineEventResource } from "./resources/timeline-event"; +import { timelineResource } from "./resources/timeline"; +import { transformJobResource } from "./resources/transform-job"; +import { transformResource } from "./resources/transform"; +import { transformTagResource } from "./resources/transform-tag"; +import { uploadResource } from "./resources/upload"; +import { userResource } from "./resources/user"; + +// The return type is left for inference on purpose: `MetabaseClient` is `ReturnType`, so any annotation here would freeze the client at what that annotation names and +// the resource namespaces composed onto it would be invisible to every consumer. +export function createClient(config: ClientCredentials, options: ClientOptions) { + const transport = createTransport(config, options); + return { + card: cardResource(transport), + collection: collectionResource(transport), + dashboard: dashboardResource(transport), + database: databaseResource(transport), + dataset: datasetResource(transport), + document: documentResource(transport), + eidTranslation: eidTranslationResource(transport), + field: fieldResource(transport), + gitSync: gitSyncResource(transport), + library: libraryResource(transport), + measure: measureResource(transport), + notification: notificationResource(transport), + pulse: pulseResource(transport), + search: searchResource(transport), + segment: segmentResource(transport), + setting: settingResource(transport), + setup: setupResource(transport), + snippet: snippetResource(transport), + table: tableResource(transport), + timeline: timelineResource(transport), + timelineEvent: timelineEventResource(transport), + transform: transformResource(transport), + transformJob: transformJobResource(transport), + transformTag: transformTagResource(transport), + upload: uploadResource(transport), + user: userResource(transport), + // The escape hatch: an endpoint the client never models still has to cost a consumer nothing. + requestParsed: transport.requestParsed, + requestRaw: transport.requestRaw, + requestStream: transport.requestStream, + }; +} + +export type MetabaseClient = ReturnType; diff --git a/src/domain/card.ts b/packages/client/src/domain/card.ts similarity index 89% rename from src/domain/card.ts rename to packages/client/src/domain/card.ts index 8f4ed60..5843323 100644 --- a/src/domain/card.ts +++ b/packages/client/src/domain/card.ts @@ -3,12 +3,30 @@ import { z } from "zod"; import { EmbeddingParams } from "./embedding"; import { FieldBaseType, FieldSemanticType } from "./field"; import { Parameter, ParameterMapping } from "./parameter"; -import type { ResourceView } from "./view"; const CardType = z.enum(["question", "model", "metric"]); const CardQueryType = z.enum(["native", "query"]); +// The server-side presets `GET /api/card`'s `f` parameter accepts. `database`, `table`, +// `using_model` and `using_segment` each need the matching id in `model_id`. +export const CardListFilter = z.enum([ + "all", + "mine", + "bookmarked", + "database", + "table", + "archived", + "using_model", + "using_segment", +]); +export type CardListFilter = z.infer; + +// The download formats `POST /api/card/{id}/query/{format}` serves, and the last path segment of +// that endpoint. +export const CardExportFormat = z.enum(["csv", "json", "xlsx"]); +export type CardExportFormat = z.infer; + // `dataset_query: {}` is accepted by the server's `::query` schema for historic // reasons but immediately trips the NOT NULL constraint on REPORT_CARD.DATABASE_ID // during INSERT, surfacing as a raw H2 stack trace. `dataset_query: null` is @@ -21,7 +39,7 @@ export const CardDatasetQuery = z message: 'dataset_query must include "lib/type" (MBQL 5) or "type" (legacy MBQL/native); empty `{}` is rejected', }) - .describe("MBQL 5, legacy MBQL, or native query — full MBQL 5 schema: mb query --print-schema"); + .describe("MBQL 5, legacy MBQL, or native query"); export type CardDatasetQuery = z.infer; export const Card = z @@ -57,19 +75,6 @@ export const CardCompact = Card.pick({ }).strip(); export type CardCompact = z.infer; -export const cardView: ResourceView = { - compactPick: CardCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "type", label: "Type" }, - { key: "display", label: "Display" }, - { key: "database_id", label: "DB" }, - { key: "collection_id", label: "Collection" }, - { key: "archived", label: "Archived" }, - ], -}; - const CollectionIdOrEntityId = z.union([ z.number().int().positive(), z.string().length(21).describe("collection entity_id (21-char NanoID)"), @@ -175,8 +180,3 @@ export const CardQueryResultCompact = CardQueryResult.pick({ .strip() .extend({ data: CardQueryDataCompact.optional() }); export type CardQueryResultCompact = z.infer; - -export const cardQueryView: ResourceView = { - compactPick: CardQueryResultCompact, - tableColumns: [{ key: "status", label: "Status" }], -}; diff --git a/src/domain/collection.ts b/packages/client/src/domain/collection.ts similarity index 87% rename from src/domain/collection.ts rename to packages/client/src/domain/collection.ts index 5f04e38..42d4517 100644 --- a/src/domain/collection.ts +++ b/packages/client/src/domain/collection.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - export const CollectionId = z.union([z.number().int(), z.string()]); export type CollectionId = z.infer; @@ -25,6 +23,9 @@ export const CollectionNamespace = z.enum([ ]); export type CollectionNamespace = z.infer; +export const CollectionListFilter = z.enum(["all", "archived", "personal"]); +export type CollectionListFilter = z.infer; + export const COLLECTION_ITEM_FILTER_MODELS = [ "card", "dataset", @@ -90,18 +91,6 @@ export const CollectionCompact = Collection.pick({ }).strip(); export type CollectionCompact = z.infer; -export const collectionView: ResourceView = { - compactPick: CollectionCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "location", label: "Location" }, - { key: "type", label: "Type" }, - { key: "authority_level", label: "Authority" }, - { key: "archived", label: "Archived" }, - ], -}; - // `archived` and `display` arrive as null on the wire for some model types (snippet, // pulse, timeline, transform, document, table) — those queries don't select the column, // so the union-all pads it with null. Stay permissive. @@ -133,17 +122,6 @@ export const CollectionItemCompact = CollectionItem.pick({ }).strip(); export type CollectionItemCompact = z.infer; -export const collectionItemView: ResourceView = { - compactPick: CollectionItemCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "model", label: "Model" }, - { key: "name", label: "Name" }, - { key: "collection_id", label: "Collection" }, - { key: "archived", label: "Archived" }, - ], -}; - const CollectionTreeNodeBase = z .object({ id: CollectionId, @@ -176,3 +154,14 @@ export const CollectionCreateInput = z }) .loose(); export type CollectionCreateInput = z.infer; + +export const CollectionUpdateInput = z + .object({ + name: z.string().min(1).optional(), + description: z.string().min(1).nullable().optional(), + parent_id: z.number().int().positive().nullable().optional(), + authority_level: CollectionAuthorityLevel.nullable().optional(), + archived: z.boolean().optional(), + }) + .loose(); +export type CollectionUpdateInput = z.infer; diff --git a/src/domain/cron.ts b/packages/client/src/domain/cron.ts similarity index 100% rename from src/domain/cron.ts rename to packages/client/src/domain/cron.ts diff --git a/src/domain/dashboard.ts b/packages/client/src/domain/dashboard.ts similarity index 89% rename from src/domain/dashboard.ts rename to packages/client/src/domain/dashboard.ts index 9d3252a..5ddcd85 100644 --- a/src/domain/dashboard.ts +++ b/packages/client/src/domain/dashboard.ts @@ -2,10 +2,13 @@ import { z } from "zod"; import { EmbeddingParams } from "./embedding"; import { Parameter, ParameterMapping } from "./parameter"; -import type { ResourceView } from "./view"; const DashboardWidth = z.enum(["fixed", "full"]); +// The server-side presets `GET /api/dashboard`'s `f` parameter accepts. +export const DashboardListFilter = z.enum(["all", "mine", "archived"]); +export type DashboardListFilter = z.infer; + export const Dashcard = z .object({ id: z.number().int(), @@ -36,19 +39,6 @@ export const DashcardCompact = Dashcard.pick({ }).strip(); export type DashcardCompact = z.infer; -export const dashcardView: ResourceView = { - compactPick: DashcardCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "card_id", label: "Card" }, - { key: "dashboard_tab_id", label: "Tab" }, - { key: "row", label: "Row" }, - { key: "col", label: "Col" }, - { key: "size_x", label: "W" }, - { key: "size_y", label: "H" }, - ], -}; - export const DashboardTab = z .object({ id: z.number().int(), @@ -110,16 +100,6 @@ export const DashboardCompact = Dashboard.pick({ }); export type DashboardCompact = z.infer; -export const dashboardView: ResourceView = { - compactPick: DashboardCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "collection_id", label: "Collection" }, - { key: "archived", label: "Archived" }, - ], -}; - const DashcardWrite = z .object({ id: z.number().int().describe("existing dashcard id; negative to create a new dashcard"), diff --git a/src/domain/database.ts b/packages/client/src/domain/database.ts similarity index 59% rename from src/domain/database.ts rename to packages/client/src/domain/database.ts index 83c68c7..4a0b5f5 100644 --- a/src/domain/database.ts +++ b/packages/client/src/domain/database.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { Table, TableCompact } from "./table"; -import type { ResourceView } from "./view"; export const Database = z .object({ @@ -27,16 +26,13 @@ export const DatabaseCompact = Database.pick({ }); export type DatabaseCompact = z.infer; -export const databaseView: ResourceView = { - compactPick: DatabaseCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "engine", label: "Engine" }, - ], -}; +// What `include` accepts differs between the list and the single-database endpoints: only the latter +// can hydrate a table's fields. +export const DatabaseListInclude = z.enum(["tables"]); +export type DatabaseListInclude = z.infer; -export const DatabaseTaskAck = z.object({ status: z.literal("ok") }); +export const DatabaseGetInclude = z.enum(["tables", "tables.fields"]); +export type DatabaseGetInclude = z.infer; export const DatabaseSyncResult = z.object({ id: z.number().int(), @@ -44,12 +40,3 @@ export const DatabaseSyncResult = z.object({ initial_sync_status: z.string().nullable().optional(), }); export type DatabaseSyncResult = z.infer; - -export const databaseSyncResultView: ResourceView = { - compactPick: DatabaseSyncResult, - tableColumns: [ - { key: "id", label: "Database" }, - { key: "status", label: "Status" }, - { key: "initial_sync_status", label: "Initial sync" }, - ], -}; diff --git a/src/domain/document.test.ts b/packages/client/src/domain/document.test.ts similarity index 100% rename from src/domain/document.test.ts rename to packages/client/src/domain/document.test.ts diff --git a/src/domain/document.ts b/packages/client/src/domain/document.ts similarity index 90% rename from src/domain/document.ts rename to packages/client/src/domain/document.ts index b379605..63840ba 100644 --- a/src/domain/document.ts +++ b/packages/client/src/domain/document.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - export const TipTapNode = z.looseObject({ type: z.string(), text: z.string().optional(), @@ -35,7 +33,7 @@ function validateNodeId({ type, attrs, content = [] }: TipTapNode): boolean { } export const TipTapNodeInput = TipTapNode.refine(validateNodeId, { - message: `every ${[...NODE_TYPES_WITH_ID].join(", ")} node needs a non-empty string \`_id\` (mint with \`mb uuid\`)`, + message: `every ${[...NODE_TYPES_WITH_ID].join(", ")} node needs a non-empty string \`_id\``, }); export type TipTapNodeInput = z.infer; @@ -79,17 +77,6 @@ export const DocumentCompact = Document.pick({ }).strip(); export type DocumentCompact = z.infer; -export const documentView: ResourceView = { - compactPick: DocumentCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "collection_id", label: "Collection" }, - { key: "creator_id", label: "Creator" }, - { key: "archived", label: "Archived" }, - ], -}; - const DocumentName = z.string().min(1).max(254); const DocumentCardInput = z diff --git a/src/domain/eid-translation.ts b/packages/client/src/domain/eid-translation.ts similarity index 86% rename from src/domain/eid-translation.ts rename to packages/client/src/domain/eid-translation.ts index 817342a..f7d1403 100644 --- a/src/domain/eid-translation.ts +++ b/packages/client/src/domain/eid-translation.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - export const EID_MODELS = [ "action", "card", @@ -57,8 +55,3 @@ export const EidTranslateResultCompact = EidTranslateResult.pick({ entity_ids: true, }).strip(); export type EidTranslateResultCompact = z.infer; - -export const eidTranslateView: ResourceView = { - compactPick: EidTranslateResultCompact, - tableColumns: [{ key: "entity_ids", label: "Translated" }], -}; diff --git a/src/domain/embedding.ts b/packages/client/src/domain/embedding.ts similarity index 100% rename from src/domain/embedding.ts rename to packages/client/src/domain/embedding.ts diff --git a/src/domain/field.ts b/packages/client/src/domain/field.ts similarity index 79% rename from src/domain/field.ts rename to packages/client/src/domain/field.ts index 472590d..13f4983 100644 --- a/src/domain/field.ts +++ b/packages/client/src/domain/field.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - const FieldVisibilityType = z.enum(["details-only", "hidden", "normal", "retired", "sensitive"]); const FieldValuesType = z.enum(["list", "search", "none", "auto-list"]); @@ -180,27 +178,6 @@ export const FieldCompact = Field.pick({ }).strip(); export type FieldCompact = z.infer; -function formatTypeTag(value: unknown): string { - return typeof value === "string" ? value.replace(/^type\//, "") : ""; -} - -function formatFkTarget(value: unknown): string { - return typeof value === "number" ? `field ${value}` : ""; -} - -export const fieldView: ResourceView = { - compactPick: FieldCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "display_name", label: "Display Name" }, - { key: "base_type", label: "Base Type", format: formatTypeTag }, - { key: "semantic_type", label: "Semantic Type", format: formatTypeTag }, - { key: "fk_target_field_id", label: "FK Target", format: formatFkTarget }, - { key: "description", label: "Description" }, - ], -}; - const NonBlankNullable = z.string().min(1).nullable(); export const FieldUpdateInput = z @@ -238,33 +215,9 @@ export const FieldValuesCompact = FieldValues.pick({ }).strip(); export type FieldValuesCompact = z.infer; -export const fieldValuesView: ResourceView = { - compactPick: FieldValuesCompact, - tableColumns: [ - { key: "field_id", label: "Field" }, - { key: "has_more_values", label: "Has More" }, - { key: "values", label: "Values" }, - ], -}; - -export const FieldSummaryRaw = z.tuple([ - z.tuple([z.literal("count"), z.number().int()]), - z.tuple([z.literal("distincts"), z.number().int()]), -]); -export type FieldSummaryRaw = z.infer; - export const FieldSummary = z.object({ field_id: z.number().int(), count: z.number().int(), distincts: z.number().int(), }); export type FieldSummary = z.infer; - -export const fieldSummaryView: ResourceView = { - compactPick: FieldSummary, - tableColumns: [ - { key: "field_id", label: "Field" }, - { key: "count", label: "Count" }, - { key: "distincts", label: "Distinct" }, - ], -}; diff --git a/packages/client/src/domain/git-sync.test.ts b/packages/client/src/domain/git-sync.test.ts new file mode 100644 index 0000000..a342521 --- /dev/null +++ b/packages/client/src/domain/git-sync.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { isSyncTaskFailed, isSyncTaskTerminal, type SyncTaskStatus } from "./git-sync"; + +const ALL_STATUSES: SyncTaskStatus[] = [ + "running", + "successful", + "errored", + "cancelled", + "timed-out", + "conflict", +]; + +describe("isSyncTaskTerminal", () => { + it("returns false only for the running status", () => { + const terminal = ALL_STATUSES.filter((status) => isSyncTaskTerminal(status)); + expect(terminal).toEqual(["successful", "errored", "cancelled", "timed-out", "conflict"]); + }); +}); + +describe("isSyncTaskFailed", () => { + it("returns true only for errored, timed-out, and conflict (not successful or cancelled)", () => { + const failures = ALL_STATUSES.filter((status) => isSyncTaskFailed(status)); + expect(failures).toEqual(["errored", "timed-out", "conflict"]); + }); +}); diff --git a/packages/client/src/domain/git-sync.ts b/packages/client/src/domain/git-sync.ts new file mode 100644 index 0000000..9df41fa --- /dev/null +++ b/packages/client/src/domain/git-sync.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; + +export const SyncTaskStatus = z.enum([ + "running", + "successful", + "errored", + "cancelled", + "timed-out", + "conflict", +]); +export type SyncTaskStatus = z.infer; + +export const SyncTaskType = z.enum(["import", "export"]); +export type SyncTaskType = z.infer; + +const TERMINAL_STATUSES = new Set([ + "successful", + "errored", + "cancelled", + "timed-out", + "conflict", +]); + +const FAILED_STATUSES = new Set(["errored", "timed-out", "conflict"]); + +/** Whether the server will report any further progress for a task in this status. */ +export function isSyncTaskTerminal(status: SyncTaskStatus): boolean { + return TERMINAL_STATUSES.has(status); +} + +/** Whether a terminal status means the sync did not carry out what it was asked to. */ +export function isSyncTaskFailed(status: SyncTaskStatus): boolean { + return FAILED_STATUSES.has(status); +} + +export const SyncTask = z + .object({ + id: z.number().int().positive(), + sync_task_type: SyncTaskType, + status: SyncTaskStatus, + progress: z.number().min(0).max(1).nullable(), + started_at: z.string(), + ended_at: z.string().nullable().optional(), + last_progress_report_at: z.string().nullable().optional(), + version: z.string().nullable().optional(), + initiated_by: z.number().int().positive().nullable().optional(), + cancelled: z.boolean().nullable().optional(), + error_message: z.string().nullable().optional(), + conflicts: z.array(z.string()).nullable().optional(), + }) + .loose(); +export type SyncTask = z.infer; + +export const SyncTaskCompact = SyncTask.pick({ + id: true, + sync_task_type: true, + status: true, + progress: true, + version: true, + error_message: true, +}).strip(); +export type SyncTaskCompact = z.infer; + +export const SyncDirtyItem = z + .object({ + id: z.number().int(), + name: z.string().nullable(), + model: z.string(), + sync_status: z.string(), + collection_id: z.number().int().positive().nullable().optional(), + description: z.string().nullable().optional(), + display: z.string().nullable().optional(), + query_type: z.string().nullable().optional(), + table_id: z.number().int().positive().nullable().optional(), + table_name: z.string().nullable().optional(), + }) + .loose(); +export type SyncDirtyItem = z.infer; + +export const SyncDirtyItemCompact = SyncDirtyItem.pick({ + id: true, + name: true, + model: true, + sync_status: true, + collection_id: true, +}).strip(); +export type SyncDirtyItemCompact = z.infer; + +export const SyncRemoteChanges = z.object({ + has_changes: z.boolean(), + remote_version: z.string().nullable(), + local_version: z.string().nullable(), + cached: z.boolean(), +}); +export type SyncRemoteChanges = z.infer; + +export const SyncBranchCreated = z.object({ + status: z.literal("success"), + message: z.string(), +}); +export type SyncBranchCreated = z.infer; + +export const SyncSettingsUpdateResult = z.object({ + success: z.boolean(), + task_id: z.number().int().positive().optional(), +}); +export type SyncSettingsUpdateResult = z.infer; + +// `final` is present only when the caller asked to wait, and null when the server had already +// forgotten the task by the time the poll ran. +export const SyncImportResult = z.object({ + message: z.string().nullable(), + task_id: z.number().int().positive().nullable(), + final: SyncTask.nullable().optional(), +}); +export type SyncImportResult = z.infer; + +export const SyncExportResult = z.object({ + message: z.string(), + task_id: z.number().int().positive(), + final: SyncTask.nullable().optional(), +}); +export type SyncExportResult = z.infer; + +export const SyncStashResult = z.object({ + status: z.literal("success"), + message: z.string(), + task_id: z.number().int().positive(), + final: SyncTask.nullable().optional(), +}); +export type SyncStashResult = z.infer; diff --git a/src/domain/library.ts b/packages/client/src/domain/library.ts similarity index 70% rename from src/domain/library.ts rename to packages/client/src/domain/library.ts index d053173..ffe824e 100644 --- a/src/domain/library.ts +++ b/packages/client/src/domain/library.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { Collection } from "./collection"; -import type { ResourceView } from "./view"; export const LibraryChild = Collection.pick({ id: true, @@ -24,12 +23,3 @@ export const LibraryCompact = Library.pick({ effective_children: true, }).strip(); export type LibraryCompact = z.infer; - -export const libraryView: ResourceView = { - compactPick: LibraryCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "type", label: "Type" }, - ], -}; diff --git a/src/domain/measure.ts b/packages/client/src/domain/measure.ts similarity index 77% rename from src/domain/measure.ts rename to packages/client/src/domain/measure.ts index 266298b..224e0c3 100644 --- a/src/domain/measure.ts +++ b/packages/client/src/domain/measure.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - export const Measure = z .object({ id: z.number().int(), @@ -31,22 +29,9 @@ export const MeasureCompact = Measure.pick({ }).strip(); export type MeasureCompact = z.infer; -export const measureView: ResourceView = { - compactPick: MeasureCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "description", label: "Description" }, - { key: "table_id", label: "Table" }, - { key: "archived", label: "Archived" }, - ], -}; - const MeasureDefinition = z .record(z.string(), z.unknown()) - .describe( - "MBQL query holding exactly one aggregation — full MBQL 5 schema: mb query --print-schema", - ); + .describe("MBQL query holding exactly one aggregation"); export const MeasureCreateInput = z .object({ diff --git a/src/domain/notification.ts b/packages/client/src/domain/notification.ts similarity index 76% rename from src/domain/notification.ts rename to packages/client/src/domain/notification.ts index 23b3fc1..d09045c 100644 --- a/src/domain/notification.ts +++ b/packages/client/src/domain/notification.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { CronUiDisplayType } from "./cron"; -import type { ResourceView } from "./view"; export const NotificationPayloadType = z.enum([ "notification/card", @@ -153,73 +152,6 @@ export const NotificationCompact = Notification.pick({ }); export type NotificationCompact = z.infer; -const NullableCardPayload = NotificationCardPayload.nullable(); -const NotificationSubscriptionList = z.array(NotificationSubscription); -const NotificationHandlerList = z.array(NotificationHandler); - -export const notificationView: ResourceView = { - compactPick: NotificationCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "payload", label: "Card", format: formatPayload }, - { key: "subscriptions", label: "Schedule", format: formatSubscriptions }, - { key: "handlers", label: "Delivery", format: formatHandlers }, - { key: "active", label: "Active" }, - ], -}; - -function formatPayload(value: unknown): string { - const parsed = NullableCardPayload.safeParse(value); - if (!parsed.success || parsed.data === null) { - return ""; - } - const { card_id, send_condition, send_once } = parsed.data; - const once = send_once ? ", once" : ""; - return `${card_id} (${send_condition}${once})`; -} - -function formatSubscriptions(value: unknown): string { - const parsed = NotificationSubscriptionList.safeParse(value); - if (!parsed.success) { - return ""; - } - return parsed.data - .map((subscription) => subscription.cron_schedule ?? subscription.event_name ?? "") - .filter((label) => label !== "") - .join("; "); -} - -function formatHandlers(value: unknown): string { - const parsed = NotificationHandlerList.safeParse(value); - if (!parsed.success) { - return ""; - } - return parsed.data.map(describeHandler).join("; "); -} - -function describeHandler(handler: NotificationHandler): string { - const channel = handler.channel_type.replace("channel/", ""); - const recipients = (handler.recipients ?? []).map(describeRecipient).join(", "); - return recipients === "" ? channel : `${channel} → ${recipients}`; -} - -function describeRecipient(recipient: NotificationRecipient): string { - switch (recipient.type) { - case "notification-recipient/user": { - return `user:${recipient.user_id}`; - } - case "notification-recipient/group": { - return `group:${recipient.permissions_group_id}`; - } - case "notification-recipient/raw-value": { - return recipient.details?.value ?? ""; - } - case "notification-recipient/template": { - return "template"; - } - } -} - const NotificationRecipientInput = z .object({ type: NotificationRecipientType, diff --git a/src/domain/parameter.ts b/packages/client/src/domain/parameter.ts similarity index 94% rename from src/domain/parameter.ts rename to packages/client/src/domain/parameter.ts index 4827530..d3506ee 100644 --- a/src/domain/parameter.ts +++ b/packages/client/src/domain/parameter.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - export const ParameterType = z.enum([ "number", "text", @@ -143,8 +141,3 @@ export const ParameterValuesCompact = ParameterValues.pick({ has_more_values: true, }).strip(); export type ParameterValuesCompact = z.infer; - -export const parameterValuesView: ResourceView = { - compactPick: ParameterValuesCompact, - tableColumns: [{ key: "has_more_values", label: "More available" }], -}; diff --git a/src/domain/pulse.ts b/packages/client/src/domain/pulse.ts similarity index 76% rename from src/domain/pulse.ts rename to packages/client/src/domain/pulse.ts index 90d9122..bd7043b 100644 --- a/src/domain/pulse.ts +++ b/packages/client/src/domain/pulse.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { Parameter } from "./parameter"; -import type { ResourceView } from "./view"; export const PulseChannelType = z.enum(["email", "slack", "http"]); export type PulseChannelType = z.infer; @@ -111,61 +110,6 @@ export const PulseCompact = Pulse.pick({ }); export type PulseCompact = z.infer; -const PulseChannelList = z.array(PulseChannel); - -export const pulseView: ResourceView = { - compactPick: PulseCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "dashboard_id", label: "Dashboard" }, - { key: "channels", label: "Delivery", format: formatChannels }, - { key: "archived", label: "Archived" }, - ], -}; - -function formatChannels(value: unknown): string { - const parsed = PulseChannelList.safeParse(value); - if (!parsed.success) { - return ""; - } - return parsed.data.map(describeChannel).join("; "); -} - -function describeChannel(channel: PulseChannel): string { - const parts: string[] = [channel.channel_type, describeSchedule(channel)]; - const audience = describeAudience(channel); - if (audience !== null) { - parts.push(`→ ${audience}`); - } - if (!channel.enabled) { - parts.push("(disabled)"); - } - return parts.join(" "); -} - -function describeSchedule(channel: PulseChannel): string { - const parts: string[] = [channel.schedule_type]; - if (channel.schedule_frame !== null) { - parts.push(channel.schedule_frame); - } - if (channel.schedule_day !== null) { - parts.push(channel.schedule_day); - } - if (channel.schedule_hour !== null) { - parts.push(`${channel.schedule_hour}:00`); - } - return parts.join(" "); -} - -function describeAudience(channel: PulseChannel): string | null { - if (channel.recipients.length > 0) { - return channel.recipients.map((recipient) => recipient.email).join(", "); - } - const slackChannel = channel.details?.channel; - return slackChannel === undefined ? null : slackChannel; -} - const PulseRecipientInput = z.union([ z.object({ id: z.number().int().positive() }).loose(), z.object({ email: z.string().min(1) }).loose(), diff --git a/src/domain/search.ts b/packages/client/src/domain/search.ts similarity index 80% rename from src/domain/search.ts rename to packages/client/src/domain/search.ts index 36cd24d..aa3c131 100644 --- a/src/domain/search.ts +++ b/packages/client/src/domain/search.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - export const SEARCH_MODELS = [ "card", "dataset", @@ -49,12 +47,3 @@ export const SearchResultCompact = SearchResult.pick({ description: true, }).strip(); export type SearchResultCompact = z.infer; - -export const searchResultView: ResourceView = { - compactPick: SearchResultCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "model", label: "Model" }, - { key: "name", label: "Name" }, - ], -}; diff --git a/src/domain/segment.ts b/packages/client/src/domain/segment.ts similarity index 79% rename from src/domain/segment.ts rename to packages/client/src/domain/segment.ts index 03fa1d2..32dd3d4 100644 --- a/src/domain/segment.ts +++ b/packages/client/src/domain/segment.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - export const Segment = z .object({ id: z.number().int(), @@ -31,20 +29,9 @@ export const SegmentCompact = Segment.pick({ }).strip(); export type SegmentCompact = z.infer; -export const segmentView: ResourceView = { - compactPick: SegmentCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "description", label: "Description" }, - { key: "table_id", label: "Table" }, - { key: "archived", label: "Archived" }, - ], -}; - const SegmentDefinition = z .record(z.string(), z.unknown()) - .describe("MBQL query holding the filter — full MBQL 5 schema: mb query --print-schema"); + .describe("MBQL query holding the filter"); export const SegmentCreateInput = z .object({ diff --git a/src/domain/session-properties.ts b/packages/client/src/domain/session-properties.ts similarity index 100% rename from src/domain/session-properties.ts rename to packages/client/src/domain/session-properties.ts diff --git a/src/domain/setting.ts b/packages/client/src/domain/setting.ts similarity index 54% rename from src/domain/setting.ts rename to packages/client/src/domain/setting.ts index 49e8db9..02c3e8c 100644 --- a/src/domain/setting.ts +++ b/packages/client/src/domain/setting.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - export const Setting = z .object({ key: z.string(), @@ -22,26 +20,8 @@ export const SettingCompact = Setting.pick({ }).strip(); export type SettingCompact = z.infer; -export const settingView: ResourceView = { - compactPick: SettingCompact, - tableColumns: [ - { key: "key", label: "Key" }, - { key: "value", label: "Value" }, - { key: "is_env_setting", label: "From env" }, - { key: "env_name", label: "Env name" }, - ], -}; - export const SettingValue = z.object({ key: z.string(), value: z.unknown(), }); export type SettingValue = z.infer; - -export const settingValueView: ResourceView = { - compactPick: SettingValue, - tableColumns: [ - { key: "key", label: "Key" }, - { key: "value", label: "Value" }, - ], -}; diff --git a/src/domain/setup.ts b/packages/client/src/domain/setup.ts similarity index 83% rename from src/domain/setup.ts rename to packages/client/src/domain/setup.ts index f41d340..caa5ed4 100644 --- a/src/domain/setup.ts +++ b/packages/client/src/domain/setup.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - const SetupUserInput = z .object({ first_name: z.string().min(1).nullable().optional(), @@ -41,8 +39,3 @@ export const SetupResultCompact = SetupResult.pick({ id: true, }).strip(); export type SetupResultCompact = z.infer; - -export const setupResultView: ResourceView = { - compactPick: SetupResultCompact, - tableColumns: [{ key: "id", label: "Session" }], -}; diff --git a/src/domain/snippet.ts b/packages/client/src/domain/snippet.ts similarity index 81% rename from src/domain/snippet.ts rename to packages/client/src/domain/snippet.ts index 8d39cd3..9b15f78 100644 --- a/src/domain/snippet.ts +++ b/packages/client/src/domain/snippet.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - export const Snippet = z .object({ id: z.number().int(), @@ -28,17 +26,6 @@ export const SnippetCompact = Snippet.pick({ }).strip(); export type SnippetCompact = z.infer; -export const snippetView: ResourceView = { - compactPick: SnippetCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "description", label: "Description" }, - { key: "collection_id", label: "Collection" }, - { key: "archived", label: "Archived" }, - ], -}; - const SNIPPET_NAME_PATTERN = /^(?!\s)[^}]*$/; const SNIPPET_NAME_MESSAGE = "snippet names cannot include '}' or start with spaces"; const snippetName = z.string().regex(SNIPPET_NAME_PATTERN, SNIPPET_NAME_MESSAGE); diff --git a/src/domain/table.ts b/packages/client/src/domain/table.ts similarity index 84% rename from src/domain/table.ts rename to packages/client/src/domain/table.ts index adf9c66..6f5c709 100644 --- a/src/domain/table.ts +++ b/packages/client/src/domain/table.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { Field, FieldCompact } from "./field"; -import type { ResourceView } from "./view"; const TableEntityType = z.enum([ "entity/GenericTable", @@ -40,6 +39,9 @@ export const TableQueryMetadata = Table.extend({ }); export type TableQueryMetadata = z.infer; +export const TableGetInclude = z.enum(["fields"]); +export type TableGetInclude = z.infer; + export const TableCompact = Table.pick({ id: true, name: true, @@ -56,19 +58,6 @@ export const TableCompact = Table.pick({ }); export type TableCompact = z.infer; -export const tableView: ResourceView
= { - compactPick: TableCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "db_id", label: "DB" }, - { key: "schema", label: "Schema" }, - { key: "name", label: "Name" }, - { key: "display_name", label: "Display Name" }, - { key: "description", label: "Description" }, - { key: "is_published", label: "Published" }, - ], -}; - const TableDataAuthority = z.enum(["unconfigured", "authoritative", "computed", "ingested"]); export const TableUpdateInput = z diff --git a/src/domain/timeline.ts b/packages/client/src/domain/timeline.ts similarity index 81% rename from src/domain/timeline.ts rename to packages/client/src/domain/timeline.ts index 605cb5a..c1faf1b 100644 --- a/src/domain/timeline.ts +++ b/packages/client/src/domain/timeline.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - export const TimelineIcon = z.enum(["star", "cake", "mail", "warning", "bell", "cloud"]); export type TimelineIcon = z.infer; @@ -34,18 +32,6 @@ export const TimelineEventCompact = TimelineEvent.pick({ }).strip(); export type TimelineEventCompact = z.infer; -export const timelineEventView: ResourceView = { - compactPick: TimelineEventCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "timestamp", label: "Timestamp" }, - { key: "icon", label: "Icon" }, - { key: "timeline_id", label: "Timeline" }, - { key: "archived", label: "Archived" }, - ], -}; - export const Timeline = z .object({ id: z.number().int(), @@ -75,19 +61,6 @@ export const TimelineCompact = Timeline.pick({ }).strip(); export type TimelineCompact = z.infer; -export const timelineView: ResourceView = { - compactPick: TimelineCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "description", label: "Description" }, - { key: "icon", label: "Icon" }, - { key: "collection_id", label: "Collection" }, - { key: "default", label: "Default" }, - { key: "archived", label: "Archived" }, - ], -}; - export const TimelineCreateInput = z .object({ name: z.string().min(1), diff --git a/src/domain/transform-job.ts b/packages/client/src/domain/transform-job.ts similarity index 76% rename from src/domain/transform-job.ts rename to packages/client/src/domain/transform-job.ts index 9e5f96e..2446d8e 100644 --- a/src/domain/transform-job.ts +++ b/packages/client/src/domain/transform-job.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { CronUiDisplayType } from "./cron"; -import type { ResourceView } from "./view"; const JobRunStatus = z.enum(["started", "succeeded", "failed", "timeout"]); @@ -57,19 +56,6 @@ export const TransformJobCompact = TransformJob.pick({ }).strip(); export type TransformJobCompact = z.infer; -export const transformJobView: ResourceView = { - compactPick: TransformJobCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "schedule", label: "Schedule" }, - { key: "ui_display_type", label: "Display" }, - { key: "active", label: "Active" }, - { key: "built_in_type", label: "Built-in" }, - { key: "description", label: "Description" }, - ], -}; - export const TransformJobCreateInput = z .object({ name: z.string().min(1), @@ -92,3 +78,18 @@ export const TransformJobUpdateInput = z }) .loose(); export type TransformJobUpdateInput = z.infer; + +// Released servers answer with an opaque stub string; head returns the numeric run id, or null when +// nothing was started — the job is already running, or resolves to no transforms. `message` is the +// constant "Job run started" in every case, so `job_run_id` is the only signal a caller can read. +export const TransformJobRunResult = z.object({ + message: z.string(), + job_run_id: z.union([z.string(), z.number().int().positive()]).nullable(), +}); +export type TransformJobRunResult = z.infer; + +export const TransformJobActiveResult = z.object({ + updated: z.number().int(), + failed: z.number().int(), +}); +export type TransformJobActiveResult = z.infer; diff --git a/src/domain/transform-tag.ts b/packages/client/src/domain/transform-tag.ts similarity index 72% rename from src/domain/transform-tag.ts rename to packages/client/src/domain/transform-tag.ts index 012bb3f..edb8994 100644 --- a/src/domain/transform-tag.ts +++ b/packages/client/src/domain/transform-tag.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - export const TransformTag = z .object({ id: z.number().int(), @@ -19,15 +17,6 @@ export const TransformTagCompact = TransformTag.pick({ }).strip(); export type TransformTagCompact = z.infer; -export const transformTagView: ResourceView = { - compactPick: TransformTagCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "built_in_type", label: "Built-in" }, - ], -}; - export const TransformTagCreateInput = z .object({ name: z.string().min(1), diff --git a/packages/client/src/domain/transform.test.ts b/packages/client/src/domain/transform.test.ts new file mode 100644 index 0000000..84ef17e --- /dev/null +++ b/packages/client/src/domain/transform.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { isTransformRunFailed, isTransformRunTerminal, type TransformRunStatus } from "./transform"; + +const ALL_STATUSES: TransformRunStatus[] = [ + "started", + "succeeded", + "failed", + "timeout", + "canceled", + "canceling", +]; + +describe("isTransformRunTerminal", () => { + it("returns false for the two in-flight statuses, started and canceling", () => { + const terminal = ALL_STATUSES.filter((status) => isTransformRunTerminal(status)); + expect(terminal).toEqual(["succeeded", "failed", "timeout", "canceled"]); + }); +}); + +describe("isTransformRunFailed", () => { + it("returns true only for failed, timeout, and canceled (not succeeded)", () => { + const failures = ALL_STATUSES.filter((status) => isTransformRunFailed(status)); + expect(failures).toEqual(["failed", "timeout", "canceled"]); + }); +}); diff --git a/src/domain/transform.ts b/packages/client/src/domain/transform.ts similarity index 79% rename from src/domain/transform.ts rename to packages/client/src/domain/transform.ts index b12db9b..22eba82 100644 --- a/src/domain/transform.ts +++ b/packages/client/src/domain/transform.ts @@ -1,10 +1,8 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - const TransformSourceType = z.enum(["native", "mbql", "python"]); -const TransformRunStatus = z.enum([ +export const TransformRunStatus = z.enum([ "started", "succeeded", "failed", @@ -12,6 +10,26 @@ const TransformRunStatus = z.enum([ "canceled", "canceling", ]); +export type TransformRunStatus = z.infer; + +const TERMINAL_STATUSES = new Set([ + "succeeded", + "failed", + "timeout", + "canceled", +]); + +const FAILURE_STATUSES = new Set(["failed", "timeout", "canceled"]); + +/** Whether the server will report any further progress for a run in this status. */ +export function isTransformRunTerminal(status: TransformRunStatus): boolean { + return TERMINAL_STATUSES.has(status); +} + +/** Whether a terminal status means the run did not produce its target table. */ +export function isTransformRunFailed(status: TransformRunStatus): boolean { + return FAILURE_STATUSES.has(status); +} const TransformRunMethod = z.enum(["manual", "cron"]); @@ -37,9 +55,7 @@ const TransformSourceTableEntry = z const TransformQuerySource = z .object({ type: z.literal("query"), - query: z - .unknown() - .describe("Native SQL or MBQL query — full MBQL 5 schema: mb query --print-schema"), + query: z.unknown().describe("Native SQL or MBQL query"), "source-incremental-strategy": TransformCheckpointStrategy.optional(), }) .loose(); @@ -96,10 +112,11 @@ const TransformTableIncrementalTarget = z }) .loose(); -const TransformTarget = z.discriminatedUnion("type", [ +export const TransformTarget = z.discriminatedUnion("type", [ TransformTableTarget, TransformTableIncrementalTarget, ]); +export type TransformTarget = z.infer; const TransformTargetCompact = z.discriminatedUnion("type", [ TransformTableTarget.strip(), @@ -145,18 +162,17 @@ export const TransformRunCompact = TransformRun.pick({ }).strip(); export type TransformRunCompact = z.infer; -export const transformRunView: ResourceView = { - compactPick: TransformRunCompact, - tableColumns: [ - { key: "id", label: "Run ID" }, - { key: "transform_id", label: "Transform" }, - { key: "status", label: "Status" }, - { key: "run_method", label: "Method" }, - { key: "start_time", label: "Started" }, - { key: "end_time", label: "Ended" }, - { key: "message", label: "Message" }, - ], -}; +// `run_id` is null when the server accepted the request without starting a run, and `final` is null +// whenever no run was polled — either because the caller did not ask to wait or because there was +// no run to wait for. `target_table_id` is present only for a caller that asked to follow the +// output table, and null when the table was not registered before the wait ran out. +export const TransformRunResult = z.object({ + message: z.string(), + run_id: z.number().int().positive().nullable(), + final: TransformRun.nullable(), + target_table_id: z.number().int().nullable().optional(), +}); +export type TransformRunResult = z.infer; export const Transform = z .object({ @@ -193,27 +209,6 @@ export const TransformCompact = Transform.pick({ .extend({ target: TransformTargetCompact }); export type TransformCompact = z.infer; -export const transformView: ResourceView = { - compactPick: TransformCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "name", label: "Name" }, - { key: "source_type", label: "Source" }, - { key: "target", label: "Target", format: (value) => formatTarget(value) }, - { key: "target_db_id", label: "Target DB" }, - { key: "description", label: "Description" }, - ], -}; - -function formatTarget(value: unknown): string { - const parsed = TransformTarget.safeParse(value); - if (!parsed.success) { - return ""; - } - const { schema, name } = parsed.data; - return schema ? `${schema}.${name}` : name; -} - export const TransformCreateInput = z .object({ name: z.string().min(1), diff --git a/src/commands/upload/results.ts b/packages/client/src/domain/upload.ts similarity index 51% rename from src/commands/upload/results.ts rename to packages/client/src/domain/upload.ts index cf7f643..b0e2d44 100644 --- a/src/commands/upload/results.ts +++ b/packages/client/src/domain/upload.ts @@ -1,34 +1,19 @@ import { z } from "zod"; -import type { ResourceView } from "../../domain/view"; - +// Composed rather than parsed from one payload: `POST /api/upload/csv` answers the model id as a +// bare integer body and puts the table id in a response header. export const UploadResult = z.object({ model_id: z.number().int(), table_id: z.number().int(), }); export type UploadResult = z.infer; -export const uploadResultView: ResourceView = { - compactPick: UploadResult, - tableColumns: [ - { key: "model_id", label: "Model ID" }, - { key: "table_id", label: "Table ID" }, - ], -}; - export const UploadUpdateAction = z.enum(["append", "replace"]); export type UploadUpdateAction = z.infer; +// The append/replace endpoints answer no useful body, so the confirmation is the request restated. export const UploadUpdateResult = z.object({ table_id: z.number().int(), action: UploadUpdateAction, }); export type UploadUpdateResult = z.infer; - -export const uploadUpdateResultView: ResourceView = { - compactPick: UploadUpdateResult, - tableColumns: [ - { key: "table_id", label: "Table ID" }, - { key: "action", label: "Action" }, - ], -}; diff --git a/src/domain/user.test.ts b/packages/client/src/domain/user.test.ts similarity index 72% rename from src/domain/user.test.ts rename to packages/client/src/domain/user.test.ts index 5dbf0c4..8bfe9eb 100644 --- a/src/domain/user.test.ts +++ b/packages/client/src/domain/user.test.ts @@ -3,8 +3,8 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { parseJson } from "../runtime/json"; -import { CurrentUser, userView } from "./user"; +import { parseJson } from "../json"; +import { CurrentUser } from "./user"; const samplePath = fileURLToPath(new URL("../../tests/fixtures/user/sample.json", import.meta.url)); @@ -36,13 +36,4 @@ describe("CurrentUser", () => { experimental_flag: true, }); }); - - it("userView declares id, email, name, and admin columns", () => { - expect(userView.tableColumns).toEqual([ - { key: "id", label: "ID" }, - { key: "email", label: "Email" }, - { key: "common_name", label: "Name" }, - { key: "is_superuser", label: "Admin" }, - ]); - }); }); diff --git a/src/domain/user.ts b/packages/client/src/domain/user.ts similarity index 58% rename from src/domain/user.ts rename to packages/client/src/domain/user.ts index 5466b45..e41de4e 100644 --- a/src/domain/user.ts +++ b/packages/client/src/domain/user.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import type { ResourceView } from "./view"; - export const CurrentUser = z .object({ id: z.number().int(), @@ -19,13 +17,3 @@ export const CurrentUserCompact = CurrentUser.pick({ is_superuser: true, }).strip(); export type CurrentUserCompact = z.infer; - -export const userView: ResourceView = { - compactPick: CurrentUserCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "email", label: "Email" }, - { key: "common_name", label: "Name" }, - { key: "is_superuser", label: "Admin" }, - ], -}; diff --git a/src/core/errors.test.ts b/packages/client/src/errors.test.ts similarity index 93% rename from src/core/errors.test.ts rename to packages/client/src/errors.test.ts index 766b2a6..29ea1dd 100644 --- a/src/core/errors.test.ts +++ b/packages/client/src/errors.test.ts @@ -5,6 +5,7 @@ import { AbortError, ConfigError, formatZodIssue, + InternalError, MetabaseError, ResponseShapeError, toMetabaseError, @@ -30,24 +31,21 @@ describe("toMetabaseError", () => { }); const result = toMetabaseError(httpError); expect(result).toBe(httpError); - expect(result.exitCode).toBe(1); expect(result.userMessage).toBe("bad key"); }); - it("maps a ZodError to a ConfigError with exit code 2", () => { + it("maps a ZodError to a ConfigError", () => { const zodError = z.object({ name: z.string() }).safeParse({ name: 42 }); assert(!zodError.success, "expected zod failure"); const result = toMetabaseError(zodError.error); expect(result).toBeInstanceOf(ConfigError); - expect(result.exitCode).toBe(2); expect(result.userMessage).toContain("name"); }); - it("wraps a generic Error as UnknownError with exit code 1 and preserves the message", () => { + it("wraps a generic Error as UnknownError and preserves the message", () => { const original = new Error("kaboom"); const result = toMetabaseError(original); expect(result).toBeInstanceOf(UnknownError); - expect(result.exitCode).toBe(1); expect(result.userMessage).toBe("kaboom"); expect(result.developerDetail).toEqual({ originalMessage: "kaboom", @@ -58,7 +56,6 @@ describe("toMetabaseError", () => { it("wraps a non-Error value as UnknownError with stringified message", () => { const result = toMetabaseError("plain string"); expect(result).toBeInstanceOf(UnknownError); - expect(result.exitCode).toBe(1); expect(result.userMessage).toBe("plain string"); expect(result.developerDetail).toEqual({ originalMessage: "plain string", stack: null }); }); @@ -84,21 +81,26 @@ describe("HttpError sanitization at the print site", () => { }); describe("MetabaseError contract", () => { - it("AbortError exposes the documented exit code and category", () => { + it("AbortError exposes the documented category", () => { const error = new AbortError(); expect(error).toBeInstanceOf(MetabaseError); expect(error.category).toBe("abort"); - expect(error.exitCode).toBe(130); expect(error.developerDetail).toBeNull(); expect(error.userMessage).toBe("aborted"); }); - it("ConfigError exposes the documented exit code and category", () => { + it("ConfigError exposes the documented category", () => { const error = new ConfigError("missing TTY"); expect(error.category).toBe("config"); - expect(error.exitCode).toBe(2); expect(error.userMessage).toBe("missing TTY"); }); + + it("InternalError separates a contract violation from a user config mistake", () => { + const error = new InternalError("pageSize must be at least 1, got 0"); + expect(error.category).toBe("internal"); + expect(error.developerDetail).toBeNull(); + expect(error.userMessage).toBe("pageSize must be at least 1, got 0"); + }); }); describe("formatZodIssue", () => { @@ -221,7 +223,8 @@ function buildResponseShapeError( zodIssues: z.ZodError["issues"], serverTag: string | null = null, ): ResponseShapeError { - return new ResponseShapeError({ + return ResponseShapeError.fromZodIssues({ + kind: "zod", method: "GET", url: "https://m.example.com/api/session/properties", status: 200, @@ -240,7 +243,6 @@ describe("ResponseShapeError", () => { " version.tag: Invalid input: expected string, received undefined", ); expect(error.category).toBe("response-shape"); - expect(error.exitCode).toBe(1); }); it("caps the listed issues at five and appends an overflow line", () => { diff --git a/src/core/errors.ts b/packages/client/src/errors.ts similarity index 79% rename from src/core/errors.ts rename to packages/client/src/errors.ts index 35984ee..a8cfde7 100644 --- a/src/core/errors.ts +++ b/packages/client/src/errors.ts @@ -1,4 +1,3 @@ -import { isCancel } from "@clack/prompts"; import { core as zodCore, ZodError } from "zod"; import { escapeJsonPointerSegment } from "./json-pointer"; @@ -12,6 +11,7 @@ export type ErrorCategory = | "config" | "capability" | "abort" + | "internal" | "unknown"; export interface NetworkErrorDetail { @@ -20,16 +20,28 @@ export interface NetworkErrorDetail { cause: string; } -export type TimeoutErrorDetail = - | { kind: "http"; method: string; url: string; timeoutMs: number } - | { kind: "polling"; timeoutMs: number; attempts: number }; +export interface HttpTimeoutDetail { + kind: "http"; + method: string; + url: string; + timeoutMs: number; +} + +export interface PollingTimeoutDetail { + kind: "polling"; + timeoutMs: number; + attempts: number; +} + +export type TimeoutErrorDetail = HttpTimeoutDetail | PollingTimeoutDetail; export interface ValidationErrorDetail { source: string; zodIssues: ZodError["issues"]; } -export interface ResponseShapeErrorDetail { +export interface ZodResponseShapeDetail { + kind: "zod"; method: string; url: string; status: number; @@ -37,6 +49,14 @@ export interface ResponseShapeErrorDetail { serverTag: string | null; } +export interface DecodedResponseShapeDetail { + kind: "decoded"; + source: string; + value: string | null; +} + +export type ResponseShapeErrorDetail = ZodResponseShapeDetail | DecodedResponseShapeDetail; + export interface UnknownErrorDetail { originalMessage: string; stack: string | null; @@ -45,7 +65,6 @@ export interface UnknownErrorDetail { export abstract class MetabaseError extends Error { abstract readonly category: ErrorCategory; abstract readonly isRetryable: boolean; - abstract readonly exitCode: number; abstract readonly developerDetail: unknown; get userMessage(): string { @@ -56,7 +75,6 @@ export abstract class MetabaseError extends Error { export class NetworkError extends MetabaseError { readonly category = "network"; readonly isRetryable = true; - readonly exitCode = 1; readonly developerDetail: NetworkErrorDetail; constructor(message: string, developerDetail: NetworkErrorDetail) { @@ -69,7 +87,6 @@ export class NetworkError extends MetabaseError { export class TimeoutError extends MetabaseError { readonly category = "timeout"; readonly isRetryable = true; - readonly exitCode = 1; readonly developerDetail: TimeoutErrorDetail; constructor(message: string, developerDetail: TimeoutErrorDetail) { @@ -82,7 +99,6 @@ export class TimeoutError extends MetabaseError { export class ValidationError extends MetabaseError { readonly category = "validation"; readonly isRetryable = false; - readonly exitCode = 1; readonly developerDetail: ValidationErrorDetail; constructor(message: string, developerDetail: ValidationErrorDetail) { @@ -139,14 +155,20 @@ const RESPONSE_SHAPE_LEAD_UNKNOWN_VERSION = "Metabase returned unexpected respon export class ResponseShapeError extends MetabaseError { readonly category = "response-shape"; readonly isRetryable = false; - readonly exitCode = 1; readonly developerDetail: ResponseShapeErrorDetail; - constructor(developerDetail: ResponseShapeErrorDetail) { - super(formatResponseShapeMessage(developerDetail.zodIssues, developerDetail.serverTag)); + constructor(message: string, developerDetail: ResponseShapeErrorDetail) { + super(message); this.name = "ResponseShapeError"; this.developerDetail = developerDetail; } + + static fromZodIssues(developerDetail: ZodResponseShapeDetail): ResponseShapeError { + return new ResponseShapeError( + formatResponseShapeMessage(developerDetail.zodIssues, developerDetail.serverTag), + developerDetail, + ); + } } function formatResponseShapeMessage(issues: ZodError["issues"], serverTag: string | null): string { @@ -163,7 +185,6 @@ function formatResponseShapeMessage(issues: ZodError["issues"], serverTag: strin export class ConfigError extends MetabaseError { readonly category = "config"; readonly isRetryable = false; - readonly exitCode = 2; readonly developerDetail = null; constructor(message: string) { @@ -172,10 +193,22 @@ export class ConfigError extends MetabaseError { } } +// A caller violated a library function's contract. Distinct from `ConfigError` so an agent can +// tell a bad flag apart from a CLI bug: nothing the user types can produce this one. +export class InternalError extends MetabaseError { + readonly category = "internal"; + readonly isRetryable = false; + readonly developerDetail = null; + + constructor(message: string) { + super(message); + this.name = "InternalError"; + } +} + export class AbortError extends MetabaseError { readonly category = "abort"; readonly isRetryable = false; - readonly exitCode = 130; readonly developerDetail = null; constructor(message = "aborted") { @@ -201,10 +234,6 @@ export class ChainedRequestError extends MetabaseError { return this.cause.isRetryable; } - override get exitCode(): number { - return this.cause.exitCode; - } - override get developerDetail(): unknown { return this.cause.developerDetail; } @@ -213,7 +242,6 @@ export class ChainedRequestError extends MetabaseError { export class UnknownError extends MetabaseError { readonly category = "unknown"; readonly isRetryable = false; - readonly exitCode = 1; readonly developerDetail: UnknownErrorDetail; constructor(input: UnknownErrorDetail) { @@ -227,9 +255,6 @@ export function toMetabaseError(error: unknown): MetabaseError { if (error instanceof MetabaseError) { return error; } - if (isCancel(error)) { - return new AbortError(); - } if (error instanceof ZodError) { return new ConfigError(error.issues.map(formatZodIssue).join("; ")); } @@ -244,7 +269,9 @@ export function formatZodIssue(issue: ZodError["issues"][number]): string { return path === "" ? issue.message : `${path}: ${issue.message}`; } -export function isNotFoundError(value: unknown): value is NodeJS.ErrnoException { +// Answers a question rather than narrowing a type: `NodeJS.ErrnoException` on the public surface would +// oblige every consumer to install `@types/node`, and no caller reads `.code` off the value anyway. +export function isFileNotFoundError(value: unknown): boolean { return value instanceof Error && "code" in value && value.code === "ENOENT"; } diff --git a/src/core/http/errors.test.ts b/packages/client/src/http/errors.test.ts similarity index 62% rename from src/core/http/errors.test.ts rename to packages/client/src/http/errors.test.ts index 99edc05..2394121 100644 --- a/src/core/http/errors.test.ts +++ b/packages/client/src/http/errors.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; -import { HttpError } from "./errors"; +import { ChainedRequestError, ConfigError } from "../errors"; + +import { chainRequestFailure, HttpError, isHttpNotFound } from "./errors"; +import type { RedactionContext } from "./sanitize"; interface HttpErrorFixtureOverrides { status?: number; @@ -10,6 +13,7 @@ interface HttpErrorFixtureOverrides { rawBody?: string | null; serverTag?: string | null; overrideUserMessage?: string; + redactionContext?: RedactionContext; } function buildHttpError(overrides: HttpErrorFixtureOverrides = {}): HttpError { @@ -24,6 +28,9 @@ function buildHttpError(overrides: HttpErrorFixtureOverrides = {}): HttpError { ...(overrides.overrideUserMessage !== undefined && { overrideUserMessage: overrides.overrideUserMessage, }), + ...(overrides.redactionContext !== undefined && { + redactionContext: overrides.redactionContext, + }), }); } @@ -176,6 +183,98 @@ describe("HttpError message extraction", () => { }); }); +// Bodies Metabase itself pins in its backend tests: `POST /api/card` with a body that misses every +// required key, and `POST /api/api-key` with a blank name. +const CARD_CREATE_400_BODY = JSON.stringify({ + errors: { + name: "value must be a non-blank string.", + dataset_query: "Value must be a map.", + display: "value must be a non-blank string.", + visualization_settings: "Value must be a map.", + }, + "specific-errors": { + name: ["missing required key, received: nil"], + dataset_query: ["missing required key, received: nil"], + display: ["missing required key, received: nil"], + visualization_settings: ['Value must be a map., received: "ABC"'], + }, +}); + +const API_KEY_BLANK_NAME_400_BODY = JSON.stringify({ + errors: { name: "value must be a non-blank string." }, + "specific-errors": { + name: ['should be at least 1 character, received: ""', 'non-blank string, received: ""'], + }, +}); + +describe("HttpError field errors", () => { + it("exposes the errors map keyed by field", () => { + expect(buildHttpError({ rawBody: CARD_CREATE_400_BODY }).fieldErrors).toEqual({ + name: "value must be a non-blank string.", + dataset_query: "Value must be a map.", + display: "value must be a non-blank string.", + visualization_settings: "Value must be a map.", + }); + }); + + it("exposes specific-errors as the received-value view of the same fields", () => { + expect(buildHttpError({ rawBody: CARD_CREATE_400_BODY }).specificFieldErrors).toEqual({ + name: "missing required key, received: nil", + dataset_query: "missing required key, received: nil", + display: "missing required key, received: nil", + visualization_settings: 'Value must be a map., received: "ABC"', + }); + }); + + it("joins the several reasons one field was rejected for", () => { + expect(buildHttpError({ rawBody: API_KEY_BLANK_NAME_400_BODY }).specificFieldErrors).toEqual({ + name: 'should be at least 1 character, received: ""; non-blank string, received: ""', + }); + }); + + it("keys a rejection nested inside a body object by its dot path", () => { + const body = JSON.stringify({ + "specific-errors": { prefs: { site_locale: ['valid locale, received: "eng-USA"'] } }, + }); + expect(buildHttpError({ rawBody: body }).specificFieldErrors).toEqual({ + "prefs.site_locale": 'valid locale, received: "eng-USA"', + }); + }); + + it("answers null when the envelope carries no field maps", () => { + const body = JSON.stringify({ message: "The object has been archived." }); + expect(buildHttpError({ rawBody: body }).fieldErrors).toBeNull(); + }); + + it("answers null when the body is not an error envelope at all", () => { + expect(buildHttpError({ status: 500, rawBody: "not json at all" }).fieldErrors).toBeNull(); + }); + + it("keeps the status and the server's message when the errors value is not a field map", () => { + const error = buildHttpError({ + rawBody: JSON.stringify({ message: "Invalid body", errors: "everything" }), + }); + expect(error).toBeInstanceOf(HttpError); + expect(error.fieldErrors).toBeNull(); + expect(error.status).toBe(400); + expect(error.message).toBe("Invalid body"); + }); + + it("drops the map rather than half of it when one field's message is not a string", () => { + const body = JSON.stringify({ errors: { name: "must be present", limit: 42 } }); + expect(buildHttpError({ rawBody: body }).fieldErrors).toBeNull(); + }); + + it("redacts a known secret quoted back inside a field message", () => { + const error = buildHttpError({ + status: 403, + rawBody: JSON.stringify({ errors: { api_key: "mb_live_secret is not valid here" } }), + redactionContext: { knownSecrets: new Set(["mb_live_secret"]) }, + }); + expect(error.fieldErrors).toEqual({ api_key: "[REDACTED] is not valid here" }); + }); +}); + describe("HttpError kind classification", () => { it("classifies 401 and 403 as auth", () => { expect(buildHttpError({ status: 401 }).kind).toBe("auth"); @@ -238,7 +337,7 @@ describe("HttpError kind classification", () => { }); describe("HttpError 404 messages", () => { - it("renders route-missing with the server tag and a real-command hint when the tag is known", () => { + it("renders route-missing with the server tag when the tag is known", () => { const error = buildHttpError({ status: 404, method: "GET", @@ -249,8 +348,7 @@ describe("HttpError 404 messages", () => { }); expect(error.message).toBe( "This endpoint is not available on Metabase v0.58.7: GET /api/this-does-not-exist?q=1. " + - "The command may require a newer Metabase major version. " + - "Run 'mb auth list' to see this server's version.", + "It may require a newer Metabase major version.", ); }); @@ -291,3 +389,53 @@ describe("HttpError 404 messages", () => { expect(error.message).not.toContain("mb doctor"); }); }); + +describe("isHttpNotFound", () => { + it("answers true for a 404 HttpError", () => { + expect(isHttpNotFound(buildHttpError({ status: 404, responseHeaders: jsonHeaders() }))).toBe( + true, + ); + }); + + it("answers false for a 400 HttpError", () => { + expect(isHttpNotFound(buildHttpError({ status: 400 }))).toBe(false); + }); + + it("answers false for a 500 HttpError", () => { + expect(isHttpNotFound(buildHttpError({ status: 500 }))).toBe(false); + }); + + it("answers false for an ENOENT error, which the similarly named file guard owns", () => { + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { + code: "ENOENT", + }); + expect(isHttpNotFound(enoent)).toBe(false); + }); +}); + +describe("chainRequestFailure", () => { + it("keeps a non-HTTP failure's category and retryability behind the new message", () => { + const cause = new ConfigError("dashcards must be an array"); + + const chained = chainRequestFailure(cause, "the follow-up write failed"); + + expect(chained).toBeInstanceOf(ChainedRequestError); + expect(chained.userMessage).toBe("the follow-up write failed"); + expect(chained.category).toBe("config"); + }); + + it("rebuilds an HttpError so its status and field errors stay readable", () => { + const cause = buildHttpError({ + status: 400, + responseHeaders: jsonHeaders(), + rawBody: '{"errors":{"name":"value must be a non-blank string."}}', + }); + + const chained = chainRequestFailure(cause, "the follow-up write failed"); + + assert(chained instanceof HttpError, "expected HttpError"); + expect(chained.status).toBe(400); + expect(chained.userMessage).toBe("the follow-up write failed"); + expect(chained.fieldErrors).toEqual({ name: "value must be a non-blank string." }); + }); +}); diff --git a/src/core/http/errors.ts b/packages/client/src/http/errors.ts similarity index 61% rename from src/core/http/errors.ts rename to packages/client/src/http/errors.ts index 2c48fae..e8fba5e 100644 --- a/src/core/http/errors.ts +++ b/packages/client/src/http/errors.ts @@ -1,8 +1,8 @@ import { z } from "zod"; -import { MetabaseError } from "../errors"; -import { JSON_CONTENT_TYPE, parseJsonResult } from "../../runtime/json"; -import { isPlainObject } from "../../runtime/predicates"; +import { ChainedRequestError, MetabaseError } from "../errors"; +import { JSON_CONTENT_TYPE, parseJsonResult } from "../json"; +import { isPlainObject } from "../predicates"; import { redactBody, redactHeaders, type RedactionContext } from "./sanitize"; @@ -19,6 +19,8 @@ interface StatusClassification { message?: string; } +const NOT_FOUND_STATUS = 404; + const ROUTE_MISSING_LITERAL = "API endpoint does not exist."; const RESOURCE_MISSING_LITERAL = "Not found."; @@ -46,6 +48,25 @@ const ErrorEnvelope = z }) .loose(); +interface FieldErrorBranch { + [field: string]: FieldErrorNode; +} + +type FieldErrorNode = string | string[] | FieldErrorBranch; + +const FieldErrorNodeSchema: z.ZodType = z.lazy(() => + z.union([z.string(), z.array(z.string()), z.record(z.string(), FieldErrorNodeSchema)]), +); + +const FieldErrorTree = z.record(z.string(), FieldErrorNodeSchema); + +// Field name to the message Metabase rejected it with. A nested rejection is keyed by its +// dot-joined path (`prefs.site_locale`), and a field the server gave several reasons for carries +// them joined, so one string always answers "what is wrong with this field". +export type FieldErrors = Record; + +const FIELD_MESSAGE_SEPARATOR = "; "; + const MAX_EXTRACTED_MESSAGE_LEN = 500; const ELLIPSIS = "…"; @@ -56,6 +77,8 @@ export interface HttpErrorDetail { url: string; responseHeaders: Record; body: string | null; + fieldErrors: FieldErrors | null; + specificFieldErrors: FieldErrors | null; } export interface HttpErrorInput { @@ -72,7 +95,6 @@ export interface HttpErrorInput { export class HttpError extends MetabaseError { readonly category = "http"; - readonly exitCode = 1; readonly status: number; readonly kind: HttpErrorKind; readonly developerDetail: HttpErrorDetail; @@ -82,6 +104,7 @@ export class HttpError extends MetabaseError { const redactedHeaders = redactHeaders(input.responseHeaders); const kind = classifyKind(input.status, sanitizedBody, redactedHeaders); super(input.overrideUserMessage ?? buildUserMessage(kind, input, sanitizedBody)); + const fields = extractFieldErrors(sanitizedBody); this.name = "HttpError"; this.status = input.status; this.kind = kind; @@ -92,12 +115,52 @@ export class HttpError extends MetabaseError { url: input.url, responseHeaders: redactedHeaders, body: sanitizedBody, + fieldErrors: fields.fieldErrors, + specificFieldErrors: fields.specificFieldErrors, }; } get isRetryable(): boolean { return isRetryableStatus(this.status); } + + // What the server said about each field it rejected, from the envelope's `errors` map: the + // humanised requirement ("value must be a non-blank string."). + get fieldErrors(): FieldErrors | null { + return this.developerDetail.fieldErrors; + } + + // The same fields from the envelope's `specific-errors` map, which names what arrived + // ("missing required key, received: nil") rather than what was required. + get specificFieldErrors(): FieldErrors | null { + return this.developerDetail.specificFieldErrors; + } +} + +// Answers the status alone. `HttpError.kind` splits the same 404 into `route-missing` (this +// Metabase does not serve the route) and `resource-missing` (it does, and the row is gone); a caller +// that has to tell those apart reads `kind` instead. +export function isHttpNotFound(error: unknown): boolean { + return error instanceof HttpError && error.status === NOT_FOUND_STATUS; +} + +// Reports a failure that followed an already-successful request under `message`, keeping the wire +// truth of the failure itself. An `HttpError` is rebuilt rather than wrapped, because a caller that +// reads `status` or `fieldErrors` off the failure must still find them; every other error keeps its +// category and retryability through `ChainedRequestError`. +export function chainRequestFailure(cause: MetabaseError, message: string): MetabaseError { + if (cause instanceof HttpError) { + return new HttpError({ + status: cause.status, + statusText: cause.developerDetail.statusText, + method: cause.developerDetail.method, + url: cause.developerDetail.url, + responseHeaders: cause.developerDetail.responseHeaders, + rawBody: cause.developerDetail.body, + overrideUserMessage: message, + }); + } + return new ChainedRequestError(message, cause); } export function isRetryableStatus(status: number): boolean { @@ -122,7 +185,7 @@ function classifyKind( if (status === 401 || status === 403) { return "auth"; } - if (status === 404) { + if (status === NOT_FOUND_STATUS) { return isRouteMissingResponse(sanitizedBody, redactedHeaders) ? "route-missing" : "resource-missing"; @@ -155,7 +218,7 @@ function isRouteMissingResponse( if (sanitizedBody === null || sanitizedBody.trim() === "") { return true; } - return !parseJsonResult(sanitizedBody, ErrorEnvelope).ok; + return parseEnvelope(sanitizedBody) === null; } function buildUserMessage( @@ -188,8 +251,7 @@ function buildRouteMissingMessage(input: HttpErrorInput): string { } return ( `This endpoint is not available on Metabase ${input.serverTag}: ${input.method} ${path}. ` + - `The command may require a newer Metabase major version. ` + - `Run 'mb auth list' to see this server's version.` + `It may require a newer Metabase major version.` ); } @@ -202,15 +264,56 @@ function hostFromUrl(url: string): string { return new URL(url).host; } -function parseEnvelopeMessage(sanitizedBody: string | null): string | null { +type ErrorEnvelope = z.infer; + +function parseEnvelope(sanitizedBody: string | null): ErrorEnvelope | null { if (!sanitizedBody) { return null; } const result = parseJsonResult(sanitizedBody, ErrorEnvelope); - if (!result.ok) { + return result.ok ? result.value : null; +} + +interface FieldErrorViews { + fieldErrors: FieldErrors | null; + specificFieldErrors: FieldErrors | null; +} + +const NO_FIELD_ERRORS: FieldErrorViews = { fieldErrors: null, specificFieldErrors: null }; + +// Read off the sanitized body rather than the raw one, so a secret quoted back inside a field +// message is already redacted by the time it reaches this typed surface. +function extractFieldErrors(sanitizedBody: string | null): FieldErrorViews { + const envelope = parseEnvelope(sanitizedBody); + if (envelope === null) { + return NO_FIELD_ERRORS; + } + return { + fieldErrors: parseFieldErrors(envelope.errors), + specificFieldErrors: parseFieldErrors(envelope["specific-errors"]), + }; +} + +// An HTTP failure is already the error path, so a value the field-error shape does not recognise +// costs the caller the structured view and nothing else — the status and message it came for +// survive untouched. +function parseFieldErrors(value: unknown): FieldErrors | null { + const parsed = FieldErrorTree.safeParse(value); + if (!parsed.success) { + return null; + } + const entries = collectLeafEntries(parsed.data, []); + if (entries.length === 0) { + return null; + } + return Object.fromEntries(entries.map((entry) => [entry.path, entry.message])); +} + +function parseEnvelopeMessage(sanitizedBody: string | null): string | null { + const envelope = parseEnvelope(sanitizedBody); + if (envelope === null) { return null; } - const envelope = result.value; const topLevel = envelope.message ?? envelope.error ?? envelope["error-message"]; if (topLevel) { return capLength(topLevel); @@ -240,7 +343,7 @@ function formatErrorTree(value: unknown): string | null { if (entries.length === 0) { return null; } - return entries.map(formatLeafEntry).join("; "); + return entries.map(formatLeafEntry).join(FIELD_MESSAGE_SEPARATOR); } function formatLeafEntry(entry: LeafEntry): string { @@ -259,7 +362,7 @@ function collectLeafEntries(value: unknown, path: ReadonlyArray): LeafEn if (messages.length === 0) { return []; } - return [{ path: path.join("."), message: messages.join("; ") }]; + return [{ path: path.join("."), message: messages.join(FIELD_MESSAGE_SEPARATOR) }]; } if (isPlainObject(value)) { return Object.entries(value).flatMap(([key, child]) => diff --git a/src/core/http/network-error.ts b/packages/client/src/http/network-error.ts similarity index 64% rename from src/core/http/network-error.ts rename to packages/client/src/http/network-error.ts index 1e00636..4b2e041 100644 --- a/src/core/http/network-error.ts +++ b/packages/client/src/http/network-error.ts @@ -12,9 +12,21 @@ const NETWORK_HINTS: Record string> = { EAI_AGAIN: (t) => `Could not resolve ${t.hostname} — check your network connection and the URL.`, ECONNRESET: (t) => `Connection to ${t.host} was reset — the server may have closed it, or http/https may be mismatched.`, + UND_ERR_SOCKET: (t) => + `Connection to ${t.host} was closed by the other side — a pooled connection or a proxy may have dropped it.`, + EPIPE: (t) => + `Connection to ${t.host} closed while the request was still being written — the server or a proxy dropped it.`, ETIMEDOUT: (t) => `Connection to ${t.host} timed out — check the host, port, and your network.`, }; +// Codes that mean the socket went away, not that the server is unhealthy: a keep-alive connection +// idle-reaped by the peer surfaces here identically to one the server never accepted. +const CONNECTION_CLOSED_CODES: ReadonlySet = new Set([ + "ECONNRESET", + "UND_ERR_SOCKET", + "EPIPE", +]); + const TLS_ERROR_CODES: ReadonlySet = new Set([ "EPROTO", "DEPTH_ZERO_SELF_SIGNED_CERT", @@ -29,7 +41,7 @@ const TLS_ERROR_CODES: ReadonlySet = new Set([ // diagnostics instead of a bare `TypeError: fetch failed`. export function buildNetworkError(error: unknown, method: string, url: string): NetworkError { const fallback = errorMessage(error); - const code = causeCode(error); + const code = failureCode(error); const target = networkTarget(url); return new NetworkError(networkMessage(code, target, fallback), { method, @@ -38,6 +50,11 @@ export function buildNetworkError(error: unknown, method: string, url: string): }); } +export function isConnectionClosed(error: unknown): boolean { + const code = failureCode(error); + return code !== null && CONNECTION_CLOSED_CODES.has(code); +} + function networkMessage(code: string | null, target: NetworkTarget, fallback: string): string { if (code === null) { return `Could not reach Metabase: ${fallback}`; @@ -56,10 +73,20 @@ function networkMessage(code: string | null, target: NetworkTarget, fallback: st return `Could not reach Metabase: ${hint(target)}`; } -function causeCode(error: unknown): string | null { - const cause = error instanceof Error ? error.cause : undefined; - if (cause instanceof Error && "code" in cause && typeof cause.code === "string") { - return cause.code; +// Node wraps a transport failure in `TypeError: fetch failed` and carries the syscall code on +// `cause`; Bun rejects with a single error that carries `code` itself. Reading both keeps the +// classification runtime-independent. +function failureCode(error: unknown): string | null { + const own = errorCode(error); + if (own !== null) { + return own; + } + return errorCode(error instanceof Error ? error.cause : undefined); +} + +function errorCode(value: unknown): string | null { + if (value instanceof Error && "code" in value && typeof value.code === "string") { + return value.code; } return null; } diff --git a/src/core/http/oauth.test.ts b/packages/client/src/http/oauth.test.ts similarity index 90% rename from src/core/http/oauth.test.ts rename to packages/client/src/http/oauth.test.ts index 8c6c5e9..f2e46ca 100644 --- a/src/core/http/oauth.test.ts +++ b/packages/client/src/http/oauth.test.ts @@ -2,9 +2,14 @@ import { afterEach, assert, describe, expect, it, vi } from "vitest"; import { ConfigError, NetworkError } from "../errors"; -import { USER_AGENT } from "./client"; import { HttpError } from "./errors"; -import { captureFetch, jsonResponse, type FetchCapture, type FetchScript } from "./fetch-capture"; +import { + captureFetch, + jsonResponse, + TEST_USER_AGENT, + type FetchCapture, + type FetchScript, +} from "../testing/fetch-capture"; import { discoverMetadata, exchangeCode, @@ -22,7 +27,6 @@ function installFetch(script: FetchScript): FetchCapture { } const TOKEN_ENDPOINT = "https://mb.example.com/oauth/token"; - describe("oauth HTTP boundary", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -38,7 +42,7 @@ describe("oauth HTTP boundary", () => { scopes_supported: ["agent:sql:read", OAUTH_SCOPE], }), ]); - const metadata = await discoverMetadata("https://mb.example.com"); + const metadata = await discoverMetadata("https://mb.example.com", TEST_USER_AGENT); expect(metadata).toEqual({ issuer: "https://mb.example.com", authorization_endpoint: "https://mb.example.com/oauth/authorize", @@ -59,7 +63,7 @@ describe("oauth HTTP boundary", () => { token_endpoint: "https://my.org.com/metabase/oauth/token", }), ]); - const metadata = await discoverMetadata("https://my.org.com/metabase"); + const metadata = await discoverMetadata("https://my.org.com/metabase", TEST_USER_AGENT); expect(metadata).toEqual({ issuer: "https://my.org.com/metabase", authorization_endpoint: "https://my.org.com/metabase/oauth/authorize", @@ -79,7 +83,7 @@ describe("oauth HTTP boundary", () => { scopes_supported: ["agent:sql:read", "agent:query"], }), ]); - expect(await tryDiscoverMetadata("https://mb.example.com")).toBeNull(); + expect(await tryDiscoverMetadata("https://mb.example.com", TEST_USER_AGENT)).toBeNull(); }); it("accepts a discovery document that omits scopes_supported", async () => { @@ -90,7 +94,7 @@ describe("oauth HTTP boundary", () => { token_endpoint: TOKEN_ENDPOINT, }), ]); - expect(await tryDiscoverMetadata("https://mb.example.com")).toEqual({ + expect(await tryDiscoverMetadata("https://mb.example.com", TEST_USER_AGENT)).toEqual({ issuer: "https://mb.example.com", authorization_endpoint: "https://mb.example.com/oauth/authorize", token_endpoint: TOKEN_ENDPOINT, @@ -104,17 +108,17 @@ describe("oauth HTTP boundary", () => { headers: { "content-type": "text/html;charset=utf-8" }, }), ]); - expect(await tryDiscoverMetadata("https://mb.example.com")).toBeNull(); + expect(await tryDiscoverMetadata("https://mb.example.com", TEST_USER_AGENT)).toBeNull(); }); it("treats a 404 at the discovery path as no OAuth support", async () => { installFetch([new Response("Not found.", { status: 404 })]); - expect(await tryDiscoverMetadata("https://mb.example.com")).toBeNull(); + expect(await tryDiscoverMetadata("https://mb.example.com", TEST_USER_AGENT)).toBeNull(); }); it("discoverMetadata names the required Metabase version when OAuth is unsupported", async () => { installFetch([new Response("Not found.", { status: 404 })]); - const error = await discoverMetadata("https://mb.example.com").catch( + const error = await discoverMetadata("https://mb.example.com", TEST_USER_AGENT).catch( (caught: unknown) => caught, ); expect(error).toBeInstanceOf(ConfigError); @@ -131,7 +135,7 @@ describe("oauth HTTP boundary", () => { token_endpoint: "https://attacker.example.com/oauth/token", }), ]); - const error = await discoverMetadata("https://mb.example.com").catch( + const error = await discoverMetadata("https://mb.example.com", TEST_USER_AGENT).catch( (caught: unknown) => caught, ); expect(error).toBeInstanceOf(ConfigError); @@ -148,7 +152,7 @@ describe("oauth HTTP boundary", () => { token_endpoint: "http://mb.example.com/oauth/token", }), ]); - const error = await discoverMetadata("http://mb.example.com").catch( + const error = await discoverMetadata("http://mb.example.com", TEST_USER_AGENT).catch( (caught: unknown) => caught, ); expect(error).toBeInstanceOf(ConfigError); @@ -164,7 +168,7 @@ describe("oauth HTTP boundary", () => { token_endpoint: "http://localhost:3000/oauth/token", }), ]); - const metadata = await discoverMetadata("http://localhost:3000"); + const metadata = await discoverMetadata("http://localhost:3000", TEST_USER_AGENT); expect(metadata).toEqual({ issuer: "http://localhost:3000", authorization_endpoint: "http://localhost:3000/oauth/authorize", @@ -188,6 +192,7 @@ describe("oauth HTTP boundary", () => { redirectUri: "http://127.0.0.1:5000/callback", clientId: "client-1", codeVerifier: "verifier-1", + userAgent: TEST_USER_AGENT, }); expect(tokens).toEqual({ access_token: "acc", @@ -217,6 +222,7 @@ describe("oauth HTTP boundary", () => { tokenEndpoint: TOKEN_ENDPOINT, refreshToken: "ref1", clientId: "client-1", + userAgent: TEST_USER_AGENT, }); expect(tokens).toEqual({ access_token: "acc2", token_type: "Bearer", refresh_token: "ref2" }); expect(stub.calls[0]?.body).toBe( @@ -236,6 +242,7 @@ describe("oauth HTTP boundary", () => { tokenEndpoint: TOKEN_ENDPOINT, refreshToken: "ref1", clientId: "client-1", + userAgent: TEST_USER_AGENT, }).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(ConfigError); assert(error instanceof ConfigError, "expected ConfigError"); @@ -248,6 +255,7 @@ describe("oauth HTTP boundary", () => { tokenEndpoint: TOKEN_ENDPOINT, refreshToken: "ref1", clientId: "client-1", + userAgent: TEST_USER_AGENT, }).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(HttpError); assert(error instanceof HttpError, "expected HttpError"); @@ -265,6 +273,7 @@ describe("oauth HTTP boundary", () => { tokenEndpoint: TOKEN_ENDPOINT, refreshToken: "ref1", clientId: "client-1", + userAgent: TEST_USER_AGENT, }).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(NetworkError); assert(error instanceof NetworkError, "expected NetworkError"); @@ -273,7 +282,7 @@ describe("oauth HTTP boundary", () => { ); }); - it("stamps the shared user-agent on OAuth requests", async () => { + it("stamps the caller's user-agent on OAuth requests", async () => { const stub = installFetch([ jsonResponse({ access_token: "acc", token_type: "Bearer", refresh_token: "ref" }), ]); @@ -281,8 +290,9 @@ describe("oauth HTTP boundary", () => { tokenEndpoint: TOKEN_ENDPOINT, refreshToken: "ref1", clientId: "client-1", + userAgent: TEST_USER_AGENT, }); - expect(stub.calls[0]?.headers["user-agent"]).toBe(USER_AGENT); + expect(stub.calls[0]?.headers["user-agent"]).toBe(TEST_USER_AGENT); }); it("revokes a token with the token and client_id in the form body", async () => { @@ -291,6 +301,7 @@ describe("oauth HTTP boundary", () => { revocationEndpoint: "https://mb.example.com/oauth/revoke", token: "ref1", clientId: "client-1", + userAgent: TEST_USER_AGENT, }); expect(stub.calls[0]?.body).toBe( new URLSearchParams({ token: "ref1", client_id: "client-1" }).toString(), diff --git a/src/core/http/oauth.ts b/packages/client/src/http/oauth.ts similarity index 92% rename from src/core/http/oauth.ts rename to packages/client/src/http/oauth.ts index 20f1c75..cfb0530 100644 --- a/src/core/http/oauth.ts +++ b/packages/client/src/http/oauth.ts @@ -1,10 +1,9 @@ import { z } from "zod"; -import { JSON_CONTENT_TYPE, parseJsonResult } from "../../runtime/json"; +import { JSON_CONTENT_TYPE, parseJsonResult } from "../json"; import { ConfigError, errorMessage, TimeoutError } from "../errors"; import { assertEndpointOrigin } from "../url"; -import { USER_AGENT } from "./client"; import { HttpError, isRetryableStatus } from "./errors"; import { buildNetworkError } from "./network-error"; @@ -17,11 +16,12 @@ type OAuthMethod = "GET" | "POST"; interface OAuthRequest { url: string; method: OAuthMethod; + userAgent: string; headers: Record; body?: string | URLSearchParams; } -// Single fetch path for every OAuth protocol call: stamps the shared user-agent, enforces the +// Single fetch path for every OAuth protocol call: stamps the caller's user-agent, enforces the // timeout, and maps transport failures to the same NetworkError/TimeoutError taxonomy as the typed // client — so a refresh that can't reach the server surfaces a diagnostic instead of a bare // `TypeError: fetch failed` that masks the original error. @@ -30,7 +30,7 @@ async function oauthFetch(request: OAuthRequest): Promise { try { return await fetch(request.url, { method: request.method, - headers: { ...request.headers, "user-agent": USER_AGENT }, + headers: { ...request.headers, "user-agent": request.userAgent }, ...(request.body !== undefined ? { body: request.body } : {}), signal: timeoutSignal, }); @@ -84,6 +84,7 @@ export interface ClientRegistration { registrationEndpoint: string; redirectUri: string; clientName: string; + userAgent: string; } export interface CodeExchange { @@ -92,18 +93,21 @@ export interface CodeExchange { redirectUri: string; clientId: string; codeVerifier: string; + userAgent: string; } export interface TokenRefresh { tokenEndpoint: string; refreshToken: string; clientId: string; + userAgent: string; } export interface TokenRevocation { revocationEndpoint: string; token: string; clientId: string; + userAgent: string; } async function readJson(response: Response, schema: z.ZodType, source: string): Promise { @@ -150,6 +154,7 @@ async function failure( async function postForm( url: string, + userAgent: string, params: Record, schema: z.ZodType, source: string, @@ -157,6 +162,7 @@ async function postForm( const response = await oauthFetch({ url, method: "POST", + userAgent, headers: { "content-type": FORM_CONTENT_TYPE, accept: JSON_CONTENT_TYPE }, body: new URLSearchParams(params), }); @@ -177,11 +183,15 @@ export const OAUTH_UNSUPPORTED_MESSAGE = // The well-known path is appended after any subpath (base + /.well-known/...), not inserted // between host and path as RFC 8414 prescribes — Metabase routes it like /api/*, so a // subpath-hosted instance serves discovery under its prefix. -export async function tryDiscoverMetadata(baseUrl: string): Promise { +export async function tryDiscoverMetadata( + baseUrl: string, + userAgent: string, +): Promise { const url = `${baseUrl}${DISCOVERY_PATH}`; const response = await oauthFetch({ url, method: "GET", + userAgent, headers: { accept: JSON_CONTENT_TYPE }, }); const contentType = response.headers.get("content-type") ?? ""; @@ -210,8 +220,11 @@ export async function tryDiscoverMetadata(baseUrl: string): Promise { - const metadata = await tryDiscoverMetadata(baseUrl); +export async function discoverMetadata( + baseUrl: string, + userAgent: string, +): Promise { + const metadata = await tryDiscoverMetadata(baseUrl, userAgent); if (metadata === null) { throw new ConfigError(OAUTH_UNSUPPORTED_MESSAGE); } @@ -222,6 +235,7 @@ export async function registerClient(input: ClientRegistration): Promise { return postForm( input.tokenEndpoint, + input.userAgent, { grant_type: "authorization_code", code: input.code, @@ -257,6 +272,7 @@ export function exchangeCode(input: CodeExchange): Promise { export function refreshTokens(input: TokenRefresh): Promise { return postForm( input.tokenEndpoint, + input.userAgent, { grant_type: "refresh_token", refresh_token: input.refreshToken, @@ -271,6 +287,7 @@ export async function revokeToken(input: TokenRevocation): Promise { const response = await oauthFetch({ url: input.revocationEndpoint, method: "POST", + userAgent: input.userAgent, headers: { "content-type": FORM_CONTENT_TYPE }, body: new URLSearchParams({ token: input.token, client_id: input.clientId }), }); diff --git a/packages/client/src/http/response-shape.ts b/packages/client/src/http/response-shape.ts new file mode 100644 index 0000000..a0612ea --- /dev/null +++ b/packages/client/src/http/response-shape.ts @@ -0,0 +1,39 @@ +import type { ZodType } from "zod"; + +import { ResponseShapeError, ValidationError } from "../errors"; +import { parseJson } from "../json"; + +import type { HttpMethod, ServerTagResolver } from "./transport"; + +export const NO_SERVER_TAG: ServerTagResolver = async () => null; + +export interface ResponseContext { + method: HttpMethod; + url: string; + status: number; + getServerTag: ServerTagResolver; +} + +// The server tag is resolved lazily: naming the version costs a request, and only a failed parse +// has anything to name it in. +export async function parseJsonResponse( + text: string, + schema: ZodType, + context: ResponseContext, +): Promise { + try { + return parseJson(text, schema, { source: context.url }); + } catch (error) { + if (error instanceof ValidationError) { + throw ResponseShapeError.fromZodIssues({ + kind: "zod", + method: context.method, + url: context.url, + status: context.status, + zodIssues: error.developerDetail.zodIssues, + serverTag: await context.getServerTag(), + }); + } + throw error; + } +} diff --git a/src/core/http/retry.test.ts b/packages/client/src/http/retry.test.ts similarity index 93% rename from src/core/http/retry.test.ts rename to packages/client/src/http/retry.test.ts index d8547be..92b01d8 100644 --- a/src/core/http/retry.test.ts +++ b/packages/client/src/http/retry.test.ts @@ -4,8 +4,8 @@ import { backoffDelay, runWithRetries } from "./retry"; describe("backoffDelay", () => { it("scales exponentially with attempt when no Retry-After is present", () => { - const delays = [0, 1, 2].map((attempt) => backoffDelay({ attempt })); - expect(delays).toEqual([250, 500, 1000]); + const delays = [0, 1, 2, 3, 4, 5].map((attempt) => backoffDelay({ attempt })); + expect(delays).toEqual([250, 500, 1000, 2000, 4000, 8000]); }); it("caps the exponential backoff at 8000ms", () => { diff --git a/src/core/http/retry.ts b/packages/client/src/http/retry.ts similarity index 100% rename from src/core/http/retry.ts rename to packages/client/src/http/retry.ts diff --git a/src/core/http/sanitize.test.ts b/packages/client/src/http/sanitize.test.ts similarity index 100% rename from src/core/http/sanitize.test.ts rename to packages/client/src/http/sanitize.test.ts diff --git a/src/core/http/sanitize.ts b/packages/client/src/http/sanitize.ts similarity index 100% rename from src/core/http/sanitize.ts rename to packages/client/src/http/sanitize.ts diff --git a/packages/client/src/http/transport.test.ts b/packages/client/src/http/transport.test.ts new file mode 100644 index 0000000..0f4c944 --- /dev/null +++ b/packages/client/src/http/transport.test.ts @@ -0,0 +1,1229 @@ +import { setTimeout as delay } from "node:timers/promises"; + +import { assert, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import type { OAuthCredential } from "../auth/credential"; +import { AbortError, ConfigError, NetworkError, ResponseShapeError, TimeoutError } from "../errors"; + +import { + type ClientCredentials, + createTransport, + type RequestOptions, + type Transport, +} from "./transport"; +import { HttpError } from "./errors"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CONFIG: ClientCredentials = { + url: "https://m.example.com", + credential: { kind: "apiKey", apiKey: "mb_test_key_abcdef0123" }, +}; + +const PingResponse = z.object({ id: z.number().int(), email: z.string() }); + +const HANGING_FETCH: typeof fetch = (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("Aborted", "AbortError")); + }); + }); + +function transportFailure(code: string, detail: string): TypeError { + const failure = new TypeError("fetch failed"); + failure.cause = Object.assign(new Error(detail), { code }); + return failure; +} + +// Bun rejects with one error carrying the syscall code itself, where Node nests it under `cause`. +function bunTransportFailure(code: string, detail: string): Error { + return Object.assign(new Error(detail), { code }); +} + +describe("createTransport.requestParsed", () => { + it("returns parsed JSON on a 200 response", async () => { + const fakeFetch = captureFetch([jsonResponse({ id: 1, email: "a@b.com" })]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const result = await client.requestParsed(PingResponse, "/api/user/current"); + + expect(result).toEqual({ id: 1, email: "a@b.com" }); + expect(fakeFetch.calls).toEqual([ + { + url: "https://m.example.com/api/user/current", + method: "GET", + headers: { + "x-api-key": "mb_test_key_abcdef0123", + accept: "application/json", + "user-agent": TEST_USER_AGENT, + }, + body: null, + }, + ]); + }); + + it("retries 5xx responses and returns the eventual success body", async () => { + const fakeFetch = captureFetch([ + new Response("oops", { status: 500 }), + new Response("oops again", { status: 502 }), + jsonResponse({ id: 7, email: "u@m.com" }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const result = await client.requestParsed(PingResponse, "/api/user/current", { retries: 3 }); + + expect(result).toEqual({ id: 7, email: "u@m.com" }); + expect(fakeFetch.calls.length).toBe(3); + }); + + it("throws HttpError when retries are exhausted", async () => { + const fakeFetch = captureFetch([ + new Response('{"message":"server boom"}', { + status: 500, + headers: { "content-type": "application/json" }, + }), + new Response('{"message":"server boom"}', { + status: 500, + headers: { "content-type": "application/json" }, + }), + new Response('{"message":"server boom"}', { + status: 500, + headers: { "content-type": "application/json" }, + }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 2 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.status).toBe(500); + expect(error.userMessage).toBe("server boom"); + expect(fakeFetch.calls.length).toBe(3); + }); + + it("retries on 429 with Retry-After header", async () => { + const fakeFetch = captureFetch([ + new Response("rate", { status: 429, headers: { "retry-after": "0" } }), + jsonResponse({ id: 1, email: "a@b.com" }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const result = await client.requestParsed(PingResponse, "/api/user/current", { retries: 1 }); + + expect(result).toEqual({ id: 1, email: "a@b.com" }); + expect(fakeFetch.calls.length).toBe(2); + }); + + it("retries network failures", async () => { + const fakeFetch = captureFetch([ + new TypeError("fetch failed"), + jsonResponse({ id: 2, email: "x@y.com" }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const result = await client.requestParsed(PingResponse, "/api/user/current", { retries: 2 }); + + expect(result).toEqual({ id: 2, email: "x@y.com" }); + expect(fakeFetch.calls.length).toBe(2); + }); + + it("maps ECONNREFUSED to an actionable message and carries the code as the cause", async () => { + const failure = new TypeError("fetch failed"); + failure.cause = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:1"), { + code: "ECONNREFUSED", + }); + const fakeFetch = captureFetch([failure]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(NetworkError); + assert(error instanceof NetworkError, "expected NetworkError"); + expect(error.userMessage).toBe( + "Could not reach Metabase: Connection refused by m.example.com — is Metabase running and is the port correct?", + ); + expect(error.developerDetail).toEqual({ + method: "GET", + url: "https://m.example.com/api/user/current", + cause: "ECONNREFUSED", + }); + }); + + it("maps ENOTFOUND to a host-not-found message", async () => { + const failure = new TypeError("fetch failed"); + failure.cause = Object.assign(new Error("getaddrinfo ENOTFOUND m.example.com"), { + code: "ENOTFOUND", + }); + const fakeFetch = captureFetch([failure]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(NetworkError); + assert(error instanceof NetworkError, "expected NetworkError"); + expect(error.userMessage).toBe( + "Could not reach Metabase: Host not found: m.example.com — check the URL.", + ); + expect(error.developerDetail).toEqual({ + method: "GET", + url: "https://m.example.com/api/user/current", + cause: "ENOTFOUND", + }); + }); + + it("falls back to the raw fetch message when the cause carries no error code", async () => { + const fakeFetch = captureFetch([new TypeError("fetch failed")]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(NetworkError); + assert(error instanceof NetworkError, "expected NetworkError"); + expect(error.userMessage).toBe("Could not reach Metabase: fetch failed"); + expect(error.developerDetail).toEqual({ + method: "GET", + url: "https://m.example.com/api/user/current", + cause: "fetch failed", + }); + }); + + it("throws ResponseShapeError carrying the request context and the schema's zod issues", async () => { + const body = { id: "not-a-number", email: "a@b.com" }; + const expectedIssues = PingResponse.safeParse(body).error?.issues; + assert(expectedIssues !== undefined, "expected zod failure for fixture body"); + const fakeFetch = captureFetch([jsonResponse(body)]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current") + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ResponseShapeError); + assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); + expect(error.userMessage).toBe( + "Metabase returned unexpected response shape:\n" + + " id: Invalid input: expected number, received string", + ); + expect(error.developerDetail).toEqual({ + kind: "zod", + method: "GET", + url: "https://m.example.com/api/user/current", + status: 200, + zodIssues: expectedIssues, + serverTag: null, + }); + }); + + it("threads getServerTag into ResponseShapeError so the lead names the version", async () => { + const body = { id: "not-a-number", email: "a@b.com" }; + const expectedIssues = PingResponse.safeParse(body).error?.issues; + assert(expectedIssues !== undefined, "expected zod failure for fixture body"); + const fakeFetch = captureFetch([jsonResponse(body)]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + getServerTag: async () => "v0.58.7", + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current") + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ResponseShapeError); + assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); + expect(error.developerDetail).toEqual({ + kind: "zod", + method: "GET", + url: "https://m.example.com/api/user/current", + status: 200, + zodIssues: expectedIssues, + serverTag: "v0.58.7", + }); + expect(error.userMessage).toBe( + "On Metabase v0.58.7 the response shape was unexpected:\n" + + " id: Invalid input: expected number, received string", + ); + }); + + it("threads getServerTag into HttpError so route-missing names the version", async () => { + const fakeFetch = captureFetch([ + new Response("API endpoint does not exist.", { + status: 404, + headers: { "content-type": "text/plain" }, + }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + getServerTag: async () => "v0.58.7", + }); + + const error = await client + .requestParsed(PingResponse, "/api/this-does-not-exist", { retries: 0 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.kind).toBe("route-missing"); + expect(error.userMessage).toBe( + "This endpoint is not available on Metabase v0.58.7: GET /api/this-does-not-exist. " + + "It may require a newer Metabase major version.", + ); + }); + + it("hands a rejected create back with the rejected fields named", async () => { + const body = JSON.stringify({ + errors: { name: "value must be a non-blank string." }, + "specific-errors": { name: ["missing required key, received: nil"] }, + }); + const fakeFetch = captureFetch([ + new Response(body, { status: 400, headers: { "content-type": "application/json" } }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/card", { method: "POST", body: { display: "table" } }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.userMessage).toBe("name: missing required key, received: nil"); + expect(error.fieldErrors).toEqual({ name: "value must be a non-blank string." }); + }); + + it("throws HttpError on content-type mismatch with no silent downgrade", async () => { + const fakeFetch = captureFetch([ + new Response("oops", { status: 200, headers: { "content-type": "text/html" } }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current") + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.developerDetail.body).toBeNull(); + expect(error.userMessage).toBe("Expected json response but got text/html"); + }); + + it("throws HttpError when the response has no content-type header", async () => { + const fakeFetch = captureFetch([ + () => { + const response = new Response('{"id":1,"email":"a@b"}', { status: 200 }); + response.headers.delete("content-type"); + return response; + }, + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current") + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.userMessage).toBe("Expected json response but got no content-type"); + }); + + it("times out a hung request and throws TimeoutError", async () => { + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: HANGING_FETCH, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { timeoutMs: 25, retries: 0 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(TimeoutError); + assert(error instanceof TimeoutError, "expected TimeoutError"); + expect(error.developerDetail.timeoutMs).toBe(25); + }); +}); + +describe("createTransport cancellation", () => { + it("installs no process signal handler, so an embedder keeps its own Ctrl-C semantics", async () => { + const before = process.listenerCount("SIGINT"); + const fakeFetch = captureFetch([jsonResponse({ id: 1, email: "a@b.com" })]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + await client.requestParsed(PingResponse, "/api/user/current"); + + expect(process.listenerCount("SIGINT")).toBe(before); + }); + + it("aborts an in-flight request with the client-wide signal's reason", async () => { + const controller = new AbortController(); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: HANGING_FETCH, + signal: controller.signal, + }); + const reason = new AbortError("interrupted"); + + const pending = client + .requestParsed(PingResponse, "/api/user/current") + .catch((caught: unknown) => caught); + controller.abort(reason); + + expect(await pending).toBe(reason); + }); + + it("aborts an in-flight request with the per-request signal's reason", async () => { + const controller = new AbortController(); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: HANGING_FETCH, + }); + const reason = new AbortError("caller cancelled"); + + const pending = client + .requestParsed(PingResponse, "/api/user/current", { signal: controller.signal }) + .catch((caught: unknown) => caught); + controller.abort(reason); + + expect(await pending).toBe(reason); + }); + + it("aborts during the retry backoff instead of sleeping out the schedule", async () => { + const controller = new AbortController(); + const fakeFetch = captureFetch([new Response("oops", { status: 500 })]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + signal: controller.signal, + }); + const reason = new AbortError("interrupted"); + + const pending = client + .requestParsed(PingResponse, "/api/user/current", { retries: 3 }) + .catch((caught: unknown) => caught); + await delay(10); + controller.abort(reason); + + expect(await pending).toBe(reason); + expect(fakeFetch.calls.length).toBe(1); + }); +}); + +describe("createTransport credential encoding", () => { + it("rejects an api key holding a character above Latin-1 at construction, naming the field", () => { + expect(() => + createTransport( + { url: "https://m.example.com", credential: { kind: "apiKey", apiKey: "mb_…key" } }, + { userAgent: TEST_USER_AGENT }, + ), + ).toThrowError( + new ConfigError( + 'Credential field "apiKey" has a character at index 3 (U+2026) that cannot be sent in an HTTP header. ' + + "Header values are limited to Latin-1; a smart quote, ellipsis, or dash substituted during copy-paste is the usual cause.", + ), + ); + }); + + it("rejects an oauth access token holding a character above Latin-1, naming that field instead", () => { + expect(() => + createTransport( + { + url: "https://m.example.com", + credential: { + kind: "oauth", + accessToken: "at’x", + refreshToken: "rt", + expiresAt: "2099-01-01T00:00:00.000Z", + clientId: "cid", + }, + }, + { userAgent: TEST_USER_AGENT }, + ), + ).toThrowError( + new ConfigError( + 'Credential field "accessToken" has a character at index 2 (U+2019) that cannot be sent in an HTTP header. ' + + "Header values are limited to Latin-1; a smart quote, ellipsis, or dash substituted during copy-paste is the usual cause.", + ), + ); + }); + + it("keeps the credential out of the message, so the error does not undo redaction", () => { + const apiKey = "mb_secret_material_…"; + let thrown: unknown = null; + try { + createTransport( + { url: "https://m.example.com", credential: { kind: "apiKey", apiKey } }, + { userAgent: TEST_USER_AGENT }, + ); + } catch (error) { + thrown = error; + } + assert(thrown instanceof ConfigError); + expect(thrown.message).toContain('Credential field "apiKey" has a character at index 19'); + expect(thrown.message).not.toContain("mb_secret_material_"); + }); + + it("accepts a key at the top of the Latin-1 range", () => { + const client = createTransport( + { url: "https://m.example.com", credential: { kind: "apiKey", apiKey: "mb_ÿ" } }, + { userAgent: TEST_USER_AGENT }, + ); + expect(typeof client.requestParsed).toBe("function"); + }); +}); + +describe("createTransport base URL", () => { + it("rejects a scheme-less base URL at construction, inside the error taxonomy", () => { + expect(() => + createTransport( + { url: "metabase.example.com", credential: CONFIG.credential }, + { userAgent: TEST_USER_AGENT }, + ), + ).toThrowError(new ConfigError("URL must start with http:// or https://")); + }); + + it("trims a trailing slash off the base URL rather than doubling the separator", async () => { + const fakeFetch = captureFetch([jsonResponse({ id: 1, email: "a@b.com" })]); + const client = createTransport( + { url: "https://m.example.com/", credential: CONFIG.credential }, + { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch }, + ); + + await client.requestParsed(PingResponse, "/api/user/current"); + + expect(fakeFetch.calls[0]?.url).toBe("https://m.example.com/api/user/current"); + }); +}); + +describe("createTransport user-agent", () => { + it("sends the caller's user-agent, not one of its own", async () => { + const otherCaller = "another-embedder/0.0.1"; + const fakeFetch = captureFetch([jsonResponse({ id: 1, email: "a@b.com" })]); + const client = createTransport(CONFIG, { userAgent: otherCaller, fetchImpl: fakeFetch.fetch }); + + await client.requestParsed(PingResponse, "/api/user/current"); + + expect(fakeFetch.calls[0]?.headers["user-agent"]).toBe(otherCaller); + }); +}); + +describe("createTransport idempotency-aware retry", () => { + it("does not retry POST on 5xx and surfaces the first HttpError", async () => { + const fakeFetch = captureFetch([ + new Response('{"message":"server boom"}', { + status: 500, + headers: { "content-type": "application/json" }, + }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/transform/run", { method: "POST", retries: 3 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.status).toBe(500); + expect(error.userMessage).toBe("server boom"); + expect(fakeFetch.calls.length).toBe(1); + }); + + it("does not retry POST on 429 either — non-idempotent never retries on status", async () => { + const fakeFetch = captureFetch([ + new Response('{"message":"slow down"}', { + status: 429, + headers: { "content-type": "application/json", "retry-after": "0" }, + }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/card", { method: "POST", retries: 3 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.status).toBe(429); + expect(error.userMessage).toBe("slow down"); + expect(fakeFetch.calls.length).toBe(1); + }); + + it("does not retry POST on network failure — a lost connection cannot prove the write never landed", async () => { + const fakeFetch = captureFetch([new TypeError("fetch failed")]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/card", { method: "POST", retries: 2 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(NetworkError); + assert(error instanceof NetworkError, "expected NetworkError"); + expect(error.userMessage).toBe("Could not reach Metabase: fetch failed"); + expect(fakeFetch.calls.length).toBe(1); + }); + + it("spends the whole budget retrying GET on network failure", async () => { + const fakeFetch = captureFetch([ + new TypeError("fetch failed"), + new TypeError("fetch failed"), + new TypeError("fetch failed"), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 2 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(NetworkError); + assert(error instanceof NetworkError, "expected NetworkError"); + expect(error.userMessage).toBe("Could not reach Metabase: fetch failed"); + expect(fakeFetch.calls.length).toBe(3); + }); + + it("retries POST on network failure when the caller opts in via idempotent: true", async () => { + const fakeFetch = captureFetch([ + new TypeError("fetch failed"), + jsonResponse({ id: 9, email: "ok@m.com" }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const result = await client.requestParsed(PingResponse, "/api/card", { + method: "POST", + idempotent: true, + retries: 2, + }); + + expect(result).toEqual({ id: 9, email: "ok@m.com" }); + expect(fakeFetch.calls.length).toBe(2); + }); + + it("retries non-idempotent calls when the caller explicitly opts in via idempotent: true", async () => { + const fakeFetch = captureFetch([ + new Response("oops", { status: 503 }), + jsonResponse({ id: 4, email: "p@u.com" }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const result = await client.requestParsed(PingResponse, "/api/card", { + method: "POST", + idempotent: true, + retries: 2, + }); + + expect(result).toEqual({ id: 4, email: "p@u.com" }); + expect(fakeFetch.calls.length).toBe(2); + }); + + it("does not retry GET on 5xx when the caller forces idempotent: false", async () => { + const fakeFetch = captureFetch([ + new Response('{"message":"down"}', { + status: 503, + headers: { "content-type": "application/json" }, + }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { idempotent: false, retries: 3 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.status).toBe(503); + expect(error.userMessage).toBe("down"); + expect(fakeFetch.calls.length).toBe(1); + }); +}); + +describe("createTransport closed-connection retry", () => { + it("retries a connection closed on a reused socket even when the caller passed retries: 0", async () => { + const fakeFetch = captureFetch([ + transportFailure("ECONNRESET", "read ECONNRESET"), + jsonResponse({ id: 3, email: "fresh@m.com" }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const result = await client.requestParsed(PingResponse, "/api/user/current", { retries: 0 }); + + expect(result).toEqual({ id: 3, email: "fresh@m.com" }); + expect(fakeFetch.calls.length).toBe(2); + }); + + it("retries a Bun-shaped close, whose code sits on the error rather than on its cause", async () => { + const fakeFetch = captureFetch([ + bunTransportFailure("ECONNRESET", "The socket connection was closed unexpectedly."), + jsonResponse({ id: 3, email: "fresh@m.com" }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const result = await client.requestParsed(PingResponse, "/api/user/current", { retries: 0 }); + + expect(result).toEqual({ id: 3, email: "fresh@m.com" }); + expect(fakeFetch.calls.length).toBe(2); + }); + + it("hints on a Bun-shaped close instead of echoing its bare socket message", async () => { + const fakeFetch = captureFetch([ + bunTransportFailure("ECONNRESET", "The socket connection was closed unexpectedly."), + bunTransportFailure("ECONNRESET", "The socket connection was closed unexpectedly."), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) + .catch((caught: unknown) => caught); + + assert(error instanceof NetworkError, "expected NetworkError"); + expect(error.userMessage).toBe( + "Could not reach Metabase: Connection to m.example.com was reset — the server may have closed it, or http/https may be mismatched.", + ); + }); + + it("takes that extra attempt once only and surfaces the second close as a NetworkError", async () => { + const fakeFetch = captureFetch([ + transportFailure("ECONNRESET", "read ECONNRESET"), + transportFailure("ECONNRESET", "read ECONNRESET"), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(NetworkError); + assert(error instanceof NetworkError, "expected NetworkError"); + expect(error.userMessage).toBe( + "Could not reach Metabase: Connection to m.example.com was reset — the server may have closed it, or http/https may be mismatched.", + ); + expect(fakeFetch.calls.length).toBe(2); + }); + + it("denies the extra attempt to a POST, which a closed socket cannot prove never landed", async () => { + const fakeFetch = captureFetch([transportFailure("ECONNRESET", "read ECONNRESET")]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/card", { method: "POST", retries: 0 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(NetworkError); + expect(fakeFetch.calls.length).toBe(1); + }); + + it("classifies undici's UND_ERR_SOCKET as a closed connection and gives it its own hint", async () => { + const fakeFetch = captureFetch([ + transportFailure("UND_ERR_SOCKET", "other side closed"), + transportFailure("UND_ERR_SOCKET", "other side closed"), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(NetworkError); + assert(error instanceof NetworkError, "expected NetworkError"); + expect(error.userMessage).toBe( + "Could not reach Metabase: Connection to m.example.com was closed by the other side — a pooled connection or a proxy may have dropped it.", + ); + expect(fakeFetch.calls.length).toBe(2); + }); + + it("classifies EPIPE as a closed connection and gives it its own hint", async () => { + const fakeFetch = captureFetch([ + transportFailure("EPIPE", "write EPIPE"), + transportFailure("EPIPE", "write EPIPE"), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(NetworkError); + assert(error instanceof NetworkError, "expected NetworkError"); + expect(error.userMessage).toBe( + "Could not reach Metabase: Connection to m.example.com closed while the request was still being written — the server or a proxy dropped it.", + ); + expect(fakeFetch.calls.length).toBe(2); + }); + + it("does not retry a cancelled request, whatever the network error says", async () => { + const controller = new AbortController(); + const reason = new AbortError("interrupted"); + const fakeFetch = captureFetch([ + () => { + controller.abort(reason); + return Promise.reject(transportFailure("ECONNRESET", "read ECONNRESET")); + }, + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + signal: controller.signal, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 3 }) + .catch((caught: unknown) => caught); + + expect(error).toBe(reason); + expect(fakeFetch.calls.length).toBe(1); + }); + + it("gives a timeout no extra attempt, since its rejection carries no connection-closed code", async () => { + let calls = 0; + const countedHang: typeof fetch = (input, init) => { + calls += 1; + return HANGING_FETCH(input, init); + }; + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: countedHang, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { timeoutMs: 25, retries: 0 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(TimeoutError); + expect(calls).toBe(1); + }); + + it("spends the retry budget on a GET that timed out", async () => { + let calls = 0; + const countedHang: typeof fetch = (input, init) => { + calls += 1; + return HANGING_FETCH(input, init); + }; + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: countedHang, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { timeoutMs: 25, retries: 1 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(TimeoutError); + expect(calls).toBe(2); + }); + + it("does not retry a POST that timed out — an elapsed clock cannot prove the write never landed", async () => { + let calls = 0; + const countedHang: typeof fetch = (input, init) => { + calls += 1; + return HANGING_FETCH(input, init); + }; + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: countedHang, + }); + + const error = await client + .requestParsed(PingResponse, "/api/card", { method: "POST", timeoutMs: 25, retries: 2 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(TimeoutError); + assert(error instanceof TimeoutError, "expected TimeoutError"); + expect(error.developerDetail).toEqual({ + kind: "http", + method: "POST", + url: "https://m.example.com/api/card", + timeoutMs: 25, + }); + expect(calls).toBe(1); + }); +}); + +describe("createTransport sanitization", () => { + it("strips the configured apiKey from error bodies", async () => { + const apiKey = "configured_api_key_value"; + const fakeFetch = captureFetch([ + new Response(`{"message":"forbidden","echo":"${apiKey}"}`, { + status: 403, + headers: { "content-type": "application/json" }, + }), + ]); + const client = createTransport( + { url: "https://m.example.com", credential: { kind: "apiKey", apiKey } }, + { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch }, + ); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.developerDetail.body).toBe('{"message":"forbidden","echo":"[REDACTED]"}'); + }); + + it("redacts secret response headers in HttpError detail", async () => { + const fakeFetch = captureFetch([ + new Response('{"message":"forbidden"}', { + status: 403, + headers: { + "content-type": "application/json", + "x-metabase-session": "session-abc", + "set-cookie": "session=foo", + }, + }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.developerDetail.responseHeaders).toEqual({ + "content-type": "application/json", + "x-metabase-session": "[REDACTED]", + "set-cookie": "[REDACTED]", + }); + }); +}); + +describe("createTransport query encoding", () => { + it("encodes array query values as repeated keys and skips undefined entries", async () => { + const fakeFetch = captureFetch([jsonResponse({ id: 1, email: "a@b.com" })]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + await client.requestParsed(PingResponse, "/api/search", { + query: { + models: ["card", "dashboard"], + q: "x", + archived: undefined, + }, + }); + + expect(fakeFetch.calls[0]?.url).toBe( + "https://m.example.com/api/search?models=card&models=dashboard&q=x", + ); + }); +}); + +const OAUTH: OAuthCredential = { + kind: "oauth", + accessToken: "acc-1", + refreshToken: "ref-1", + expiresAt: "2026-06-08T13:00:00.000Z", + clientId: "c1", +}; + +function unauthorizedResponse(): Response { + return new Response('{"error":"unauthorized"}', { + status: 401, + headers: { "content-type": "application/json" }, + }); +} + +describe("createTransport OAuth bearer auth", () => { + it("sends an Authorization Bearer header and no x-api-key", async () => { + const fakeFetch = captureFetch([jsonResponse({ id: 1, email: "a@b.c" })]); + const client = createTransport( + { url: "https://m.example.com", credential: OAUTH }, + { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch }, + ); + await client.requestParsed(PingResponse, "/api/user/current"); + expect(fakeFetch.calls[0]?.headers["authorization"]).toBe("Bearer acc-1"); + expect(fakeFetch.calls[0]?.headers["x-api-key"]).toBeUndefined(); + }); + + it("refreshes on 401 and replays the request with the new access token", async () => { + const fakeFetch = captureFetch([ + unauthorizedResponse(), + jsonResponse({ id: 1, email: "a@b.c" }), + ]); + const refreshed: OAuthCredential = { ...OAUTH, accessToken: "acc-2", refreshToken: "ref-2" }; + let refreshCalls = 0; + const client = createTransport( + { url: "https://m.example.com", credential: OAUTH }, + { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + refreshCredential: async () => { + refreshCalls += 1; + return refreshed; + }, + }, + ); + const result = await client.requestParsed(PingResponse, "/api/user/current", { retries: 0 }); + expect(result).toEqual({ id: 1, email: "a@b.c" }); + expect(refreshCalls).toBe(1); + expect(fakeFetch.calls[0]?.headers["authorization"]).toBe("Bearer acc-1"); + expect(fakeFetch.calls[1]?.headers["authorization"]).toBe("Bearer acc-2"); + }); + + it("gives up after a single refresh when the replay still 401s", async () => { + const fakeFetch = captureFetch([unauthorizedResponse(), unauthorizedResponse()]); + let refreshCalls = 0; + const client = createTransport( + { url: "https://m.example.com", credential: OAUTH }, + { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + refreshCredential: async () => { + refreshCalls += 1; + return { ...OAUTH, accessToken: "acc-2" }; + }, + }, + ); + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.status).toBe(401); + expect(refreshCalls).toBe(1); + expect(fakeFetch.calls).toHaveLength(2); + }); + + it("shares a single refresh across concurrent 401s", async () => { + const fakeFetch = captureFetch([ + unauthorizedResponse(), + unauthorizedResponse(), + jsonResponse({ id: 1, email: "a@b.c" }), + jsonResponse({ id: 1, email: "a@b.c" }), + ]); + let refreshCalls = 0; + const client = createTransport( + { url: "https://m.example.com", credential: OAUTH }, + { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + refreshCredential: async () => { + refreshCalls += 1; + // Yield a macrotask so both 401 handlers (pure microtask chains) join this refresh + // before it settles — the deterministic worst case for a duplicate-refresh bug. + await delay(0); + return { ...OAUTH, accessToken: "acc-2" }; + }, + }, + ); + const results = await Promise.all([ + client.requestParsed(PingResponse, "/api/user/current", { retries: 0 }), + client.requestParsed(PingResponse, "/api/user/current", { retries: 0 }), + ]); + expect(results).toEqual([ + { id: 1, email: "a@b.c" }, + { id: 1, email: "a@b.c" }, + ]); + expect(refreshCalls).toBe(1); + expect(fakeFetch.calls[2]?.headers["authorization"]).toBe("Bearer acc-2"); + expect(fakeFetch.calls[3]?.headers["authorization"]).toBe("Bearer acc-2"); + }); + + it("refreshes again on a later request when the token expires a second time", async () => { + const fakeFetch = captureFetch([ + unauthorizedResponse(), + jsonResponse({ id: 1, email: "a@b.c" }), + unauthorizedResponse(), + jsonResponse({ id: 1, email: "a@b.c" }), + ]); + let refreshCalls = 0; + const client = createTransport( + { url: "https://m.example.com", credential: OAUTH }, + { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + refreshCredential: async () => { + refreshCalls += 1; + return { ...OAUTH, accessToken: `acc-${refreshCalls + 1}` }; + }, + }, + ); + await client.requestParsed(PingResponse, "/api/user/current", { retries: 0 }); + await client.requestParsed(PingResponse, "/api/user/current", { retries: 0 }); + // Each expiry event gets its own refresh — no per-client "refresh only once" latch. + expect(refreshCalls).toBe(2); + }); + + it("does not refresh an API key credential on 401", async () => { + const fakeFetch = captureFetch([unauthorizedResponse()]); + let refreshCalls = 0; + const client = createTransport( + { url: "https://m.example.com", credential: { kind: "apiKey", apiKey: "k" } }, + { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + refreshCredential: async () => { + refreshCalls += 1; + return null; + }, + }, + ); + await client + .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) + .catch(() => undefined); + expect(refreshCalls).toBe(0); + expect(fakeFetch.calls).toHaveLength(1); + }); + + it("redacts both OAuth tokens from error bodies", async () => { + const fakeFetch = captureFetch([ + new Response('{"echo":"acc-1 and ref-1"}', { + status: 403, + headers: { "content-type": "application/json" }, + }), + ]); + const client = createTransport( + { url: "https://m.example.com", credential: OAUTH }, + { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch }, + ); + const error = await client + .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.developerDetail.body).toBe('{"echo":"[REDACTED] and [REDACTED]"}'); + }); +}); + +describe("createTransport subpath base URLs", () => { + it("joins request paths under a base URL that carries a subpath", async () => { + const fakeFetch = captureFetch([jsonResponse({ id: 1, email: "a@b.c" })]); + const client = createTransport( + { url: "https://my.org.com/metabase", credential: { kind: "apiKey", apiKey: "k" } }, + { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch }, + ); + await client.requestParsed(PingResponse, "/api/user/current"); + expect(fakeFetch.calls[0]?.url).toBe("https://my.org.com/metabase/api/user/current"); + }); +}); + +// The shape every resource method takes: the endpoint and its verb belong to the method, and the +// caller's options are forwarded whole. +async function createCard( + client: Transport, + options: RequestOptions, +): Promise> { + return await client.requestParsed(PingResponse, "/api/card", { method: "POST", ...options }); +} + +describe("the caller-facing RequestOptions", () => { + it("cannot express the wire fields a resource method decides for itself", () => { + const hidesMethod: "method" extends keyof RequestOptions ? never : true = true; + const hidesQuery: "query" extends keyof RequestOptions ? never : true = true; + const hidesBody: "body" extends keyof RequestOptions ? never : true = true; + const hidesContentType: "expectContentType" extends keyof RequestOptions ? never : true = true; + expect([hidesMethod, hidesQuery, hidesBody, hidesContentType]).toEqual([ + true, + true, + true, + true, + ]); + }); + + it("carries idempotent through, so a caller can opt a write into a resend", async () => { + const fakeFetch = captureFetch([ + new TypeError("fetch failed"), + jsonResponse({ id: 9, email: "ok@m.com" }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const result = await createCard(client, { idempotent: true, retries: 2 }); + + expect(result).toEqual({ id: 9, email: "ok@m.com" }); + expect(fakeFetch.calls.length).toBe(2); + }); +}); diff --git a/src/core/http/client.ts b/packages/client/src/http/transport.ts similarity index 69% rename from src/core/http/client.ts rename to packages/client/src/http/transport.ts index 2a3ecac..89ee052 100644 --- a/src/core/http/client.ts +++ b/packages/client/src/http/transport.ts @@ -1,17 +1,12 @@ import type { ZodType } from "zod"; -import packageJson from "../../../package.json" with { type: "json" }; -import { - errorMessage, - NetworkError, - ResponseShapeError, - TimeoutError, - ValidationError, -} from "../errors"; -import { JSON_CONTENT_TYPE, parseJson } from "../../runtime/json"; -import { combineAborts, throwIfAborted } from "../../runtime/signal"; +import { errorMessage, NetworkError, TimeoutError } from "../errors"; +import { JSON_CONTENT_TYPE } from "../json"; +import { combineAborts, throwIfAborted } from "../signal"; +import { normalizeUrl } from "../url"; import { + assertCredentialHeaderSafe, type Credential, credentialAuthHeader, credentialSecrets, @@ -19,7 +14,8 @@ import { } from "../auth/credential"; import { HttpError, isRetryableStatus } from "./errors"; -import { buildNetworkError } from "./network-error"; +import { buildNetworkError, isConnectionClosed } from "./network-error"; +import { NO_SERVER_TAG, parseJsonResponse } from "./response-shape"; import { backoffDelay, DEFAULT_MAX_RETRIES, runWithRetries, type RetryOutcome } from "./retry"; import type { RedactionContext } from "./sanitize"; @@ -28,32 +24,39 @@ const UNAUTHORIZED_STATUS = 401; export type HttpMethod = "GET" | "HEAD" | "OPTIONS" | "POST" | "PUT" | "PATCH" | "DELETE"; export type ExpectedContentType = "json" | "text" | "binary"; +export const DEFAULT_METHOD: HttpMethod = "GET"; + const DEFAULT_TIMEOUT_MS = 30_000; const OCTET_STREAM_CONTENT_TYPE = "application/octet-stream"; const TEXT_CONTENT_TYPE_PREFIX = "text/"; const ERROR_BODY_BYTE_CAP = 64 * 1024; -export const USER_AGENT = `metabase-cli/${packageJson.version}`; const IDEMPOTENT_METHODS: ReadonlySet = new Set(["GET", "HEAD", "OPTIONS"]); export type QueryPrimitive = string | number | boolean; export type QueryValue = QueryPrimitive | ReadonlyArray | undefined; +// What a caller may hand to a resource method. The wire shape — method, path, query, body, expected +// content type — is the resource method's own to decide, so none of it is reachable from here. +// `idempotent` is: only the caller knows whether the endpoint behind a write tolerates a resend. export interface RequestOptions { - method?: HttpMethod; - query?: Record; - body?: unknown; signal?: AbortSignal; timeoutMs?: number; retries?: number; idempotent?: boolean; +} + +export interface TransportRequestOptions extends RequestOptions { + method?: HttpMethod; + query?: Record; + body?: unknown; expectContentType?: ExpectedContentType; } -export interface Client { - requestParsed(schema: ZodType, path: string, opts?: RequestOptions): Promise; - requestRaw(path: string, opts?: RequestOptions): Promise; - requestStream(path: string, opts?: RequestOptions): Promise>; +export interface Transport { + requestParsed(schema: ZodType, path: string, opts?: TransportRequestOptions): Promise; + requestRaw(path: string, opts?: TransportRequestOptions): Promise; + requestStream(path: string, opts?: TransportRequestOptions): Promise>; } export interface ClientCredentials { @@ -72,7 +75,7 @@ interface PreparedRequest { retries: number; idempotent: boolean; timeoutMs: number; - callerSignal: AbortSignal | undefined; + cancelSignal: AbortSignal; } interface ExecOutcome { @@ -82,18 +85,24 @@ interface ExecOutcome { export type ServerTagResolver = () => Promise; -export interface ClientOverrides { +// Required so no caller can reach a Metabase anonymously: the wire identity is the caller's to +// declare, not something this layer invents on their behalf. +export interface ClientOptions { + userAgent: string; fetchImpl?: typeof fetch; getServerTag?: ServerTagResolver; refreshCredential?: CredentialRefresher; + // Cancels every request this client makes; composed with the per-request `signal` and the + // timeout. A process-level interrupt is the caller's to own and to hand over here. + signal?: AbortSignal; } -const NO_SERVER_TAG: ServerTagResolver = async () => null; - -export function createClient(config: ClientCredentials, overrides: ClientOverrides = {}): Client { - const fetchImpl = overrides.fetchImpl ?? globalThis.fetch.bind(globalThis); - const getServerTag = overrides.getServerTag ?? NO_SERVER_TAG; - const refreshCredential = overrides.refreshCredential; +export function createTransport(config: ClientCredentials, options: ClientOptions): Transport { + const baseUrl = normalizeUrl(config.url); + assertCredentialHeaderSafe(config.credential); + const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + const getServerTag = options.getServerTag ?? NO_SERVER_TAG; + const refreshCredential = options.refreshCredential; let credential = config.credential; const knownSecrets = new Set(credentialSecrets(credential)); const redactionContext: RedactionContext = { knownSecrets }; @@ -129,7 +138,7 @@ export function createClient(config: ClientCredentials, overrides: ClientOverrid async function attemptOnce(prepared: PreparedRequest, attempt: number): Promise { const hasRetriesLeft = attempt < prepared.retries; const timeoutSignal = AbortSignal.timeout(prepared.timeoutMs); - const { combined, processSignal } = combineAborts(timeoutSignal, prepared.callerSignal); + const combined = combineAborts(timeoutSignal, prepared.cancelSignal); let response: Response; try { @@ -140,8 +149,19 @@ export function createClient(config: ClientCredentials, overrides: ClientOverrid signal: combined, }); } catch (error) { - throwIfAborted(prepared.callerSignal, processSignal); - if (hasRetriesLeft) { + throwIfAborted(prepared.cancelSignal); + // A dropped connection or an elapsed timeout says nothing about whether the request landed: + // the bytes may never have left the client, or the server may already have committed the + // write and lost only the response. Metabase offers no idempotency-key framework to tell a + // replay from a first delivery, so a resend is only safe for a method safe to repeat — the + // same question, and the same answer, as the status gate below. A caller who knows the + // endpoint tolerates a resend opts back in with `idempotent: true`. + // + // A pooled connection reaped by the peer between requests is the one failure that reports the + // socket's age rather than the server's health, so it earns an attempt on a fresh socket even + // when the caller opted out of retries entirely. + const isStaleSocketFirstTry = attempt === 0 && isConnectionClosed(error); + if (prepared.idempotent && (hasRetriesLeft || isStaleSocketFirstTry)) { return { kind: "retry", delayMs: backoffDelay({ attempt }) }; } if (timeoutSignal.aborted) { @@ -155,6 +175,9 @@ export function createClient(config: ClientCredentials, overrides: ClientOverrid throw buildNetworkError(error, prepared.method, prepared.url); } + // Metabase offers no idempotency-key framework, so the server cannot recognise a replayed + // write as the same request: retrying a POST whose response was lost double-creates. A status + // code is only grounds for another attempt when the method is safe to repeat. const canRetryStatus = hasRetriesLeft && prepared.idempotent; if (!response.ok && isRetryableStatus(response.status) && canRetryStatus) { const retryAfter = response.headers.get("Retry-After"); @@ -185,12 +208,25 @@ export function createClient(config: ClientCredentials, overrides: ClientOverrid } async function executeRaw(prepared: PreparedRequest): Promise { - return runWithRetries((attempt) => attemptOnce(prepared, attempt), prepared.callerSignal); + try { + return await runWithRetries( + (attempt) => attemptOnce(prepared, attempt), + prepared.cancelSignal, + ); + } catch (error) { + // A cancellation during the backoff sleep surfaces as the timer's own rejection, which + // carries none of the taxonomy; the signal's reason does. + throwIfAborted(prepared.cancelSignal); + throw error; + } } // On a 401 with an OAuth credential, attempt a single token refresh and replay the request with // the new access token. API-key credentials and non-401 errors propagate unchanged. - async function executeWithAuthRefresh(path: string, opts: RequestOptions): Promise { + async function executeWithAuthRefresh( + path: string, + opts: TransportRequestOptions, + ): Promise { const prepared = prepare(path, opts); try { return { response: await executeRaw(prepared), prepared }; @@ -205,15 +241,15 @@ export function createClient(config: ClientCredentials, overrides: ClientOverrid } } - function prepare(path: string, opts: RequestOptions = {}): PreparedRequest { - const method = opts.method ?? "GET"; + function prepare(path: string, opts: TransportRequestOptions = {}): PreparedRequest { + const method = opts.method ?? DEFAULT_METHOD; const expectContentType = opts.expectContentType ?? "json"; - const url = buildUrl(config.url, path, opts.query); + const url = buildUrl(baseUrl, path, opts.query); const headers = new Headers(); const auth = credentialAuthHeader(credential); headers.set(auth.name, auth.value); headers.set("accept", acceptHeader(expectContentType)); - headers.set("user-agent", USER_AGENT); + headers.set("user-agent", options.userAgent); let body: FetchBody | null = null; if (opts.body !== undefined && opts.body !== null) { if (typeof opts.body === "string" || opts.body instanceof URLSearchParams) { @@ -237,7 +273,7 @@ export function createClient(config: ClientCredentials, overrides: ClientOverrid retries: opts.retries ?? DEFAULT_MAX_RETRIES, idempotent: opts.idempotent ?? IDEMPOTENT_METHODS.has(method), timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, - callerSignal: opts.signal, + cancelSignal: combineAborts(opts.signal, options.signal), }; } @@ -251,20 +287,12 @@ export function createClient(config: ClientCredentials, overrides: ClientOverrid expectContentType: "json", }); const text = await response.text(); - try { - return parseJson(text, schema, { source: prepared.url }); - } catch (error) { - if (error instanceof ValidationError) { - throw new ResponseShapeError({ - method: prepared.method, - url: prepared.url, - status: response.status, - zodIssues: error.developerDetail.zodIssues, - serverTag: await getServerTag(), - }); - } - throw error; - } + return parseJsonResponse(text, schema, { + method: prepared.method, + url: prepared.url, + status: response.status, + getServerTag, + }); }, async requestStream(path, opts) { const { response, prepared } = await executeWithAuthRefresh(path, { diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts new file mode 100644 index 0000000..7a46af2 --- /dev/null +++ b/packages/client/src/index.test.ts @@ -0,0 +1,362 @@ +import { assert, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "./testing/fetch-capture"; +import * as barrel from "./index"; +import { + createClient, + isHttpNotFound, + CardCreateInput, + CardUpdateInput, + CollectionCreateInput, + CollectionUpdateInput, + DashboardCreateInput, + DashboardUpdateInput, + DashcardPatchInput, + DocumentCreateInput, + DocumentUpdateInput, + EidTranslateInput, + FieldUpdateInput, + HttpError, + MeasureCreateInput, + MeasureUpdateInput, + NotificationCreateInput, + NotificationUpdateInput, + PulseCreateInput, + PulseUpdateInput, + ResponseShapeError, + SegmentCreateInput, + SegmentUpdateInput, + SetupInput, + SnippetCreateInput, + SnippetUpdateInput, + TableUpdateInput, + TimelineCreateInput, + TimelineEventCreateInput, + TimelineEventUpdateInput, + TimelineUpdateInput, + TimeoutError, + TipTapNodeInput, + TransformCreateInput, + TransformJobCreateInput, + TransformJobUpdateInput, + TransformTagCreateInput, + TransformTagUpdateInput, + TransformUpdateInput, +} from "./index"; +import type { + ClientCredentials, + CredentialRefresher, + DecodedResponseShapeDetail, + HttpErrorDetail, + ListResult, + MetabaseClient, + RequestOptions, + HttpErrorKind, + HttpTimeoutDetail, + NetworkErrorDetail, + PollingTimeoutDetail, + ResponseShapeErrorDetail, + ServerTagResolver, + TimeoutErrorDetail, + UnknownErrorDetail, + ValidationErrorDetail, + ZodResponseShapeDetail, +} from "./index"; + +// A Zod schema and the type alias merged onto its name are one `export { X }` binding, but only +// naming both proves it: the explicit type argument fails to compile if the alias is missing, the +// call argument fails if the value is, and the two must agree on what the schema infers. +function pin(schema: z.ZodType): z.ZodType { + return schema; +} + +const writePath = { + CardCreateInput: pin(CardCreateInput), + CardUpdateInput: pin(CardUpdateInput), + CollectionCreateInput: pin(CollectionCreateInput), + CollectionUpdateInput: pin(CollectionUpdateInput), + DashboardCreateInput: pin(DashboardCreateInput), + DashboardUpdateInput: pin(DashboardUpdateInput), + DashcardPatchInput: pin(DashcardPatchInput), + DocumentCreateInput: pin(DocumentCreateInput), + DocumentUpdateInput: pin(DocumentUpdateInput), + EidTranslateInput: pin(EidTranslateInput), + FieldUpdateInput: pin(FieldUpdateInput), + MeasureCreateInput: pin(MeasureCreateInput), + MeasureUpdateInput: pin(MeasureUpdateInput), + NotificationCreateInput: pin(NotificationCreateInput), + NotificationUpdateInput: pin(NotificationUpdateInput), + PulseCreateInput: pin(PulseCreateInput), + PulseUpdateInput: pin(PulseUpdateInput), + SegmentCreateInput: pin(SegmentCreateInput), + SegmentUpdateInput: pin(SegmentUpdateInput), + SetupInput: pin(SetupInput), + SnippetCreateInput: pin(SnippetCreateInput), + SnippetUpdateInput: pin(SnippetUpdateInput), + TableUpdateInput: pin(TableUpdateInput), + TimelineCreateInput: pin(TimelineCreateInput), + TimelineEventCreateInput: pin(TimelineEventCreateInput), + TimelineEventUpdateInput: pin(TimelineEventUpdateInput), + TimelineUpdateInput: pin(TimelineUpdateInput), + TipTapNodeInput: pin(TipTapNodeInput), + TransformCreateInput: pin(TransformCreateInput), + TransformJobCreateInput: pin(TransformJobCreateInput), + TransformJobUpdateInput: pin(TransformJobUpdateInput), + TransformTagCreateInput: pin(TransformTagCreateInput), + TransformTagUpdateInput: pin(TransformTagUpdateInput), + TransformUpdateInput: pin(TransformUpdateInput), +}; + +const INPUT_SUFFIX = "Input"; + +// `developerDetail` is `unknown` on the abstract base, so a consumer that narrows to a concrete +// error class has to be able to name the shape it gets back. +type DeveloperDetail = + | HttpErrorDetail + | NetworkErrorDetail + | ResponseShapeErrorDetail + | TimeoutErrorDetail + | UnknownErrorDetail + | ValidationErrorDetail; + +function developerDetailOf(error: HttpError): DeveloperDetail { + return error.developerDetail; +} + +// One step further in than `developerDetailOf`: a `TimeoutError` carries a two-armed detail, and a +// consumer that switches on the discriminant needs a name for the arm it lands on. These would not +// compile without the narrow, and the arm has no name off the barrel without the export. +function httpTimeoutDetailOf(error: TimeoutError): HttpTimeoutDetail | null { + return error.developerDetail.kind === "http" ? error.developerDetail : null; +} + +function pollingTimeoutDetailOf(error: TimeoutError): PollingTimeoutDetail | null { + return error.developerDetail.kind === "polling" ? error.developerDetail : null; +} + +function zodResponseShapeDetailOf(error: ResponseShapeError): ZodResponseShapeDetail | null { + return error.developerDetail.kind === "zod" ? error.developerDetail : null; +} + +function decodedResponseShapeDetailOf( + error: ResponseShapeError, +): DecodedResponseShapeDetail | null { + return error.developerDetail.kind === "decoded" ? error.developerDetail : null; +} + +function describeKind(kind: HttpErrorKind): string { + switch (kind) { + case "route-missing": { + return "the route is not served by this Metabase"; + } + case "resource-missing": { + return "the route exists and the resource does not"; + } + case "auth": { + return "the credential was rejected"; + } + case "rate-limit": { + return "the caller is sending too fast"; + } + case "server-error": { + return "Metabase failed to answer"; + } + case "generic": { + return "an unclassified failure"; + } + } +} + +interface ClientHooks { + refreshCredential: CredentialRefresher; + getServerTag: ServerTagResolver; +} + +function hooksOf(options: barrel.ClientOptions): ClientHooks | null { + const { refreshCredential, getServerTag } = options; + if (refreshCredential === undefined || getServerTag === undefined) { + return null; + } + return { refreshCredential, getServerTag }; +} + +const ROUTE_MISSING_BODY = JSON.stringify({ message: "API endpoint does not exist." }); + +const refreshCredential: CredentialRefresher = () => Promise.resolve(null); +const getServerTag: ServerTagResolver = () => Promise.resolve("v0.58.0"); + +const CONSUMER_CREDENTIALS: ClientCredentials = { + url: "https://m.example.com", + credential: { kind: "apiKey", apiKey: "mb_consumer_key" }, +}; + +const CardRow = z.object({ id: z.number().int() }); +type CardRow = z.infer; + +// A resource method as a consumer would write one, named end to end from the barrel: the client, +// the caller-facing options, the uniform list result, and the guard that reads a missing route as +// nothing to list. +async function listCardRows( + mb: MetabaseClient, + options: RequestOptions, +): Promise> { + try { + const data = await mb.requestParsed(z.array(CardRow), "/api/card", options); + return { data, total: data.length }; + } catch (error) { + if (isHttpNotFound(error)) { + return { data: [], total: null }; + } + throw error; + } +} + +describe("@metabase/client barrel as a consumer surface", () => { + it("names every input schema the barrel exports in a value-and-type position", () => { + const onBarrel = Object.keys(barrel) + .filter((name) => name.endsWith(INPUT_SUFFIX)) + .toSorted(); + expect(Object.keys(writePath).toSorted()).toEqual(onBarrel); + }); + + it("classifies a 404 through a switch that covers HttpErrorKind exhaustively", () => { + const error = new HttpError({ + status: 404, + statusText: "Not Found", + method: "GET", + url: "https://metabase.example.com/api/nope", + responseHeaders: { "content-type": "application/json" }, + rawBody: ROUTE_MISSING_BODY, + }); + expect(describeKind(error.kind)).toBe("the route is not served by this Metabase"); + }); + + it("hands a narrowed error's developerDetail back under a nameable type", () => { + const error = new HttpError({ + status: 401, + statusText: "Unauthorized", + method: "GET", + url: "https://metabase.example.com/api/user/current", + responseHeaders: { "content-type": "application/json" }, + rawBody: null, + }); + expect(developerDetailOf(error)).toEqual({ + status: 401, + statusText: "Unauthorized", + method: "GET", + url: "https://metabase.example.com/api/user/current", + responseHeaders: { "content-type": "application/json" }, + body: null, + fieldErrors: null, + specificFieldErrors: null, + }); + }); + + it("names the arm a caller narrows a request timeout to", () => { + const error = new TimeoutError("Request timed out after 25ms", { + kind: "http", + method: "GET", + url: "https://metabase.example.com/api/user/current", + timeoutMs: 25, + }); + expect(httpTimeoutDetailOf(error)).toEqual({ + kind: "http", + method: "GET", + url: "https://metabase.example.com/api/user/current", + timeoutMs: 25, + }); + }); + + it("names the arm a caller narrows a polling timeout to", () => { + const error = new TimeoutError("Polling timed out after 600000ms", { + kind: "polling", + timeoutMs: 600_000, + attempts: 3, + }); + expect(pollingTimeoutDetailOf(error)).toEqual({ + kind: "polling", + timeoutMs: 600_000, + attempts: 3, + }); + }); + + it("names the arm a caller narrows a failed schema parse to", () => { + const schema = z.object({ id: z.number() }); + const parsed = schema.safeParse({ id: "x" }); + assert(!parsed.success, "expected zod failure"); + const error = ResponseShapeError.fromZodIssues({ + kind: "zod", + method: "GET", + url: "https://metabase.example.com/api/card/1", + status: 200, + zodIssues: parsed.error.issues, + serverTag: null, + }); + expect(zodResponseShapeDetailOf(error)).toEqual({ + kind: "zod", + method: "GET", + url: "https://metabase.example.com/api/card/1", + status: 200, + zodIssues: parsed.error.issues, + serverTag: null, + }); + }); + + it("names the arm a caller narrows a hand-decoded payload to", () => { + const error = new ResponseShapeError("upload succeeded but the response body was empty", { + kind: "decoded", + source: "response body", + value: "", + }); + expect(decodedResponseShapeDetailOf(error)).toEqual({ + kind: "decoded", + source: "response body", + value: "", + }); + }); + + it("reads the other arm's narrow as no match rather than as a detail", () => { + const error = new TimeoutError("Polling timed out after 600000ms", { + kind: "polling", + timeoutMs: 600_000, + attempts: 3, + }); + expect(httpTimeoutDetailOf(error)).toBeNull(); + }); + + it("builds a working client from createClient and returns its rows as a ListResult", async () => { + const fakeFetch = captureFetch([jsonResponse([{ id: 4 }, { id: 9 }])]); + const mb = createClient(CONSUMER_CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + expect(await listCardRows(mb, { timeoutMs: 5_000 })).toEqual({ + data: [{ id: 4 }, { id: 9 }], + total: 2, + }); + }); + + it("reads a 404 from that client through isHttpNotFound rather than as a failure", async () => { + const fakeFetch = captureFetch([ + new Response('{"message":"Not found."}', { + status: 404, + headers: { "content-type": "application/json" }, + }), + ]); + const mb = createClient(CONSUMER_CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + expect(await listCardRows(mb, {})).toEqual({ data: [], total: null }); + }); + + it("lets a caller name the transport hooks it passes to createTransport", () => { + expect(hooksOf({ userAgent: "consumer/1.0", refreshCredential, getServerTag })).toEqual({ + refreshCredential, + getServerTag, + }); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..3ea5b8b --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,275 @@ +export { createClient } from "./client"; +export type { MetabaseClient } from "./client"; +export { createTransport } from "./http/transport"; +export type { + Transport, + ClientCredentials, + ClientOptions, + ExpectedContentType, + HttpMethod, + QueryValue, + RequestOptions, + ServerTagResolver, + TransportRequestOptions, +} from "./http/transport"; +export type { ListResult } from "./list"; + +export { HttpError, isHttpNotFound } from "./http/errors"; +export type { FieldErrors, HttpErrorDetail, HttpErrorKind } from "./http/errors"; +export { + AbortError, + ChainedRequestError, + ConfigError, + errorMessage, + InternalError, + isFileNotFoundError, + MetabaseError, + NetworkError, + ResponseShapeError, + TimeoutError, + toMetabaseError, + UnknownError, + ValidationError, +} from "./errors"; +export type { + DecodedResponseShapeDetail, + ErrorCategory, + HttpTimeoutDetail, + NetworkErrorDetail, + PollingTimeoutDetail, + ResponseShapeErrorDetail, + TimeoutErrorDetail, + UnknownErrorDetail, + ValidationErrorDetail, + ZodResponseShapeDetail, +} from "./errors"; + +export { oauthLogin } from "./auth/oauth-login"; +export type { OAuthLoginDeps, OAuthLoginInput } from "./auth/oauth-login"; +export { refreshOAuthCredential, revokeOAuthCredential } from "./auth/oauth-session"; +export type { + ApiKeyCredential, + Credential, + CredentialRefresher, + OAuthCredential, +} from "./auth/credential"; + +export { probeServer } from "./version/probe"; +export type { ServerInfo } from "./version/probe"; +export { ParsedVersion } from "./version/tag"; +export { + BASELINE_CAPABILITIES, + Capabilities, + checkCapabilities, + mergeCapabilities, +} from "./version/capabilities"; +export type { PreflightFailure } from "./version/capabilities"; +export { CapabilityError } from "./version/preflight-error"; + +export type { Page, PaginateOptions } from "./paginate"; +export { pollUntil } from "./poll"; +export type { Backoff, PollOptions } from "./poll"; +export { parseJson } from "./json"; +export type { ParseJsonOptions } from "./json"; +export { assertEndpointOrigin, displayUrl, normalizeUrl } from "./url"; + +export { + Card, + CardCompact, + CardCreateInput, + CardDatasetQuery, + CardExportFormat, + CardListFilter, + CardQueryResult, + CardQueryResultCompact, + CardUpdateInput, +} from "./domain/card"; +export { + COLLECTION_ITEM_FILTER_MODELS, + COLLECTION_ITEM_MODELS, + COLLECTION_PINNED_STATES, + Collection, + CollectionCompact, + CollectionCreateInput, + CollectionId, + CollectionItem, + CollectionItemCompact, + CollectionItemFilterModel, + CollectionItemModel, + CollectionListFilter, + CollectionNamespace, + CollectionPinnedState, + CollectionTreeNode, + CollectionUpdateInput, +} from "./domain/collection"; +export { CronUiDisplayType } from "./domain/cron"; +export { + Dashboard, + DashboardCompact, + DashboardCreateInput, + DashboardDetail, + DashboardListFilter, + DashboardTab, + DashboardTabCompact, + DashboardUpdateInput, + Dashcard, + DashcardCompact, + DashcardPatchInput, +} from "./domain/dashboard"; +export { + Database, + DatabaseCompact, + DatabaseGetInclude, + DatabaseListInclude, + DatabaseSyncResult, +} from "./domain/database"; +export { + Document, + DocumentCompact, + DocumentCreateInput, + DocumentUpdateInput, + TipTapNode, + TipTapNodeInput, +} from "./domain/document"; +export { + EID_MODELS, + EidModel, + EidTranslateEntry, + EidTranslateInput, + EidTranslateResult, + EidTranslateResultCompact, +} from "./domain/eid-translation"; +export { EmbeddingParams } from "./domain/embedding"; +export { + Field, + FieldBaseType, + FieldCoercionStrategy, + FieldCompact, + FieldSemanticType, + FieldSummary, + FieldUpdateInput, + FieldValues, + FieldValuesCompact, +} from "./domain/field"; +export { + isSyncTaskFailed, + isSyncTaskTerminal, + SyncBranchCreated, + SyncDirtyItem, + SyncDirtyItemCompact, + SyncExportResult, + SyncImportResult, + SyncRemoteChanges, + SyncSettingsUpdateResult, + SyncStashResult, + SyncTask, + SyncTaskCompact, + SyncTaskStatus, + SyncTaskType, +} from "./domain/git-sync"; +export { Library, LibraryChild, LibraryCompact } from "./domain/library"; +export { Measure, MeasureCompact, MeasureCreateInput, MeasureUpdateInput } from "./domain/measure"; +export { + CARD_PAYLOAD_TYPE, + Notification, + NotificationCardPayload, + NotificationCardPayloadCompact, + NotificationCardPayloadPatch, + NotificationChannelType, + NotificationCompact, + NotificationCreateInput, + NotificationHandler, + NotificationHandlerCompact, + NotificationPayloadType, + NotificationRecipient, + NotificationRecipientCompact, + NotificationRecipientDetails, + NotificationRecipientType, + NotificationSendCondition, + NotificationSubscription, + NotificationSubscriptionCompact, + NotificationSubscriptionType, + NotificationUpdateInput, +} from "./domain/notification"; +export { + Parameter, + ParameterMapping, + ParameterTarget, + ParameterType, + ParameterValues, + ParameterValuesCompact, + TemporalUnit, + ValuesQueryType, + ValuesSourceConfig, + ValuesSourceType, +} from "./domain/parameter"; +export { + Pulse, + PulseCard, + PulseCardCompact, + PulseChannel, + PulseChannelCompact, + PulseChannelDetails, + PulseChannelType, + PulseCompact, + PulseCreateInput, + PulseRecipient, + PulseRecipientCompact, + PulseScheduleDay, + PulseScheduleFrame, + PulseScheduleType, + PulseUpdateInput, +} from "./domain/pulse"; +export { SEARCH_MODELS, SearchModel, SearchResult, SearchResultCompact } from "./domain/search"; +export { Segment, SegmentCompact, SegmentCreateInput, SegmentUpdateInput } from "./domain/segment"; +export { SessionProperties, TokenFeatures } from "./domain/session-properties"; +export { Setting, SettingCompact, SettingValue } from "./domain/setting"; +export { SetupInput, SetupResult, SetupResultCompact } from "./domain/setup"; +export { Snippet, SnippetCompact, SnippetCreateInput, SnippetUpdateInput } from "./domain/snippet"; +export { + Table, + TableCompact, + TableGetInclude, + TableQueryMetadata, + TableUpdateInput, +} from "./domain/table"; +export { + Timeline, + TimelineCompact, + TimelineCreateInput, + TimelineEvent, + TimelineEventCompact, + TimelineEventCreateInput, + TimelineEventUpdateInput, + TimelineIcon, + TimelineUpdateInput, +} from "./domain/timeline"; +export { + isTransformRunFailed, + isTransformRunTerminal, + Transform, + TransformCompact, + TransformCreateInput, + TransformRun, + TransformRunCompact, + TransformRunResult, + TransformRunStatus, + TransformTarget, + TransformUpdateInput, +} from "./domain/transform"; +export { + TransformJob, + TransformJobActiveResult, + TransformJobCompact, + TransformJobCreateInput, + TransformJobRunResult, + TransformJobUpdateInput, +} from "./domain/transform-job"; +export { + TransformTag, + TransformTagCompact, + TransformTagCreateInput, + TransformTagUpdateInput, +} from "./domain/transform-tag"; +export { UploadResult, UploadUpdateAction, UploadUpdateResult } from "./domain/upload"; +export { CurrentUser, CurrentUserCompact } from "./domain/user"; diff --git a/src/core/json-pointer.test.ts b/packages/client/src/json-pointer.test.ts similarity index 100% rename from src/core/json-pointer.test.ts rename to packages/client/src/json-pointer.test.ts diff --git a/src/core/json-pointer.ts b/packages/client/src/json-pointer.ts similarity index 100% rename from src/core/json-pointer.ts rename to packages/client/src/json-pointer.ts diff --git a/src/runtime/json.test.ts b/packages/client/src/json.test.ts similarity index 95% rename from src/runtime/json.test.ts rename to packages/client/src/json.test.ts index cb400dd..06f1651 100644 --- a/src/runtime/json.test.ts +++ b/packages/client/src/json.test.ts @@ -2,7 +2,7 @@ import * as fc from "fast-check"; import { assert, describe, expect, it } from "vitest"; import { z } from "zod"; -import { ConfigError, ValidationError } from "../core/errors"; +import { ConfigError, ValidationError } from "./errors"; import { parseJson, parseJsonOrPlain, parseJsonResult } from "./json"; const Person = z.object({ id: z.number(), name: z.string() }); @@ -79,6 +79,14 @@ describe("parseJsonResult", () => { expect(result.error.message).toContain("--body"); }); + it("treats an empty source string as a source, prefixing it rather than falling back to ", () => { + const result = parseJsonResult('{"id":"x","name":"y"}', Person, { source: "" }); + assert(!result.ok, "expected failure"); + assert(result.error instanceof ValidationError, "expected ValidationError"); + expect(result.error.message).toBe(": value did not match expected schema"); + expect(result.error.developerDetail.source).toBe(""); + }); + it("returns ok:true with null when the schema accepts a successful null parse", () => { expect(parseJsonResult("null", z.null())).toEqual({ ok: true, value: null }); }); diff --git a/src/runtime/json.ts b/packages/client/src/json.ts similarity index 93% rename from src/runtime/json.ts rename to packages/client/src/json.ts index 0bf1017..fd1275c 100644 --- a/src/runtime/json.ts +++ b/packages/client/src/json.ts @@ -1,6 +1,6 @@ import type { ZodType } from "zod"; -import { ConfigError, errorMessage, ValidationError } from "../core/errors"; +import { ConfigError, errorMessage, ValidationError } from "./errors"; export const JSON_CONTENT_TYPE = "application/json"; @@ -25,7 +25,7 @@ export function parseJsonResult( schema: ZodType, opts: ParseJsonOptions = {}, ): ParseJsonResult { - const sourcePrefix = opts.source ? `${opts.source}: ` : ""; + const sourcePrefix = opts.source === undefined ? "" : `${opts.source}: `; let raw: unknown; try { raw = JSON.parse(input); diff --git a/packages/client/src/list.ts b/packages/client/src/list.ts new file mode 100644 index 0000000..38382cf --- /dev/null +++ b/packages/client/src/list.ts @@ -0,0 +1,7 @@ +// The whole result of a list endpoint that answers in one response, as against `Page` in +// `./paginate` — the same two fields, but one page of a walk still in progress. A method's return +// type is how a caller tells which of the two it is holding. +export interface ListResult { + data: T[]; + total: number | null; +} diff --git a/packages/client/src/paginate.test.ts b/packages/client/src/paginate.test.ts new file mode 100644 index 0000000..fe4f27b --- /dev/null +++ b/packages/client/src/paginate.test.ts @@ -0,0 +1,682 @@ +import { assert, describe, expect, it } from "vitest"; +import { z, type ZodType } from "zod"; + +import { AbortError, ConfigError, errorMessage, InternalError } from "./errors"; +import { type Transport, type ClientCredentials, createTransport } from "./http/transport"; +import { TEST_USER_AGENT } from "./testing/fetch-capture"; +import { type Page, type PaginateOptions, paginatePages } from "./paginate"; + +const CONFIG: ClientCredentials = { + url: "https://m.example.com", + credential: { kind: "apiKey", apiKey: "mb_test_key" }, +}; + +const Card = z.object({ id: z.number().int(), name: z.string() }); + +async function collect( + client: Transport, + path: string, + itemSchema: ZodType, + opts: PaginateOptions = {}, +): Promise { + const items: T[] = []; + for await (const page of paginatePages(client, path, itemSchema, opts)) { + items.push(...page.items); + } + return items; +} + +interface FetchCallRecord { + url: string; + method: string; +} + +interface FakeFetchHandle { + fetch: typeof fetch; + calls: FetchCallRecord[]; +} + +interface FetchScriptResponse { + body: unknown; +} + +function requestUrl(input: Parameters[0]): string { + if (typeof input === "string") { + return input; + } + return input instanceof URL ? input.toString() : input.url; +} + +function makeFakeFetch(script: FetchScriptResponse[]): FakeFetchHandle { + const queue = [...script]; + const calls: FetchCallRecord[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + calls.push({ url: requestUrl(input), method: init?.method ?? "GET" }); + const next = queue.shift(); + assert(next !== undefined, "fakeFetch: no more responses queued"); + return new Response(JSON.stringify(next.body), { + headers: { "content-type": "application/json" }, + }); + }; + return { fetch: fetchImpl, calls }; +} + +interface CappedSource { + items: Array>; + perRequest: number; +} + +// A server that honours `offset` but never returns more than `perRequest` rows, and reports no +// count — every page below the requested limit, none of them the end until the rows run out. +function makeCappedFetch(source: CappedSource): FakeFetchHandle { + const calls: FetchCallRecord[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + const url = requestUrl(input); + calls.push({ url, method: init?.method ?? "GET" }); + const params = new URL(url).searchParams; + const limitParam = params.get("limit"); + const offsetParam = params.get("offset"); + assert(limitParam !== null && offsetParam !== null, "paginate must send both limit and offset"); + const offset = Number(offsetParam); + const size = Math.min(Number(limitParam), source.perRequest); + return new Response(JSON.stringify({ data: source.items.slice(offset, offset + size) }), { + headers: { "content-type": "application/json" }, + }); + }; + return { fetch: fetchImpl, calls }; +} + +function makeOffsetIgnoringFetch( + page: Array>, + total?: number, +): FakeFetchHandle { + const calls: FetchCallRecord[] = []; + const body = total === undefined ? { data: page } : { data: page, total }; + const fetchImpl: typeof fetch = async (input, init) => { + calls.push({ url: requestUrl(input), method: init?.method ?? "GET" }); + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }); + }; + return { fetch: fetchImpl, calls }; +} + +// A server that honours both `limit` and `offset` exactly. `reportedTotal` is the count it claims, +// which an honest server states as the number of rows it holds. +function buildPagedFetch( + items: Array>, + reportedTotal?: number, +): FakeFetchHandle { + const calls: FetchCallRecord[] = []; + const fetchImpl: typeof fetch = async (input, init) => { + const url = requestUrl(input); + calls.push({ url, method: init?.method ?? "GET" }); + const params = new URL(url).searchParams; + const limitParam = params.get("limit"); + const offsetParam = params.get("offset"); + assert( + limitParam !== null && offsetParam !== null, + "paginate must always send both limit and offset", + ); + const slice = items.slice(Number(offsetParam), Number(offsetParam) + Number(limitParam)); + return new Response(JSON.stringify({ data: slice, total: reportedTotal ?? items.length }), { + headers: { "content-type": "application/json" }, + }); + }; + return { fetch: fetchImpl, calls }; +} + +// Pages like `buildPagedFetch`, but the caller trips `controller` while the nominated request is in +// flight. Rejecting with the signal's own reason is what a real `fetch` does on abort, so the abort +// travels back out through the client instead of being simulated. +function makeAbortingFetch( + items: Array>, + controller: AbortController, + abortOnCall: number, +): FakeFetchHandle { + const paged = buildPagedFetch(items); + const fetchImpl: typeof fetch = async (input, init) => { + const response = await paged.fetch(input, init); + if (paged.calls.length === abortOnCall) { + controller.abort(new Error("user cancelled the listing")); + } + const signal = init?.signal; + if (signal?.aborted === true) { + const reason: unknown = signal.reason; + throw reason; + } + return response; + }; + return { fetch: fetchImpl, calls: paged.calls }; +} + +function urlOf(call: FetchCallRecord): URL { + return new URL(call.url); +} + +function makeItems(count: number): Array> { + return Array.from({ length: count }, (_, index) => ({ id: index, name: `card-${index}` })); +} + +function pageOf(call: FetchCallRecord): { limit: string | null; offset: string | null } { + const params = urlOf(call).searchParams; + return { limit: params.get("limit"), offset: params.get("offset") }; +} + +describe("paginate", () => { + it("yields items from a single page and stops once the server total is reached", async () => { + const handle = makeFakeFetch([ + { + body: { + data: [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ], + total: 2, + }, + }, + ]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const items = await collect(client, "/api/card", Card, { pageSize: 50 }); + + expect(items).toEqual([ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ]); + expect(handle.calls.map(pageOf)).toEqual([{ limit: "50", offset: "0" }]); + }); + + it("walks multiple pages until total is reached", async () => { + const handle = makeFakeFetch([ + { + body: { + data: [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ], + total: 5, + }, + }, + { + body: { + data: [ + { id: 3, name: "c" }, + { id: 4, name: "d" }, + ], + total: 5, + }, + }, + { body: { data: [{ id: 5, name: "e" }], total: 5 } }, + ]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const items = await collect(client, "/api/card", Card, { pageSize: 2 }); + + expect(items).toEqual([ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + { id: 3, name: "c" }, + { id: 4, name: "d" }, + { id: 5, name: "e" }, + ]); + expect(handle.calls.map(pageOf)).toEqual([ + { limit: "2", offset: "0" }, + { limit: "2", offset: "2" }, + { limit: "2", offset: "4" }, + ]); + }); + + it("stops before fetching another page when max cap is reached mid-page", async () => { + const handle = makeFakeFetch([ + { + body: { + data: [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ], + total: 100, + }, + }, + ]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const items = await collect(client, "/api/card", Card, { pageSize: 2, max: 1 }); + + expect(items).toEqual([{ id: 1, name: "a" }]); + expect(handle.calls).toHaveLength(1); + }); + + it("requests at most `max` items in the final page", async () => { + const handle = makeFakeFetch([ + { + body: { + data: [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ], + total: 100, + }, + }, + { body: { data: [{ id: 3, name: "c" }], total: 100 } }, + ]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + await collect(client, "/api/card", Card, { pageSize: 2, max: 3 }); + + expect(handle.calls.map(pageOf)).toEqual([ + { limit: "2", offset: "0" }, + { limit: "1", offset: "2" }, + ]); + }); + + it("forwards extra query params to every page request", async () => { + const handle = makeFakeFetch([ + { body: { data: [{ id: 1, name: "a" }], total: 2 } }, + { body: { data: [{ id: 2, name: "b" }], total: 2 } }, + ]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + await collect(client, "/api/search", Card, { + pageSize: 1, + query: { q: "hello", archived: false }, + }); + + for (const call of handle.calls) { + const params = urlOf(call).searchParams; + expect(params.get("q")).toBe("hello"); + expect(params.get("archived")).toBe("false"); + } + }); + + it("keeps paging past a short page when the endpoint reports no total", async () => { + const all = makeItems(1000); + const handle = makeCappedFetch({ items: all, perRequest: 30 }); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const items = await collect(client, "/api/card", Card, { pageSize: 50 }); + + expect(items).toEqual(all); + expect(handle.calls).toHaveLength(35); + }); + + it("refuses to page an endpoint that ignores offset and reports no total", async () => { + const handle = makeOffsetIgnoringFetch(makeItems(3)); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const error = await collect(client, "/api/card", Card, { pageSize: 3 }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(ConfigError); + expect(errorMessage(error)).toBe( + "the endpoint returned the same first row at offset 3 as at the previous offset; it is ignoring the offset parameter, so paging cannot advance", + ); + expect(handle.calls).toHaveLength(2); + }); + + it("refuses to page an endpoint that ignores offset even when its count bounds the walk", async () => { + const handle = makeOffsetIgnoringFetch(makeItems(3), 6); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const error = await collect(client, "/api/card", Card, { pageSize: 3 }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(ConfigError); + expect(errorMessage(error)).toBe( + "the endpoint returned the same first row at offset 3 as at the previous offset; it is ignoring the offset parameter, so paging cannot advance", + ); + expect(handle.calls).toHaveLength(2); + }); + + it("keeps paging past a count the rows already exceed rather than stranding the tail", async () => { + const all = makeItems(7); + const handle = buildPagedFetch(all, 2); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const items = await collect(client, "/api/card", Card, { pageSize: 3 }); + + expect(items).toEqual(all); + expect(handle.calls.map(pageOf)).toEqual([ + { limit: "3", offset: "0" }, + { limit: "3", offset: "3" }, + { limit: "3", offset: "6" }, + { limit: "3", offset: "7" }, + ]); + }); + + it("yields the whole page when the server ignores limit and returns everything", async () => { + const all = makeItems(10); + const handle = makeFakeFetch([{ body: { data: all, total: 10 } }]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const items = await collect(client, "/api/card", Card, { pageSize: 3 }); + + expect(items).toEqual(all); + expect(handle.calls).toHaveLength(1); + }); + + it("caps yielded items at max when the server returns more than the requested limit", async () => { + const handle = makeFakeFetch([{ body: { data: makeItems(4), total: 100 } }]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const items = await collect(client, "/api/card", Card, { pageSize: 4, max: 3 }); + + expect(items).toEqual(makeItems(3)); + expect(handle.calls.map(pageOf)).toEqual([{ limit: "3", offset: "0" }]); + }); + + it("starts at the requested offset and keeps advancing from there", async () => { + const handle = makeFakeFetch([ + { body: { data: [{ id: 5, name: "e" }], total: 7 } }, + { body: { data: [{ id: 6, name: "f" }], total: 7 } }, + { body: { data: [{ id: 7, name: "g" }], total: 7 } }, + ]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const items = await collect(client, "/api/card", Card, { pageSize: 1, offset: 4 }); + + expect(items).toEqual([ + { id: 5, name: "e" }, + { id: 6, name: "f" }, + { id: 7, name: "g" }, + ]); + expect(handle.calls.map(pageOf)).toEqual([ + { limit: "1", offset: "4" }, + { limit: "1", offset: "5" }, + { limit: "1", offset: "6" }, + ]); + }); + + it("offsets from the requested start and clamps the final limit when max and offset combine", async () => { + const handle = buildPagedFetch(makeItems(10)); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const items = await collect(client, "/api/card", Card, { + pageSize: 3, + offset: 4, + max: 4, + }); + + expect(items).toEqual(makeItems(10).slice(4, 8)); + expect(handle.calls.map(pageOf)).toEqual([ + { limit: "3", offset: "4" }, + { limit: "1", offset: "7" }, + ]); + }); + + it("stops requesting pages when the caller's signal aborts mid-walk", async () => { + const controller = new AbortController(); + const handle = makeAbortingFetch(makeItems(10), controller, 2); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const error = await collect(client, "/api/card", Card, { + pageSize: 2, + signal: controller.signal, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(AbortError); + expect(errorMessage(error)).toBe("user cancelled the listing"); + expect(handle.calls.map(pageOf)).toEqual([ + { limit: "2", offset: "0" }, + { limit: "2", offset: "2" }, + ]); + }); + + it("reports the server total on every page it yields", async () => { + const handle = makeFakeFetch([ + { body: { data: makeItems(4).slice(0, 2), total: 42 } }, + { body: { data: makeItems(4).slice(2), total: 42 } }, + ]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const totals: Array = []; + for await (const page of paginatePages(client, "/api/card", Card, { pageSize: 2, max: 4 })) { + totals.push(page.total); + } + + expect(totals).toEqual([42, 42]); + }); + + it("reports a null total when the endpoint omits one", async () => { + const handle = makeFakeFetch([ + { body: { data: [{ id: 1, name: "a" }] } }, + { body: { data: [] } }, + ]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const pages: Array>> = []; + for await (const page of paginatePages(client, "/api/card", Card, { pageSize: 50 })) { + pages.push(page); + } + + expect(pages).toEqual([ + { items: [{ id: 1, name: "a" }], total: null }, + { items: [], total: null }, + ]); + }); + + it("rejects a pageSize below 1 instead of looping on a page that can never advance", async () => { + const handle = makeFakeFetch([]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const error = await collect(client, "/api/card", Card, { pageSize: 0 }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(InternalError); + expect(errorMessage(error)).toBe("pageSize must be an integer of at least 1, got 0"); + expect(handle.calls).toEqual([]); + }); + + it("rejects a NaN pageSize rather than sending limit=NaN to the endpoint", async () => { + const handle = makeFakeFetch([]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const error = await collect(client, "/api/card", Card, { pageSize: Number.NaN }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(InternalError); + expect(errorMessage(error)).toBe("pageSize must be an integer of at least 1, got NaN"); + expect(handle.calls).toEqual([]); + }); + + it("rejects an infinite pageSize rather than sending limit=Infinity to the endpoint", async () => { + const handle = makeFakeFetch([]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const error = await collect(client, "/api/card", Card, { + pageSize: Number.POSITIVE_INFINITY, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(InternalError); + expect(errorMessage(error)).toBe("pageSize must be an integer of at least 1, got Infinity"); + expect(handle.calls).toEqual([]); + }); + + it("rejects a fractional pageSize rather than sending a non-integer limit", async () => { + const handle = makeFakeFetch([]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const error = await collect(client, "/api/card", Card, { pageSize: 2.5 }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(InternalError); + expect(errorMessage(error)).toBe("pageSize must be an integer of at least 1, got 2.5"); + expect(handle.calls).toEqual([]); + }); + + it("rejects pages whose items fail schema validation", async () => { + const handle = makeFakeFetch([{ body: { data: [{ id: "not-a-number", name: "x" }] } }]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const error = await collect(client, "/api/card", Card).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(Error); + }); + + it("preserves passthrough fields in the envelope without affecting yielded items", async () => { + const handle = makeFakeFetch([ + { body: { data: [{ id: 1, name: "a" }], total: 1, models: ["card"] } }, + ]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const items = await collect(client, "/api/search", Card); + expect(items).toEqual([{ id: 1, name: "a" }]); + }); + + it("accepts total: null on an empty page without falling over (collection-items shape)", async () => { + const handle = makeFakeFetch([{ body: { data: [], total: null, models: ["card"] } }]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const items = await collect(client, "/api/collection/8/items", Card, { pageSize: 50 }); + + expect(items).toEqual([]); + expect(handle.calls).toHaveLength(1); + }); + + it("treats total: null as unknown total and continues paginating until an empty page", async () => { + const handle = makeFakeFetch([ + { body: { data: [{ id: 1, name: "a" }], total: null } }, + { body: { data: [{ id: 2, name: "b" }], total: null } }, + { body: { data: [], total: null } }, + ]); + const client = createTransport(CONFIG, { userAgent: TEST_USER_AGENT, fetchImpl: handle.fetch }); + + const items = await collect(client, "/api/card", Card, { pageSize: 1 }); + + expect(items).toEqual([ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ]); + expect(handle.calls).toHaveLength(3); + }); +}); + +describe("paginate edge-case grid", () => { + interface GridCase { + label: string; + itemsCount: number; + pageSize: number; + max: number | undefined; + expectedCollected: Array>; + expectedRequests: Array<{ limit: string; offset: string }>; + } + + const grid: GridCase[] = [ + { + label: "empty source, no max", + itemsCount: 0, + pageSize: 5, + max: undefined, + expectedCollected: [], + expectedRequests: [{ limit: "5", offset: "0" }], + }, + { + label: "single page exactly fills and stops (total == offset+data)", + itemsCount: 5, + pageSize: 5, + max: undefined, + expectedCollected: makeItems(5), + expectedRequests: [{ limit: "5", offset: "0" }], + }, + { + label: "two full pages then short tail, no max", + itemsCount: 7, + pageSize: 3, + max: undefined, + expectedCollected: makeItems(7), + expectedRequests: [ + { limit: "3", offset: "0" }, + { limit: "3", offset: "3" }, + { limit: "3", offset: "6" }, + ], + }, + { + label: "max=0 returns nothing without any fetch", + itemsCount: 10, + pageSize: 3, + max: 0, + expectedCollected: [], + expectedRequests: [], + }, + { + label: "max=1 with pageSize=1 issues a single request", + itemsCount: 10, + pageSize: 1, + max: 1, + expectedCollected: makeItems(1), + expectedRequests: [{ limit: "1", offset: "0" }], + }, + { + label: "max mid-page caps the final request to the remaining slots", + itemsCount: 10, + pageSize: 4, + max: 6, + expectedCollected: makeItems(6), + expectedRequests: [ + { limit: "4", offset: "0" }, + { limit: "2", offset: "4" }, + ], + }, + { + label: "max larger than items still stops when source is exhausted", + itemsCount: 3, + pageSize: 2, + max: 100, + expectedCollected: makeItems(3), + expectedRequests: [ + { limit: "2", offset: "0" }, + { limit: "2", offset: "2" }, + ], + }, + { + label: "max equal to items.length matches without overshoot", + itemsCount: 5, + pageSize: 5, + max: 5, + expectedCollected: makeItems(5), + expectedRequests: [{ limit: "5", offset: "0" }], + }, + { + label: "pageSize=1 walks one item per call until total reached", + itemsCount: 3, + pageSize: 1, + max: undefined, + expectedCollected: makeItems(3), + expectedRequests: [ + { limit: "1", offset: "0" }, + { limit: "1", offset: "1" }, + { limit: "1", offset: "2" }, + ], + }, + { + label: "pageSize larger than items short-circuits after first response", + itemsCount: 2, + pageSize: 50, + max: undefined, + expectedCollected: makeItems(2), + expectedRequests: [{ limit: "50", offset: "0" }], + }, + ]; + + it.each(grid)( + "$label", + async ({ itemsCount, pageSize, max, expectedCollected, expectedRequests }) => { + const items = makeItems(itemsCount); + const handle = buildPagedFetch(items); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: handle.fetch, + }); + + const opts = max === undefined ? { pageSize } : { pageSize, max }; + const collected = await collect(client, "/api/card", Card, opts); + + expect(collected).toEqual(expectedCollected); + expect(handle.calls.map(pageOf)).toEqual(expectedRequests); + }, + ); +}); diff --git a/packages/client/src/paginate.ts b/packages/client/src/paginate.ts new file mode 100644 index 0000000..926d3bd --- /dev/null +++ b/packages/client/src/paginate.ts @@ -0,0 +1,95 @@ +import { z, type ZodType } from "zod"; + +import { ConfigError, InternalError } from "./errors"; +import type { Transport, QueryValue } from "./http/transport"; + +export const DEFAULT_PAGE_SIZE = 50; + +export interface PaginateOptions { + query?: Record; + pageSize?: number; + offset?: number; + max?: number; + signal?: AbortSignal; +} + +export interface Page { + items: T[]; + total: number | null; +} + +export interface PaginatedEnvelope { + data: T[]; + total?: number | null | undefined; + limit?: number | undefined; + offset?: number | undefined; +} + +export async function* paginatePages( + client: Transport, + path: string, + itemSchema: ZodType, + opts: PaginateOptions = {}, +): AsyncIterable> { + const envelopeSchema = paginatedEnvelopeSchema(itemSchema); + const pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE; + if (!Number.isInteger(pageSize) || pageSize < 1) { + throw new InternalError(`pageSize must be an integer of at least 1, got ${pageSize}`); + } + const start = opts.offset ?? 0; + const max = opts.max ?? Number.POSITIVE_INFINITY; + + let taken = 0; + let previousHead: string | null = null; + while (taken < max) { + const remaining = max - taken; + const limit = Math.min(pageSize, remaining); + const offset = start + taken; + const envelope = await client.requestParsed(envelopeSchema, path, { + query: { ...opts.query, limit, offset }, + ...(opts.signal !== undefined && { signal: opts.signal }), + }); + + const items = + envelope.data.length > remaining ? envelope.data.slice(0, remaining) : envelope.data; + const total = envelope.total ?? null; + const head = items.length === 0 ? null : JSON.stringify(items[0]); + // A server that ignores `offset` hands back the rows it already handed back, so the walk either + // requests forever or serves the same rows twice under a count that bounds it. Neither is a + // listing, so a repeated head row ends it loudly. + const repeatsPreviousPage = head !== null && head === previousHead; + if (repeatsPreviousPage) { + throw new ConfigError( + `the endpoint returned the same first row at offset ${offset} as at the previous offset; it is ignoring the offset parameter, so paging cannot advance`, + ); + } + previousHead = head; + yield { items, total }; + + // Without a server count, only an empty page proves exhaustion: a short page can also come from + // a server-side page cap or a filter applied after the limit, and stopping there drops the tail. + if (items.length === 0) { + return; + } + const consumed = taken + items.length; + // Only a count the rows corroborate ends the walk. A server that has already handed back more + // rows than it counted has disproved its own count, and stopping on it would strand every row + // past the page in hand — so the walk keeps going until a page comes back empty. + const reachedServerTotal = total !== null && start + consumed === total; + if (reachedServerTotal) { + return; + } + taken = consumed; + } +} + +function paginatedEnvelopeSchema(itemSchema: ZodType): ZodType> { + return z + .object({ + data: z.array(itemSchema), + total: z.number().int().nonnegative().nullable().optional(), + limit: z.number().int().nonnegative().optional(), + offset: z.number().int().nonnegative().optional(), + }) + .loose(); +} diff --git a/src/runtime/poll.test.ts b/packages/client/src/poll.test.ts similarity index 89% rename from src/runtime/poll.test.ts rename to packages/client/src/poll.test.ts index ce2dd55..355a79c 100644 --- a/src/runtime/poll.test.ts +++ b/packages/client/src/poll.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; import { assert, describe, expect, it } from "vitest"; -import { TimeoutError } from "../core/errors"; +import { AbortError, TimeoutError } from "./errors"; import { pollUntil } from "./poll"; describe("pollUntil", () => { @@ -32,18 +32,34 @@ describe("pollUntil", () => { expect(error.developerDetail).toEqual({ kind: "polling", timeoutMs: 10, attempts: 1 }); }); - it("throws TimeoutError when the external signal aborts before any call", async () => { + it("rethrows the caller signal's reason when it is already aborted, never a timeout", async () => { const controller = new AbortController(); - controller.abort(); + const reason = new AbortError("interrupted"); + controller.abort(reason); + const error = await pollUntil( async () => "never", () => false, { intervalMs: 1_000, timeoutMs: 60_000, signal: controller.signal }, ).catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(TimeoutError); - assert(error instanceof TimeoutError, "expected TimeoutError"); - expect(error.developerDetail).toEqual({ kind: "polling", timeoutMs: 60_000, attempts: 0 }); + expect(error).toBe(reason); + }); + + it("rethrows the caller signal's reason when it aborts between polls", async () => { + const controller = new AbortController(); + const reason = new AbortError("interrupted"); + + const error = await pollUntil( + async () => { + setTimeout(() => controller.abort(reason), 5); + return "still working"; + }, + () => false, + { intervalMs: 1_000, timeoutMs: 60_000, signal: controller.signal }, + ).catch((caught: unknown) => caught); + + expect(error).toBe(reason); }); it("propagates the loop signal to fn so it aborts when the budget elapses", async () => { diff --git a/src/runtime/poll.ts b/packages/client/src/poll.ts similarity index 88% rename from src/runtime/poll.ts rename to packages/client/src/poll.ts index e1fd5ef..3e9b1e0 100644 --- a/src/runtime/poll.ts +++ b/packages/client/src/poll.ts @@ -1,6 +1,6 @@ import { setTimeout as delay } from "node:timers/promises"; -import { TimeoutError } from "../core/errors"; +import { TimeoutError } from "./errors"; import { combineAborts, throwIfAborted } from "./signal"; @@ -29,13 +29,13 @@ export async function pollUntil( const backoff = opts.backoff ?? "fixed"; const timeoutSignal = AbortSignal.timeout(timeoutMs); - const { combined, processSignal } = combineAborts(timeoutSignal, opts.signal); + const combined = combineAborts(timeoutSignal, opts.signal); let interval = intervalMs; let attempts = 0; while (true) { - throwIfAborted(processSignal); + throwIfAborted(opts.signal); if (combined.aborted) { throw pollTimeout(timeoutMs, attempts); } @@ -44,7 +44,7 @@ export async function pollUntil( try { value = await fn(combined); } catch (error) { - throwIfAborted(processSignal); + throwIfAborted(opts.signal); if (combined.aborted) { throw pollTimeout(timeoutMs, attempts); } @@ -58,7 +58,7 @@ export async function pollUntil( try { await delay(interval, undefined, { signal: combined }); } catch (error) { - throwIfAborted(processSignal); + throwIfAborted(opts.signal); if (combined.aborted) { throw pollTimeout(timeoutMs, attempts); } diff --git a/src/runtime/predicates.ts b/packages/client/src/predicates.ts similarity index 100% rename from src/runtime/predicates.ts rename to packages/client/src/predicates.ts diff --git a/packages/client/src/resources/card.test.ts b/packages/client/src/resources/card.test.ts new file mode 100644 index 0000000..bca10fb --- /dev/null +++ b/packages/client/src/resources/card.test.ts @@ -0,0 +1,220 @@ +import { assert, describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import { NetworkError } from "../errors"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const CARD = { + id: 7, + name: "Orders", + type: "question", + display: "table", + description: null, + archived: false, + query_type: "query", + database_id: 1, + table_id: 2, + collection_id: 3, + entity_id: "aaaaaaaaaaaaaaaaaaaaa", + creator_id: 1, + dataset_query: { type: "query" }, + visualization_settings: {}, +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("card resource wire requests", () => { + it("sends the list request with both filter parameters", async () => { + const { mb, capture } = clientOver([jsonResponse([CARD])]); + + await mb.card.list({ f: "using_model", model_id: "42" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/card?f=using_model&model_id=42", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("omits an unset list parameter from the query string", async () => { + const { mb, capture } = clientOver([jsonResponse([CARD])]); + + await mb.card.list({ f: "all" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/card?f=all", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("wraps the bare list array in a ListResult that claims no server count", async () => { + const { mb } = clientOver([jsonResponse([CARD])]); + + expect(await mb.card.list()).toEqual({ data: [CARD], total: null }); + }); + + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(CARD)]); + + await mb.card.get(7); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/card/7", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the body as JSON", async () => { + const { mb, capture } = clientOver([jsonResponse(CARD)]); + + await mb.card.create({ + name: "Orders", + display: "table", + dataset_query: { type: "query" }, + visualization_settings: {}, + }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/card", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Orders","display":"table","dataset_query":{"type":"query"},"visualization_settings":{}}', + }, + ]); + }); + + it("sends the update request as a PUT carrying only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse(CARD)]); + + await mb.card.update(7, { name: "Renamed" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/card/7", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Renamed"}', + }, + ]); + }); + + it("sends the archive request as the same PUT with archived set", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...CARD, archived: true })]); + + await mb.card.archive(7); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/card/7", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"archived":true}', + }, + ]); + }); + + it("sends the query request with the parameters array", async () => { + const { mb, capture } = clientOver([jsonResponse({ status: "completed" })]); + + await mb.card.query(7, { parameters: [{ type: "category", value: "A" }] }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/card/7/query", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"parameters":[{"type":"category","value":"A"}]}', + }, + ]); + }); + + it("sends the export request form-encoded and accepting any content type", async () => { + const { mb, capture } = clientOver([new Response("id,total\n1,9\n")]); + + await mb.card.exportQuery(7, "csv", { + parameters: [], + format_rows: true, + pivot_results: false, + }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/card/7/query/csv", + method: "POST", + headers: { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", + }, + body: "parameters=%5B%5D&format_rows=true&pivot_results=false", + }, + ]); + }); + + it("hands back the download bytes unparsed, so a caller can pipe them", async () => { + const { mb } = clientOver([new Response("id,total\n1,9\n")]); + + const stream = await mb.card.exportQuery(7, "csv", { + parameters: [], + format_rows: true, + pivot_results: false, + }); + + expect(await new Response(stream).text()).toBe("id,total\n1,9\n"); + }); + + it("refuses an export whose response carries no body at all", async () => { + const { mb } = clientOver([new Response(null, { status: 204 })]); + + const error = await mb.card + .exportQuery(7, "csv", { parameters: [], format_rows: true, pivot_results: false }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(NetworkError); + assert(error instanceof NetworkError, "expected NetworkError"); + expect(error.message).toBe("Response had no body to stream"); + expect(error.developerDetail).toEqual({ + method: "POST", + url: "https://mb.example.com/metabase/api/card/7/query/csv", + cause: "missing body", + }); + }); +}); diff --git a/packages/client/src/resources/card.ts b/packages/client/src/resources/card.ts new file mode 100644 index 0000000..cbc85be --- /dev/null +++ b/packages/client/src/resources/card.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; + +import { + Card, + type CardCreateInput, + type CardExportFormat, + type CardListFilter, + CardQueryResult, + type CardUpdateInput, +} from "../domain/card"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// `GET /api/card` answers a bare array rather than a `{ data, total }` envelope, so the count a +// caller reads off `ListResult` is the array's own length and the server reports none. +const CardApiList = z.array(Card); + +export interface CardListParams { + f?: CardListFilter | undefined; + model_id?: string | undefined; +} + +export interface CardQueryParams { + parameters: unknown[]; +} + +export interface CardExportParams { + parameters: unknown[]; + format_rows: boolean; + pivot_results: boolean; +} + +export function cardResource(transport: Transport) { + /** List cards. `f` picks a server-side preset; `model_id` scopes the presets that need an id. */ + async function list( + params: CardListParams = {}, + options: RequestOptions = {}, + ): Promise> { + const data = await transport.requestParsed(CardApiList, "/api/card", { + ...options, + query: { f: params.f, model_id: params.model_id }, + }); + return { data, total: null }; + } + + /** Get one card by id. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(Card, `/api/card/${id}`, { ...options }); + } + + /** Create a card — a question, a model, or a metric — from a full card body. */ + async function create(params: CardCreateInput, options: RequestOptions = {}): Promise { + return transport.requestParsed(Card, "/api/card", { + ...options, + method: "POST", + body: params, + }); + } + + /** Update a card by id, patching only the fields the body carries. */ + async function update( + id: number, + params: CardUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Card, `/api/card/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** Archive (soft-delete) a card by id. Metabase models this as an update, not its own endpoint. */ + async function archive(id: number, options: RequestOptions = {}): Promise { + return update(id, { archived: true }, options); + } + + /** Run a saved card and return the query result envelope. */ + async function query( + id: number, + params: CardQueryParams, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(CardQueryResult, `/api/card/${id}/query`, { + ...options, + method: "POST", + body: { parameters: params.parameters }, + }); + } + + /** + * Run a saved card and stream its result as a download. Unlike `query`, this endpoint takes a + * form-encoded body and answers bytes, so a caller consumes the stream rather than a value. + */ + async function exportQuery( + id: number, + format: CardExportFormat, + params: CardExportParams, + options: RequestOptions = {}, + ): Promise> { + const body = new URLSearchParams({ + parameters: JSON.stringify(params.parameters), + format_rows: String(params.format_rows), + pivot_results: String(params.pivot_results), + }); + return transport.requestStream(`/api/card/${id}/query/${format}`, { + ...options, + method: "POST", + body, + }); + } + + return { list, get, create, update, archive, query, exportQuery }; +} diff --git a/packages/client/src/resources/collection.test.ts b/packages/client/src/resources/collection.test.ts new file mode 100644 index 0000000..5e019eb --- /dev/null +++ b/packages/client/src/resources/collection.test.ts @@ -0,0 +1,267 @@ +import { assert, describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import { ResponseShapeError } from "../errors"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const COLLECTION = { + id: 4, + name: "Ops", + description: null, + archived: false, + location: "/", + parent_id: null, + entity_id: "voo1If9y8Sld0lXej6xl0", +}; + +const COLLECTION_ITEM = { + id: 12, + model: "card", + name: "Orders", + archived: false, + collection_id: 4, +}; + +// A `type` the server sends and the domain schema does not enumerate. +const UNPINNED_TYPE_COLLECTION = { id: 51, name: "Workspace", type: "workspace" }; + +const TREE_NODE = { + id: 4, + name: "Ops", + location: "/", + children: [], +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("collection resource wire requests", () => { + it("sends the unfiltered list request with no query parameters", async () => { + const { mb, capture } = clientOver([jsonResponse([COLLECTION])]); + + await mb.collection.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the archived filter preset as the archived parameter", async () => { + const { mb, capture } = clientOver([jsonResponse([COLLECTION])]); + + await mb.collection.list({ filter: "archived" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection?archived=true", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the personal filter preset as the personal-only parameter", async () => { + const { mb, capture } = clientOver([jsonResponse([COLLECTION])]); + + await mb.collection.list({ filter: "personal" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection?personal-only=true", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the library-inclusive listing as the include-library parameter", async () => { + const { mb, capture } = clientOver([jsonResponse([COLLECTION])]); + + await mb.collection.listWithLibrary(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection?include-library=true", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the get request for a numeric id", async () => { + const { mb, capture } = clientOver([jsonResponse(COLLECTION)]); + + await mb.collection.get(4); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection/4", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the get request for the root alias", async () => { + const { mb, capture } = clientOver([jsonResponse({ id: "root", name: "Our analytics" })]); + + await mb.collection.get("root"); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection/root", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("percent-encodes a string ref instead of letting it reshape the path", async () => { + const { mb, capture } = clientOver([jsonResponse(COLLECTION)]); + + await mb.collection.get("a/b?c d"); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection/a%2Fb%3Fc%20d", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the body as JSON", async () => { + const { mb, capture } = clientOver([jsonResponse(COLLECTION)]); + + await mb.collection.create({ name: "Ops", parent_id: 2 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Ops","parent_id":2}', + }, + ]); + }); + + it("sends the update request as a PUT carrying only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse(COLLECTION)]); + + await mb.collection.update(4, { name: "Renamed" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection/4", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Renamed"}', + }, + ]); + }); + + it("sends the archive request as the same PUT with archived set", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...COLLECTION, archived: true })]); + + await mb.collection.archive(4); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection/4", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"archived":true}', + }, + ]); + }); + + it("sends the items request with every filter plus the paging window", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ data: [COLLECTION_ITEM, { ...COLLECTION_ITEM, id: 13 }], total: 12 }), + ]); + + const pages = mb.collection.itemPages( + "root", + { models: ["card", "dashboard"], archived: true, pinned_state: "is_pinned" }, + { offset: 10, max: 2, pageSize: 2 }, + ); + await pages[Symbol.asyncIterator]().next(); + + expect(capture.calls).toEqual([ + { + url: + "https://mb.example.com/metabase/api/collection/root/items" + + "?models=card&models=dashboard&archived=true&pinned_state=is_pinned&limit=2&offset=10", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the tree request", async () => { + const { mb, capture } = clientOver([jsonResponse([TREE_NODE])]); + + await mb.collection.tree(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection/tree", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); +}); + +describe("collection resource results", () => { + it("parses the library-inclusive listing through the full collection schema", async () => { + const { mb } = clientOver([jsonResponse([COLLECTION, UNPINNED_TYPE_COLLECTION])]); + + const error = await mb.collection.listWithLibrary().catch((caught: unknown) => caught); + + assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); + expect(error.userMessage).toBe( + "Metabase returned unexpected response shape:\n" + + ' [1].type: Invalid option: expected one of "instance-analytics"|"trash"|"library"|' + + '"library-data"|"library-metrics"|"tenant-specific-root-collection"', + ); + }); +}); diff --git a/packages/client/src/resources/collection.ts b/packages/client/src/resources/collection.ts new file mode 100644 index 0000000..2dafa78 --- /dev/null +++ b/packages/client/src/resources/collection.ts @@ -0,0 +1,160 @@ +import { z } from "zod"; + +import { + Collection, + type CollectionCreateInput, + type CollectionId, + CollectionItem, + type CollectionItemFilterModel, + type CollectionListFilter, + type CollectionPinnedState, + CollectionTreeNode, + type CollectionUpdateInput, +} from "../domain/collection"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; +import { type Page, type PaginateOptions, paginatePages } from "../paginate"; + +// `GET /api/collection` and `GET /api/collection/tree` both answer a bare array rather than a +// `{ data, total }` envelope, so the count a caller reads off `ListResult` is the array's own +// length and the server reports none. +const CollectionApiList = z.array(Collection); +const CollectionTreeApiList = z.array(CollectionTreeNode); + +// Each preset is a distinct server-side query rather than a flag the caller composes: `all` sends +// nothing, and the other two send the one parameter Metabase reads for that view. +const COLLECTION_LIST_QUERY = { + all: {}, + archived: { archived: true }, + personal: { "personal-only": true }, +} as const; + +const DEFAULT_LIST_FILTER = "all"; + +export interface CollectionListParams { + filter?: CollectionListFilter | undefined; +} + +export interface CollectionItemListParams { + models?: CollectionItemFilterModel[] | undefined; + archived?: boolean | undefined; + pinned_state?: CollectionPinnedState | undefined; +} + +// The walk's own settings, minus the query the method builds from `CollectionItemListParams`. +export type CollectionItemPageOptions = Omit; + +// A collection is reachable by numeric id, by 21-character entity id, and by the aliases `root` +// and `trash`, so every ref reaches the path through `encodeURIComponent`. +function refPath(ref: CollectionId): string { + return encodeURIComponent(ref); +} + +/** + * List collections including the Library and its children, parsing each through the caller's own + * projection. `Collection` pins `type`, `namespace` and `authority_level` to closed enums, so a + * consumer reading a few fields off every collection on the instance narrows here: one collection + * carrying a server value outside those sets then costs nothing to a caller that never reads the + * field. + */ +export async function listCollectionsWithLibrary( + transport: Transport, + schema: z.ZodType, + options: RequestOptions = {}, +): Promise { + return transport.requestParsed(z.array(schema), "/api/collection", { + ...options, + query: { "include-library": true }, + }); +} + +export function collectionResource(transport: Transport) { + /** List collections. `filter` picks a server-side preset: everything, archived, or personal. */ + async function list( + params: CollectionListParams = {}, + options: RequestOptions = {}, + ): Promise> { + const data = await transport.requestParsed(CollectionApiList, "/api/collection", { + ...options, + query: COLLECTION_LIST_QUERY[params.filter ?? DEFAULT_LIST_FILTER], + }); + return { data, total: null }; + } + + /** + * List collections including the Library and its children, which the plain listing omits. + * The Library's own children carry neither `type` nor `is_remote_synced`, so this is where a + * caller resolves both. + */ + async function listWithLibrary(options: RequestOptions = {}): Promise> { + const data = await listCollectionsWithLibrary(transport, Collection, options); + return { data, total: null }; + } + + /** Get one collection by id, entity id, or the `root`/`trash` alias. */ + async function get(ref: CollectionId, options: RequestOptions = {}): Promise { + return transport.requestParsed(Collection, `/api/collection/${refPath(ref)}`, { ...options }); + } + + /** Create a new collection. */ + async function create( + params: CollectionCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Collection, "/api/collection", { + ...options, + method: "POST", + body: params, + }); + } + + /** Update a collection, patching only the fields the body carries. */ + async function update( + ref: CollectionId, + params: CollectionUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Collection, `/api/collection/${refPath(ref)}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** Archive (soft-delete) a collection. Metabase models this as an update, not its own endpoint. */ + async function archive(ref: CollectionId, options: RequestOptions = {}): Promise { + return update(ref, { archived: true }, options); + } + + /** + * Walk the items inside a collection one page at a time. This endpoint pages on the server, so + * the caller consumes pages rather than a single list and decides how far to pull. + */ + function itemPages( + ref: CollectionId, + params: CollectionItemListParams = {}, + options: CollectionItemPageOptions = {}, + ): AsyncIterable> { + return paginatePages(transport, `/api/collection/${refPath(ref)}/items`, CollectionItem, { + query: { + models: params.models, + archived: params.archived, + pinned_state: params.pinned_state, + }, + ...(options.offset !== undefined && { offset: options.offset }), + ...(options.max !== undefined && { max: options.max }), + ...(options.pageSize !== undefined && { pageSize: options.pageSize }), + ...(options.signal !== undefined && { signal: options.signal }), + }); + } + + /** Fetch the collection hierarchy as a forest of nested nodes. */ + async function tree(options: RequestOptions = {}): Promise> { + const data = await transport.requestParsed(CollectionTreeApiList, "/api/collection/tree", { + ...options, + }); + return { data, total: null }; + } + + return { list, listWithLibrary, get, create, update, archive, itemPages, tree }; +} diff --git a/packages/client/src/resources/csv-upload.ts b/packages/client/src/resources/csv-upload.ts new file mode 100644 index 0000000..3620497 --- /dev/null +++ b/packages/client/src/resources/csv-upload.ts @@ -0,0 +1,14 @@ +const CSV_CONTENT_TYPE = "text/csv"; + +// The bytes and the name Metabase records for them. Where they came from — a path, a stream, a +// buffer built in memory — is the caller's, and the client never reads a filesystem to find out. +export interface CsvFile { + filename: string; + bytes: Uint8Array; +} + +export function buildCsvFormData(file: CsvFile): FormData { + const form = new FormData(); + form.append("file", new Blob([file.bytes], { type: CSV_CONTENT_TYPE }), file.filename); + return form; +} diff --git a/packages/client/src/resources/dashboard.test.ts b/packages/client/src/resources/dashboard.test.ts new file mode 100644 index 0000000..9bd7879 --- /dev/null +++ b/packages/client/src/resources/dashboard.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import { NetworkError } from "../errors"; +import { HttpError } from "../http/errors"; +import type { ClientCredentials } from "../http/transport"; +import { createFakeClient } from "../testing/fake-client"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +import { dashboardResource } from "./dashboard"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +function card(id: number, archived = false) { + return { + id, + name: `card-${id}`, + type: "question", + display: "table", + description: null, + archived, + query_type: "query", + database_id: 1, + table_id: null, + collection_id: null, + entity_id: null, + creator_id: 1, + dataset_query: { type: "query" }, + visualization_settings: {}, + }; +} + +function cardRequest(id: number) { + return { + url: `https://mb.example.com/metabase/api/card/${id}`, + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }; +} + +const DASHCARD = { + id: 11, + dashboard_id: 5, + card_id: 7, + dashboard_tab_id: null, + row: 0, + col: 0, + size_x: 12, + size_y: 6, + entity_id: "ccccccccccccccccccccc", + visualization_settings: {}, + parameter_mappings: [], + inline_parameters: [], +}; + +const DASHBOARD = { + id: 5, + name: "Orders Overview", + description: null, + archived: false, + collection_id: 3, + creator_id: 1, + entity_id: "aaaaaaaaaaaaaaaaaaaaa", + width: "fixed", + auto_apply_filters: true, + enable_embedding: false, + public_uuid: null, + cache_ttl: null, + parameters: [], +}; + +const DASHBOARD_DETAIL = { + ...DASHBOARD, + dashcards: [DASHCARD], + tabs: [], +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("dashboard resource wire requests", () => { + it("sends the list request with the filter preset", async () => { + const { mb, capture } = clientOver([jsonResponse([DASHBOARD])]); + + await mb.dashboard.list({ f: "archived" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/dashboard?f=archived", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("omits an unset filter preset from the query string", async () => { + const { mb, capture } = clientOver([jsonResponse([DASHBOARD])]); + + await mb.dashboard.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/dashboard", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(DASHBOARD_DETAIL)]); + + await mb.dashboard.get(5); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/dashboard/5", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the body as JSON", async () => { + const { mb, capture } = clientOver([jsonResponse(DASHBOARD)]); + + await mb.dashboard.create({ name: "Orders Overview", collection_id: 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/dashboard", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Orders Overview","collection_id":3}', + }, + ]); + }); + + it("sends the update request as a PUT carrying only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse(DASHBOARD_DETAIL)]); + + await mb.dashboard.update(5, { name: "Renamed" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/dashboard/5", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Renamed"}', + }, + ]); + }); + + it("sends the archive request as the same PUT with archived set", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...DASHBOARD, archived: true })]); + + await mb.dashboard.archive(5); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/dashboard/5", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"archived":true}', + }, + ]); + }); + + it("reads the dashboard, then replaces every dashcard with the patched one merged in", async () => { + const { mb, capture } = clientOver([ + jsonResponse(DASHBOARD_DETAIL), + jsonResponse({ ...DASHBOARD_DETAIL, dashcards: [{ ...DASHCARD, row: 4, col: 2 }] }), + ]); + + await mb.dashboard.updateDashcard(5, 11, { row: 4, col: 2 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/dashboard/5", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + { + url: "https://mb.example.com/metabase/api/dashboard/5", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"dashcards":[{"dashboard_id":5,"card_id":7,"dashboard_tab_id":null,"visualization_settings":{},"parameter_mappings":[],"inline_parameters":[],"id":11,"size_x":12,"size_y":6,"row":4,"col":2}]}', + }, + ]); + }); + + it("sends the parameter values request with the parameter id escaped into the path", async () => { + const { mb, capture } = clientOver([jsonResponse({ values: [], has_more_values: false })]); + + await mb.dashboard.parameterValues(5, "order status"); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/dashboard/5/params/order%20status/values", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the parameter value search with both the parameter id and the query escaped", async () => { + const { mb, capture } = clientOver([jsonResponse({ values: [], has_more_values: false })]); + + await mb.dashboard.searchParameterValues(5, "order status", "Cam/2"); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/dashboard/5/params/order%20status/search/Cam%2F2", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); +}); + +describe("dashboard card-reference check", () => { + it("sends no request when the body carries no dashcards", async () => { + const { mb, capture } = clientOver([]); + + await mb.dashboard.checkCardReferences(undefined); + + expect(capture.calls).toEqual([]); + }); + + it("sends no request for null, zero, negative and absent card ids", async () => { + const { mb, capture } = clientOver([]); + + await mb.dashboard.checkCardReferences([ + { id: -1, card_id: null }, + { id: -2, card_id: -5 }, + { id: -3, card_id: 0 }, + { id: -4 }, + ]); + + expect(capture.calls).toEqual([]); + }); + + it("reads each distinct card once, in the order the dashcards first reference it", async () => { + const { mb, capture } = clientOver([jsonResponse(card(42)), jsonResponse(card(17))]); + + await mb.dashboard.checkCardReferences([ + { id: -1, card_id: 42 }, + { id: -2, card_id: 17 }, + { id: -3, card_id: 42 }, + ]); + + expect(capture.calls).toEqual([cardRequest(42), cardRequest(17)]); + }); + + it("reports no issue when every referenced card exists and is live", async () => { + const { mb } = clientOver([jsonResponse(card(42))]); + + const issues = await mb.dashboard.checkCardReferences([{ id: -1, card_id: 42 }]); + + expect(issues).toEqual([]); + }); + + it("reports one issue per reference when several dashcards share an archived card", async () => { + const { mb } = clientOver([jsonResponse(card(134, true))]); + + const issues = await mb.dashboard.checkCardReferences([ + { id: -1, card_id: 134 }, + { id: -2, card_id: 134 }, + ]); + + expect(issues).toEqual([ + { cardId: 134, path: "/dashcards/0/card_id", problem: { reason: "archived" } }, + { cardId: 134, path: "/dashcards/1/card_id", problem: { reason: "archived" } }, + ]); + }); + + it("keeps each reference's own index in its path across entries it cannot read", async () => { + const { mb } = clientOver([jsonResponse(card(99, true)), jsonResponse(card(7, true))]); + + const issues = await mb.dashboard.checkCardReferences([ + { id: -1, card_id: 99 }, + "not an object", + 42, + null, + { id: -2, card_id: "not a number" }, + { id: -3, card_id: 7 }, + ]); + + expect(issues).toEqual([ + { cardId: 99, path: "/dashcards/0/card_id", problem: { reason: "archived" } }, + { cardId: 7, path: "/dashcards/5/card_id", problem: { reason: "archived" } }, + ]); + }); + + it("reports a card the server has no row for as missing", async () => { + const { mb } = clientOver([jsonResponse({ message: "Not found." }, 404)]); + + const issues = await mb.dashboard.checkCardReferences([{ id: -1, card_id: 9999 }]); + + expect(issues).toEqual([ + { cardId: 9999, path: "/dashcards/0/card_id", problem: { reason: "missing" } }, + ]); + }); + + it("reports a card the caller may not read as unreadable, carrying the server's message", async () => { + const { mb } = clientOver([ + jsonResponse({ message: "You do not have permissions to do that." }, 403), + ]); + + const issues = await mb.dashboard.checkCardReferences([{ id: -1, card_id: 55 }]); + + expect(issues).toEqual([ + { + cardId: 55, + path: "/dashcards/0/card_id", + problem: { + reason: "unreadable", + detail: "You do not have permissions to do that.", + }, + }, + ]); + }); + + it("propagates a 404 that says the card route itself is absent", async () => { + const { mb } = clientOver([jsonResponse({ message: "API endpoint does not exist." }, 404)]); + + const failure = mb.dashboard.checkCardReferences([{ id: -1, card_id: 42 }]); + + await expect(failure).rejects.toBeInstanceOf(HttpError); + await expect(failure).rejects.toThrow( + "This endpoint is not available on the connected Metabase: GET /metabase/api/card/42.", + ); + }); + + it("propagates a non-HTTP failure untouched", async () => { + const network = new NetworkError("Could not reach Metabase: connect ECONNREFUSED", { + method: "GET", + url: "https://mb.example.com/metabase/api/card/1", + cause: "connect ECONNREFUSED", + }); + const { client } = createFakeClient({ + routes: [{ path: "/api/card/1", reply: { kind: "error", error: network } }], + }); + + const failure = dashboardResource(client).checkCardReferences([{ id: -1, card_id: 1 }]); + + await expect(failure).rejects.toBe(network); + }); +}); diff --git a/packages/client/src/resources/dashboard.ts b/packages/client/src/resources/dashboard.ts new file mode 100644 index 0000000..5782c6c --- /dev/null +++ b/packages/client/src/resources/dashboard.ts @@ -0,0 +1,302 @@ +import { z } from "zod"; + +import { + Dashboard, + type DashboardCreateInput, + DashboardDetail, + type DashboardListFilter, + type DashboardUpdateInput, + Dashcard, + type DashcardPatchInput, +} from "../domain/dashboard"; +import { ParameterValues } from "../domain/parameter"; +import { ConfigError } from "../errors"; +import { HttpError } from "../http/errors"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +import { cardResource } from "./card"; + +// `GET /api/dashboard` answers a bare array rather than a `{ data, total }` envelope, so the count a +// caller reads off `ListResult` is the array's own length and the server reports none. +const DashboardApiList = z.array(Dashboard); + +// A dashcard as the reference check reads it: the id it points at, whatever else the caller is about +// to send. The write shapes carry `card_id` under a loose catchall, so it arrives untyped. +const PreflightDashcard = z.looseObject({ + card_id: z.number().int().nullable().optional(), +}); + +export interface DashboardListParams { + f?: DashboardListFilter | undefined; +} + +export interface DashcardCardMissing { + reason: "missing"; +} + +export interface DashcardCardArchived { + reason: "archived"; +} + +export interface DashcardCardUnreadable { + reason: "unreadable"; + detail: string; +} + +export type DashcardCardProblem = + | DashcardCardMissing + | DashcardCardArchived + | DashcardCardUnreadable; + +export interface DashcardCardIssue { + cardId: number; + path: string; + problem: DashcardCardProblem; +} + +interface CardReference { + cardId: number; + path: string; +} + +interface ReferenceCheck { + references: CardReference[]; + problem: DashcardCardProblem | null; +} + +type DashcardWriteInput = NonNullable[number]; + +export function dashboardResource(transport: Transport) { + const card = cardResource(transport); + + /** List dashboards. `f` picks a server-side preset. */ + async function list( + params: DashboardListParams = {}, + options: RequestOptions = {}, + ): Promise> { + const data = await transport.requestParsed(DashboardApiList, "/api/dashboard", { + ...options, + query: { f: params.f }, + }); + return { data, total: null }; + } + + /** Get one dashboard by id, with its dashcards, tabs and parameters. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(DashboardDetail, `/api/dashboard/${id}`, { ...options }); + } + + /** + * Create a dashboard. The endpoint accepts no dashcards or tabs — a follow-up `update` is what + * puts content on a fresh dashboard. + */ + async function create( + params: DashboardCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Dashboard, "/api/dashboard", { + ...options, + method: "POST", + body: params, + }); + } + + /** + * Update a dashboard by id, patching only the fields the body carries. `dashcards` and `tabs` are + * full replacements: whatever the body omits is deleted from the dashboard. + */ + async function update( + id: number, + params: DashboardUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(DashboardDetail, `/api/dashboard/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** + * Archive (soft-delete) a dashboard by id. Metabase models this as an update, not its own + * endpoint. The answer is read as `Dashboard` rather than the `DashboardDetail` `update` returns: + * an archive is a state change on the dashboard itself, and its dashcards are not part of it. + */ + async function archive(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(Dashboard, `/api/dashboard/${id}`, { + ...options, + method: "PUT", + body: { archived: true }, + }); + } + + /** + * Patch one dashcard on a dashboard. Metabase exposes no dashcard endpoint and its dashboard PUT + * is a full replace of `dashcards`, so the patch is read-modify-written over the dashboard as it + * currently stands. + */ + async function updateDashcard( + dashboardId: number, + dashcardId: number, + params: DashcardPatchInput, + options: RequestOptions = {}, + ): Promise { + const dashboard = await get(dashboardId, options); + const target = dashboard.dashcards.find((dashcard) => dashcard.id === dashcardId); + if (target === undefined) { + throw new ConfigError(`dashcard ${dashcardId} not found on dashboard ${dashboardId}`); + } + const patched = Dashcard.parse({ ...target, ...params }); + const dashcards = dashboard.dashcards.map((dashcard) => + stripEntityId(dashcard.id === dashcardId ? patched : dashcard), + ); + const result = await update(dashboardId, { dashcards }, options); + const refreshed = result.dashcards.find((dashcard) => dashcard.id === dashcardId); + if (refreshed === undefined) { + throw new Error( + `PUT /api/dashboard/${dashboardId}: dashcard ${dashcardId} missing from response`, + ); + } + return refreshed; + } + + /** + * Check every card a `dashcards` body references before writing it, through `GET /api/card/{id}` + * once per distinct id. Reports one issue per reference whose card is missing, archived, or + * unreadable, so an empty result means the body is safe to send; an id the server answers for in + * any other way propagates as the failure it was. + */ + async function checkCardReferences( + dashcards: ReadonlyArray | null | undefined, + options: RequestOptions = {}, + ): Promise { + const grouped = groupByCardId(collectCardReferences(dashcards)); + const checks = await Promise.all( + Array.from(grouped.entries()).map( + async ([cardId, references]): Promise => ({ + references, + problem: await inspectCard(cardId, options), + }), + ), + ); + return checks.flatMap((check) => { + const problem = check.problem; + if (problem === null) { + return []; + } + return check.references.map((reference) => ({ ...reference, problem })); + }); + } + + async function inspectCard( + cardId: number, + options: RequestOptions, + ): Promise { + try { + const found = await card.get(cardId, options); + return found.archived ? { reason: "archived" } : null; + } catch (error) { + if (!(error instanceof HttpError)) { + throw error; + } + // `resource-missing` rather than the bare 404: a `route-missing` 404 says this Metabase does + // not serve card reads at all, and reporting that as "the card is gone" would be a lie. + if (error.kind === "resource-missing") { + return { reason: "missing" }; + } + if (error.kind === "auth") { + return { reason: "unreadable", detail: error.userMessage }; + } + throw error; + } + } + + /** + * Fetch the selectable values for a dashboard parameter, through the chain-filter value endpoint. + */ + async function parameterValues( + dashboardId: number, + parameterId: string, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed( + ParameterValues, + `/api/dashboard/${dashboardId}/params/${encodeURIComponent(parameterId)}/values`, + { ...options }, + ); + } + + /** + * Search a dashboard parameter's selectable values: the server returns only values containing the + * substring, capped at the first 1000 matches. + */ + async function searchParameterValues( + dashboardId: number, + parameterId: string, + query: string, + options: RequestOptions = {}, + ): Promise { + const parameter = encodeURIComponent(parameterId); + return transport.requestParsed( + ParameterValues, + `/api/dashboard/${dashboardId}/params/${parameter}/search/${encodeURIComponent(query)}`, + { ...options }, + ); + } + + return { + list, + get, + create, + update, + archive, + updateDashcard, + checkCardReferences, + parameterValues, + searchParameterValues, + }; +} + +// An entry the loose shape rejects is left to the server: the write is about to send it, and the +// error it comes back with says more than anything a pre-flight could invent about it. +function collectCardReferences( + dashcards: ReadonlyArray | null | undefined, +): CardReference[] { + if (dashcards === undefined || dashcards === null) { + return []; + } + const references: CardReference[] = []; + dashcards.forEach((dashcard, index) => { + const parsed = PreflightDashcard.safeParse(dashcard); + if (!parsed.success) { + return; + } + const cardId = parsed.data.card_id; + if (typeof cardId === "number" && cardId > 0) { + references.push({ cardId, path: `/dashcards/${index}/card_id` }); + } + }); + return references; +} + +function groupByCardId(references: ReadonlyArray): Map { + const grouped = new Map(); + for (const reference of references) { + const existing = grouped.get(reference.cardId); + if (existing === undefined) { + grouped.set(reference.cardId, [reference]); + } else { + existing.push(reference); + } + } + return grouped; +} + +// The five layout fields are named again on the way out because `Dashcard`'s loose catchall makes +// the rest-spread collapse every declared property into the index signature, and the write shape +// requires them. +function stripEntityId(dashcard: Dashcard): DashcardWriteInput { + const { entity_id: _entity_id, id, size_x, size_y, row, col, ...rest } = dashcard; + return { ...rest, id, size_x, size_y, row, col }; +} diff --git a/packages/client/src/resources/database.test.ts b/packages/client/src/resources/database.test.ts new file mode 100644 index 0000000..a387f34 --- /dev/null +++ b/packages/client/src/resources/database.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const DATABASE = { + id: 1, + name: "Warehouse", + engine: "postgres", + is_saved_questions: false, + initial_sync_status: "complete", +}; + +const TABLE = { + id: 11, + name: "orders", + display_name: "Orders", + description: null, + db_id: 1, + schema: "public", + entity_type: "entity/TransactionTable", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const IMMEDIATE_POLL = { intervalMs: 1, timeoutMs: 1_000 }; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("database resource wire requests", () => { + it("sends the list request with both query parameters", async () => { + const { mb, capture } = clientOver([jsonResponse({ data: [DATABASE], total: 1 })]); + + await mb.database.list({ include: "tables", saved: true }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/database?include=tables&saved=true", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("omits unset list parameters from the query string", async () => { + const { mb, capture } = clientOver([jsonResponse({ data: [DATABASE], total: 1 })]); + + await mb.database.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/database", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("surfaces the server's own count as the list total", async () => { + const { mb } = clientOver([jsonResponse({ data: [DATABASE], total: 37 })]); + + expect(await mb.database.list()).toEqual({ data: [DATABASE], total: 37 }); + }); + + it("sends the get request with the include parameter", async () => { + const { mb, capture } = clientOver([jsonResponse(DATABASE)]); + + await mb.database.get(1, { include: "tables.fields" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/database/1?include=tables.fields", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the schemas request", async () => { + const { mb, capture } = clientOver([jsonResponse(["public"])]); + + await mb.database.schemas(1); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/database/1/schemas", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reports no total for the schemas listing, which the server does not count", async () => { + const { mb } = clientOver([jsonResponse(["public", "reporting"])]); + + expect(await mb.database.schemas(1)).toEqual({ + data: ["public", "reporting"], + total: null, + }); + }); + + it("percent-encodes the schema name in the schema-tables path", async () => { + const { mb, capture } = clientOver([jsonResponse([TABLE])]); + + await mb.database.schemaTables(1, "sales/eu west"); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/database/1/schema/sales%2Feu%20west", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the sync-schema request as a POST without polling when no wait is given", async () => { + const { mb, capture } = clientOver([jsonResponse({ status: "ok" })]); + + await mb.database.syncSchema(1); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/database/1/sync_schema", + method: "POST", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("polls the database after the sync-schema POST when a wait schedule is given", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ status: "ok" }), + jsonResponse({ ...DATABASE, initial_sync_status: "incomplete" }), + jsonResponse(DATABASE), + ]); + + await mb.database.syncSchema(1, { wait: IMMEDIATE_POLL }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/database/1/sync_schema", + method: "POST", + headers: JSON_READ_HEADERS, + body: null, + }, + { + url: "https://mb.example.com/metabase/api/database/1", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + { + url: "https://mb.example.com/metabase/api/database/1", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reports the settled sync status once the wait completes", async () => { + const { mb } = clientOver([jsonResponse({ status: "ok" }), jsonResponse(DATABASE)]); + + expect(await mb.database.syncSchema(1, { wait: IMMEDIATE_POLL })).toEqual({ + id: 1, + status: "ok", + initial_sync_status: "complete", + }); + }); + + it("sends the rescan-values request as a POST", async () => { + const { mb, capture } = clientOver([jsonResponse({ status: "ok" })]); + + await mb.database.rescanValues(1); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/database/1/rescan_values", + method: "POST", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("answers a rescan-values acknowledgement with the id the caller asked about", async () => { + const { mb } = clientOver([jsonResponse({ status: "ok" })]); + + expect(await mb.database.rescanValues(9)).toEqual({ id: 9, status: "ok" }); + }); +}); diff --git a/packages/client/src/resources/database.ts b/packages/client/src/resources/database.ts new file mode 100644 index 0000000..43ec437 --- /dev/null +++ b/packages/client/src/resources/database.ts @@ -0,0 +1,138 @@ +import { z } from "zod"; + +import { + Database, + type DatabaseGetInclude, + type DatabaseListInclude, + type DatabaseSyncResult, +} from "../domain/database"; +import { Table } from "../domain/table"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; +import { type PollOptions, pollUntil } from "../poll"; + +// `GET /api/database` is the one Metabase list endpoint that wraps its rows in a `{ data, total }` +// envelope and reports a real server count, so `ListResult.total` carries the server's number rather +// than the length of the rows in hand. +const DatabaseApiList = z + .object({ + data: z.array(Database), + total: z.number().int().nonnegative(), + }) + .loose(); + +// The sync and rescan endpoints queue background work and answer a bare acknowledgement, so the id a +// caller reads back off the result is the one it asked about. +const DatabaseTaskAck = z.object({ status: z.literal("ok") }); + +const DatabaseApiSchemaList = z.array(z.string()); + +const DatabaseApiSchemaTableList = z.array(Table); + +const SYNC_COMPLETE = "complete"; + +export interface DatabaseListParams { + include?: DatabaseListInclude | undefined; + saved?: boolean | undefined; +} + +export interface DatabaseGetParams { + include?: DatabaseGetInclude | undefined; +} + +// Presence of `wait` is the choice to block: the schedule is the caller's, the terminal condition is +// the server's. +export interface DatabaseSyncSchemaParams { + wait?: PollOptions | undefined; +} + +export function databaseResource(transport: Transport) { + /** List databases. `include` hydrates each database's tables; `saved` adds the Saved Questions virtual database. */ + async function list( + params: DatabaseListParams = {}, + options: RequestOptions = {}, + ): Promise> { + const response = await transport.requestParsed(DatabaseApiList, "/api/database", { + ...options, + query: { include: params.include, saved: params.saved }, + }); + return { data: response.data, total: response.total }; + } + + /** Get one database by id. `include` hydrates its tables, and `tables.fields` their fields too. */ + async function get( + id: number, + params: DatabaseGetParams = {}, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Database, `/api/database/${id}`, { + ...options, + query: { include: params.include }, + }); + } + + /** List the schema names in a database. */ + async function schemas(id: number, options: RequestOptions = {}): Promise> { + const data = await transport.requestParsed( + DatabaseApiSchemaList, + `/api/database/${id}/schemas`, + { ...options }, + ); + return { data, total: null }; + } + + /** List the tables one schema of a database holds. */ + async function schemaTables( + id: number, + schema: string, + options: RequestOptions = {}, + ): Promise> { + const data = await transport.requestParsed( + DatabaseApiSchemaTableList, + `/api/database/${id}/schema/${encodeURIComponent(schema)}`, + { ...options }, + ); + return { data, total: null }; + } + + /** + * Trigger a manual schema sync for a database. The endpoint queues the sync and returns at once; + * pass `wait` to poll the database until it reports its initial sync complete. + */ + async function syncSchema( + id: number, + params: DatabaseSyncSchemaParams = {}, + options: RequestOptions = {}, + ): Promise { + const ack = await transport.requestParsed(DatabaseTaskAck, `/api/database/${id}/sync_schema`, { + ...options, + method: "POST", + }); + const wait = params.wait; + if (wait === undefined) { + return { id, status: ack.status }; + } + const database = await pollUntil( + async (signal) => + transport.requestParsed(Database, `/api/database/${id}`, { ...options, signal }), + (candidate) => candidate.initial_sync_status === SYNC_COMPLETE, + wait, + ); + return { id, status: ack.status, initial_sync_status: database.initial_sync_status ?? null }; + } + + /** Trigger a rescan of the cached field values across a database. */ + async function rescanValues( + id: number, + options: RequestOptions = {}, + ): Promise { + const ack = await transport.requestParsed( + DatabaseTaskAck, + `/api/database/${id}/rescan_values`, + { ...options, method: "POST" }, + ); + return { id, status: ack.status }; + } + + return { list, get, schemas, schemaTables, syncSchema, rescanValues }; +} diff --git a/packages/client/src/resources/dataset.test.ts b/packages/client/src/resources/dataset.test.ts new file mode 100644 index 0000000..5ffba9f --- /dev/null +++ b/packages/client/src/resources/dataset.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("dataset resource wire requests", () => { + it("posts the ad-hoc query body verbatim", async () => { + const { mb, capture } = clientOver([jsonResponse({ status: "completed" })]); + + await mb.dataset.query({ + "lib/type": "mbql/query", + database: 1, + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 2 }], + }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/dataset", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"lib/type":"mbql/query","database":1,"stages":[{"lib/type":"mbql.stage/mbql","source-table":2}]}', + }, + ]); + }); +}); diff --git a/packages/client/src/resources/dataset.ts b/packages/client/src/resources/dataset.ts new file mode 100644 index 0000000..c6a7a4a --- /dev/null +++ b/packages/client/src/resources/dataset.ts @@ -0,0 +1,18 @@ +import { CardQueryResult } from "../domain/card"; +import type { RequestOptions, Transport } from "../http/transport"; + +export function datasetResource(transport: Transport) { + /** + * Run an ad-hoc query and return its result envelope. The body is a whole query — MBQL 5, legacy + * MBQL, or native — rather than a reference to a saved one, so nothing here is a card. + */ + async function query(body: unknown, options: RequestOptions = {}): Promise { + return transport.requestParsed(CardQueryResult, "/api/dataset", { + ...options, + method: "POST", + body, + }); + } + + return { query }; +} diff --git a/packages/client/src/resources/document.test.ts b/packages/client/src/resources/document.test.ts new file mode 100644 index 0000000..20a6769 --- /dev/null +++ b/packages/client/src/resources/document.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const DOCUMENT = { + id: 4, + name: "Runbook", + document: { type: "doc" }, + entity_id: "bbbbbbbbbbbbbbbbbbbbb", + collection_id: 3, + creator_id: 1, + archived: false, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +const DOCUMENT_BODY = { + type: "doc", + content: [{ type: "paragraph", attrs: { _id: "6f1a" } }], +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("document resource wire requests", () => { + it("sends the list request", async () => { + const { mb, capture } = clientOver([jsonResponse({ items: [DOCUMENT] })]); + + await mb.document.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/document", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("unwraps the { items } list envelope into a ListResult with no server count", async () => { + const { mb } = clientOver([jsonResponse({ items: [DOCUMENT] })]); + + expect(await mb.document.list()).toEqual({ data: [DOCUMENT], total: null }); + }); + + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(DOCUMENT)]); + + await mb.document.get(4); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/document/4", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the body as JSON", async () => { + const { mb, capture } = clientOver([jsonResponse(DOCUMENT)]); + + await mb.document.create({ name: "Runbook", document: DOCUMENT_BODY, collection_id: 3 }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/document", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Runbook","document":{"type":"doc","content":[{"type":"paragraph","attrs":{"_id":"6f1a"}}]},"collection_id":3}', + }, + ]); + }); + + it("sends the update request as a PUT carrying only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...DOCUMENT, name: "Renamed" })]); + + await mb.document.update(4, { name: "Renamed" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/document/4", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Renamed"}', + }, + ]); + }); + + it("sends the archive request as the same PUT with archived set", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...DOCUMENT, archived: true })]); + + await mb.document.archive(4); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/document/4", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"archived":true}', + }, + ]); + }); +}); diff --git a/packages/client/src/resources/document.ts b/packages/client/src/resources/document.ts new file mode 100644 index 0000000..7932750 --- /dev/null +++ b/packages/client/src/resources/document.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +import { Document, type DocumentCreateInput, type DocumentUpdateInput } from "../domain/document"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// `GET /api/document` wraps its rows in `{ items }` and reports no count, so the total a caller +// reads off `ListResult` is the array's own length. +const DocumentApiList = z.object({ items: z.array(Document) }).loose(); + +export function documentResource(transport: Transport) { + /** Get existing documents. */ + async function list(options: RequestOptions = {}): Promise> { + const response = await transport.requestParsed(DocumentApiList, "/api/document", { + ...options, + }); + return { data: response.items, total: null }; + } + + /** Return an existing document by id. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(Document, `/api/document/${id}`, { ...options }); + } + + /** Create a new document. */ + async function create( + params: DocumentCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Document, "/api/document", { + ...options, + method: "POST", + body: params, + }); + } + + /** Update an existing document by id. */ + async function update( + id: number, + params: DocumentUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Document, `/api/document/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** Archive (soft-delete) a document by id. Metabase models this as an update, not its own endpoint. */ + async function archive(id: number, options: RequestOptions = {}): Promise { + return update(id, { archived: true }, options); + } + + return { list, get, create, update, archive }; +} diff --git a/packages/client/src/resources/eid-translation.test.ts b/packages/client/src/resources/eid-translation.test.ts new file mode 100644 index 0000000..839f2ff --- /dev/null +++ b/packages/client/src/resources/eid-translation.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const TRANSLATION = { + entity_ids: { + "Ss3mHTaWs8T-VLPYEeraG": { status: "ok", type: "card", id: 7 }, + }, +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("eid-translation resource wire requests", () => { + it("posts the entity ids grouped by model", async () => { + const { mb, capture } = clientOver([jsonResponse(TRANSLATION)]); + + await mb.eidTranslation.translate({ entity_ids: { card: ["Ss3mHTaWs8T-VLPYEeraG"] } }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/eid-translation/translate", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"entity_ids":{"card":["Ss3mHTaWs8T-VLPYEeraG"]}}', + }, + ]); + }); + + // Request and response are keyed differently — models in, entity ids out — so the caller needs + // the answer as the server keyed it, not the grouping it asked under. + it("returns the lookup keyed by entity id", async () => { + const { mb } = clientOver([jsonResponse(TRANSLATION)]); + + const result = await mb.eidTranslation.translate({ + entity_ids: { card: ["Ss3mHTaWs8T-VLPYEeraG"] }, + }); + + expect(result).toEqual({ + entity_ids: { "Ss3mHTaWs8T-VLPYEeraG": { status: "ok", type: "card", id: 7 } }, + }); + }); +}); diff --git a/packages/client/src/resources/eid-translation.ts b/packages/client/src/resources/eid-translation.ts new file mode 100644 index 0000000..441079a --- /dev/null +++ b/packages/client/src/resources/eid-translation.ts @@ -0,0 +1,22 @@ +import { type EidTranslateInput, EidTranslateResult } from "../domain/eid-translation"; +import type { RequestOptions, Transport } from "../http/transport"; + +export function eidTranslationResource(transport: Transport) { + /** + * Translate entity ids to the numeric ids of the rows they name. The request groups the entity + * ids by model; the response is keyed by entity id, each entry carrying its model and — when the + * lookup succeeded — the numeric id. + */ + async function translate( + params: EidTranslateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(EidTranslateResult, "/api/eid-translation/translate", { + ...options, + method: "POST", + body: params, + }); + } + + return { translate }; +} diff --git a/packages/client/src/resources/field.test.ts b/packages/client/src/resources/field.test.ts new file mode 100644 index 0000000..c08aed6 --- /dev/null +++ b/packages/client/src/resources/field.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const FIELD = { + id: 101, + table_id: 11, + name: "total", + display_name: "Total", + description: null, + base_type: "type/Float", + semantic_type: null, + fk_target_field_id: null, +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("field resource wire requests", () => { + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(FIELD)]); + + await mb.field.get(101); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/field/101", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the update request as a PUT carrying only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...FIELD, semantic_type: "type/Price" })]); + + await mb.field.update(101, { semantic_type: "type/Price" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/field/101", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"semantic_type":"type/Price"}', + }, + ]); + }); + + it("sends the summary request", async () => { + const { mb, capture } = clientOver([ + jsonResponse([ + ["count", 200], + ["distincts", 17], + ]), + ]); + + await mb.field.summary(101); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/field/101/summary", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("decodes the summary tuples into counts keyed by the field asked about", async () => { + const { mb } = clientOver([ + jsonResponse([ + ["count", 200], + ["distincts", 17], + ]), + ]); + + expect(await mb.field.summary(101)).toEqual({ field_id: 101, count: 200, distincts: 17 }); + }); + + it("sends the values request", async () => { + const { mb, capture } = clientOver([jsonResponse({ values: [["a"], ["b"]], field_id: 101 })]); + + await mb.field.values(101); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/field/101/values", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); +}); diff --git a/packages/client/src/resources/field.ts b/packages/client/src/resources/field.ts new file mode 100644 index 0000000..69a442e --- /dev/null +++ b/packages/client/src/resources/field.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +import { Field, type FieldSummary, type FieldUpdateInput, FieldValues } from "../domain/field"; +import type { RequestOptions, Transport } from "../http/transport"; + +// `GET /api/field/{id}/summary` answers a pair of `[name, count]` tuples rather than an object, so +// the counts are decoded into `FieldSummary` before they reach a caller. +const FieldApiSummary = z.tuple([ + z.tuple([z.literal("count"), z.number().int()]), + z.tuple([z.literal("distincts"), z.number().int()]), +]); + +export function fieldResource(transport: Transport) { + /** Get one field by id. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(Field, `/api/field/${id}`, { ...options }); + } + + /** Update a field by id, patching only the fields the body carries. */ + async function update( + id: number, + params: FieldUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Field, `/api/field/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** Get the row count and the distinct-value count for a field. */ + async function summary(id: number, options: RequestOptions = {}): Promise { + const [[, count], [, distincts]] = await transport.requestParsed( + FieldApiSummary, + `/api/field/${id}/summary`, + { ...options }, + ); + return { field_id: id, count, distincts }; + } + + /** Get the cached distinct values Metabase holds for a field. */ + async function values(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(FieldValues, `/api/field/${id}/values`, { ...options }); + } + + return { get, update, summary, values }; +} diff --git a/packages/client/src/resources/git-sync.test.ts b/packages/client/src/resources/git-sync.test.ts new file mode 100644 index 0000000..9447b20 --- /dev/null +++ b/packages/client/src/resources/git-sync.test.ts @@ -0,0 +1,565 @@ +import { assert, describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import { HttpError } from "../http/errors"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const RUNNING_TASK = { + id: 12, + sync_task_type: "import", + status: "running", + progress: 0.5, + started_at: "2026-05-21T00:00:00Z", +}; + +const SETTLED_TASK = { ...RUNNING_TASK, status: "successful", progress: 1 }; + +const DIRTY_ITEM = { + id: 4, + name: "Orders", + model: "card", + sync_status: "modified", + collection_id: 9, +}; + +// Values the server sends and the collection domain schema does not enumerate. +const UNPINNED_ENUM_FIELDS = { + type: "workspace", + namespace: "workspaces", + authority_level: "critical", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_WRITE_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const BINARY_READ_HEADERS = { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const IMMEDIATE_POLL = { intervalMs: 1, timeoutMs: 1_000 }; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +function noContent(): Response { + return new Response(null, { status: 204 }); +} + +async function thrownBy(run: () => Promise): Promise { + try { + await run(); + } catch (error: unknown) { + return error; + } + throw new Error("expected the call to reject"); +} + +describe("git-sync resource wire requests", () => { + it("sends the current-task request as an optional GET", async () => { + const { mb, capture } = clientOver([jsonResponse(RUNNING_TASK)]); + + await mb.gitSync.currentTask(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/current-task", + method: "GET", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reads the current-task 204 as no task at all", async () => { + const { mb } = clientOver([noContent()]); + + expect(await mb.gitSync.currentTask()).toBeNull(); + }); + + it("sends the cancel request as a POST without a body", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...RUNNING_TASK, cancelled: true })]); + + await mb.gitSync.cancelTask(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/current-task/cancel", + method: "POST", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the is-dirty request", async () => { + const { mb, capture } = clientOver([jsonResponse({ is_dirty: true })]); + + await mb.gitSync.isDirty(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/is-dirty", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("unwraps the is-dirty envelope to the flag itself", async () => { + const { mb } = clientOver([jsonResponse({ is_dirty: true })]); + + expect(await mb.gitSync.isDirty()).toBe(true); + }); + + it("sends the dirty listing request", async () => { + const { mb, capture } = clientOver([jsonResponse({ dirty: [DIRTY_ITEM] })]); + + await mb.gitSync.dirty(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/dirty", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reports no total for the dirty listing, which the server does not count", async () => { + const { mb } = clientOver([jsonResponse({ dirty: [DIRTY_ITEM] })]); + + expect(await mb.gitSync.dirty()).toEqual({ data: [DIRTY_ITEM], total: null }); + }); + + it("sends the force-refresh flag as a has-remote-changes query parameter", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ + has_changes: false, + remote_version: null, + local_version: null, + cached: true, + }), + ]); + + await mb.gitSync.hasRemoteChanges({ "force-refresh": true }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/has-remote-changes?force-refresh=true", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("omits the has-remote-changes query when no force-refresh is asked for", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ + has_changes: true, + remote_version: "abc123", + local_version: "def456", + cached: false, + }), + ]); + + await mb.gitSync.hasRemoteChanges(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/has-remote-changes", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the import request with the branch and force fields it was given", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), + ]); + + await mb.gitSync.import({ branch: "main", force: true }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/import", + method: "POST", + headers: JSON_WRITE_HEADERS, + body: '{"branch":"main","force":true}', + }, + ]); + }); + + it("returns the started import without a final task when no wait is given", async () => { + const { mb } = clientOver([ + jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), + ]); + + expect(await mb.gitSync.import()).toEqual({ message: "Import queued", task_id: 12 }); + }); + + it("polls the current task after the import POST when a wait schedule is given", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), + jsonResponse(RUNNING_TASK), + jsonResponse(SETTLED_TASK), + ]); + + await mb.gitSync.import({ wait: IMMEDIATE_POLL }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/import", + method: "POST", + headers: JSON_WRITE_HEADERS, + body: "{}", + }, + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/current-task", + method: "GET", + headers: BINARY_READ_HEADERS, + body: null, + }, + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/current-task", + method: "GET", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reports the settled task alongside the import that started it", async () => { + const { mb } = clientOver([ + jsonResponse({ status: "success", task_id: 12, message: "Import queued" }), + jsonResponse(SETTLED_TASK), + ]); + + expect(await mb.gitSync.import({ wait: IMMEDIATE_POLL })).toEqual({ + message: "Import queued", + task_id: 12, + final: SETTLED_TASK, + }); + }); + + it("does not poll for an import the server answered with no task to wait on", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ status: "success", task_id: null, message: "Already up to date" }), + ]); + + await mb.gitSync.import({ wait: IMMEDIATE_POLL }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/import", + method: "POST", + headers: JSON_WRITE_HEADERS, + body: "{}", + }, + ]); + }); + + it("sends the export request with the branch, message and force fields it was given", async () => { + const { mb, capture } = clientOver([jsonResponse({ message: "Export queued", task_id: 8 })]); + + await mb.gitSync.export({ branch: "main", message: "update dashboards", force: true }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/export", + method: "POST", + headers: JSON_WRITE_HEADERS, + body: '{"branch":"main","message":"update dashboards","force":true}', + }, + ]); + }); + + it("reports the settled task alongside the export that started it", async () => { + const { mb } = clientOver([ + jsonResponse({ message: "Export queued", task_id: 8 }), + jsonResponse(SETTLED_TASK), + ]); + + expect(await mb.gitSync.export({ wait: IMMEDIATE_POLL })).toEqual({ + message: "Export queued", + task_id: 8, + final: SETTLED_TASK, + }); + }); + + it("sends the stash request with the new branch and commit message", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ status: "success", message: "Stash queued", task_id: 5 }), + ]); + + await mb.gitSync.stash({ new_branch: "wip", message: "work in progress" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/stash", + method: "POST", + headers: JSON_WRITE_HEADERS, + body: '{"new_branch":"wip","message":"work in progress"}', + }, + ]); + }); + + it("reports the settled task alongside the stash that started it", async () => { + const { mb } = clientOver([ + jsonResponse({ status: "success", message: "Stash queued", task_id: 5 }), + jsonResponse(SETTLED_TASK), + ]); + + expect( + await mb.gitSync.stash({ new_branch: "wip", message: "x", wait: IMMEDIATE_POLL }), + ).toEqual({ status: "success", message: "Stash queued", task_id: 5, final: SETTLED_TASK }); + }); + + it("sends the branches request", async () => { + const { mb, capture } = clientOver([jsonResponse({ items: ["main", "wip"] })]); + + await mb.gitSync.branches(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/branches", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("unwraps the branches envelope into an uncounted listing", async () => { + const { mb } = clientOver([jsonResponse({ items: ["main", "wip"] })]); + + expect(await mb.gitSync.branches()).toEqual({ data: ["main", "wip"], total: null }); + }); + + it("sends the create-branch request with the name in the body", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ status: "success", message: "Created feat/x" }), + ]); + + await mb.gitSync.createBranch({ name: "feat/x" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/create-branch", + method: "POST", + headers: JSON_WRITE_HEADERS, + body: '{"name":"feat/x"}', + }, + ]); + }); + + it("sends the collection sync flag as a settings PUT keyed by collection id", async () => { + const { mb, capture } = clientOver([jsonResponse({ success: true, task_id: 3 })]); + + await mb.gitSync.setCollectionSynced(12, true); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/settings", + method: "PUT", + headers: JSON_WRITE_HEADERS, + body: '{"collections":{"12":true}}', + }, + ]); + }); + + it("sends a false flag to unmark a collection", async () => { + const { mb, capture } = clientOver([jsonResponse({ success: true })]); + + await mb.gitSync.setCollectionSynced(12, false); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/settings", + method: "PUT", + headers: JSON_WRITE_HEADERS, + body: '{"collections":{"12":false}}', + }, + ]); + }); + + it("reads the synced collections off the library-inclusive collection listing", async () => { + const { mb, capture } = clientOver([jsonResponse([])]); + + await mb.gitSync.syncedCollections(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/collection?include-library=true", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("keeps only the collections the server flagged for sync", async () => { + const synced = { id: 4, name: "Ops", is_remote_synced: true }; + const { mb } = clientOver([ + jsonResponse([ + { id: 51, name: "Data", is_remote_synced: false }, + synced, + { id: "root", name: "Our analytics", is_remote_synced: false }, + { id: 9, name: "Legacy", is_remote_synced: null }, + ]), + ]); + + expect(await mb.gitSync.syncedCollections()).toEqual({ data: [synced], total: null }); + }); + + it("reads the sync scope past collections whose enum fields carry unpinned values", async () => { + const { mb } = clientOver([ + jsonResponse([ + { ...UNPINNED_ENUM_FIELDS, id: 51, name: "Workspace", is_remote_synced: false }, + { ...UNPINNED_ENUM_FIELDS, id: 4, name: "Ops", is_remote_synced: true }, + ]), + ]); + + expect(await mb.gitSync.syncedCollections()).toEqual({ + data: [{ id: 4, name: "Ops", is_remote_synced: true }], + total: null, + }); + }); + + it("reads the remote url off its own setting", async () => { + const { mb, capture } = clientOver([jsonResponse("https://github.com/acme/sync.git")]); + + await mb.gitSync.remoteUrl(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/setting/remote-sync-url", + method: "GET", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reads an unconfigured remote url setting's null as no remote", async () => { + const { mb } = clientOver([jsonResponse(null)]); + + expect(await mb.gitSync.remoteUrl()).toBeNull(); + }); + + it("normalizes an empty remote url setting to no remote", async () => { + const { mb } = clientOver([jsonResponse("")]); + + expect(await mb.gitSync.remoteUrl()).toBeNull(); + }); + + it("reports no remote when the caller may not read settings", async () => { + const { mb } = clientOver([jsonResponse({ message: "You don't have permissions" }, 403)]); + + expect(await mb.gitSync.remoteUrl()).toBeNull(); + }); + + it("reports no remote when the setting is not registered on the server", async () => { + const { mb } = clientOver([jsonResponse({ message: "Not found." }, 404)]); + + expect(await mb.gitSync.remoteUrl()).toBeNull(); + }); + + it("rethrows a remote url failure that is neither a permission nor a registration answer", async () => { + const { mb } = clientOver([jsonResponse({ message: "boom" }, 500)]); + + const error = await thrownBy(() => mb.gitSync.remoteUrl({ retries: 0 })); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.message).toBe("boom"); + }); + + it("reads the tracked branch off its own setting", async () => { + const { mb, capture } = clientOver([jsonResponse("main")]); + + await mb.gitSync.branch(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/setting/remote-sync-branch", + method: "GET", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reads an unset branch setting's 204 as no branch", async () => { + const { mb } = clientOver([noContent()]); + + expect(await mb.gitSync.branch()).toBeNull(); + }); + + it("reads an unconfigured branch setting's null as no branch", async () => { + const { mb } = clientOver([jsonResponse(null)]); + + expect(await mb.gitSync.branch()).toBeNull(); + }); + + it("answers the task in the status that ended the wait", async () => { + const { mb } = clientOver([jsonResponse(RUNNING_TASK), jsonResponse(SETTLED_TASK)]); + + expect(await mb.gitSync.waitForTask(IMMEDIATE_POLL)).toEqual(SETTLED_TASK); + }); + + it("re-reads the current task until it leaves the running status", async () => { + const { mb, capture } = clientOver([jsonResponse(RUNNING_TASK), jsonResponse(SETTLED_TASK)]); + + await mb.gitSync.waitForTask(IMMEDIATE_POLL); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/current-task", + method: "GET", + headers: BINARY_READ_HEADERS, + body: null, + }, + { + url: "https://mb.example.com/metabase/api/ee/remote-sync/current-task", + method: "GET", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + + it("stops waiting at once when the server reports no task", async () => { + const { mb } = clientOver([noContent()]); + + expect(await mb.gitSync.waitForTask(IMMEDIATE_POLL)).toBeNull(); + }); +}); diff --git a/packages/client/src/resources/git-sync.ts b/packages/client/src/resources/git-sync.ts new file mode 100644 index 0000000..140138c --- /dev/null +++ b/packages/client/src/resources/git-sync.ts @@ -0,0 +1,320 @@ +import { z } from "zod"; + +import { Collection } from "../domain/collection"; +import { + isSyncTaskTerminal, + SyncBranchCreated, + SyncDirtyItem, + type SyncExportResult, + type SyncImportResult, + SyncRemoteChanges, + SyncSettingsUpdateResult, + type SyncStashResult, + SyncTask, +} from "../domain/git-sync"; +import { HttpError } from "../http/errors"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; +import { type PollOptions, pollUntil } from "../poll"; + +import { listCollectionsWithLibrary } from "./collection"; +import { fetchOptionalParsed } from "./optional-parsed"; + +// The sync scope is decided from three fields of every collection on the instance, so it reads them +// through a projection: a collection whose `type`, `namespace` or `authority_level` carries a value +// outside `Collection`'s pinned enums is still one this answer has to account for. +export const SyncScopeCollection = Collection.pick({ + id: true, + name: true, + is_remote_synced: true, +}).strip(); +export type SyncScopeCollection = z.infer; + +const SyncDirtyFlag = z.object({ is_dirty: z.boolean() }); + +const SyncDirtyList = z.object({ dirty: z.array(SyncDirtyItem) }); + +const SyncBranchList = z.object({ items: z.array(z.string()) }); + +const SyncImportStarted = z.object({ + status: z.literal("success"), + task_id: z.number().int().positive().nullable(), + message: z.string().nullable().optional(), +}); + +const SyncExportStarted = z.object({ + message: z.string(), + task_id: z.number().int().positive(), +}); + +const SyncStashStarted = z.object({ + status: z.literal("success"), + message: z.string(), + task_id: z.number().int().positive(), +}); + +const RemoteSyncSetting = z.string().nullable(); + +const FORBIDDEN_STATUS = 403; +const UNREGISTERED_STATUS = 404; + +// The remote-sync settings are admin-readable only, and unregistered on servers without the +// remote-sync module; both answers mean "no usable remote", not a failure of the caller's request. +function isRemoteUnreadable(error: unknown): boolean { + if (!(error instanceof HttpError)) { + return false; + } + return error.status === FORBIDDEN_STATUS || error.status === UNREGISTERED_STATUS; +} + +// Presence of `wait` is the choice to block: the schedule is the caller's, the terminal condition is +// the server's. +export interface SyncWaitParams { + wait?: PollOptions | undefined; +} + +export interface SyncRemoteChangesParams { + "force-refresh"?: boolean | undefined; +} + +export interface SyncImportParams extends SyncWaitParams { + branch?: string | undefined; + force?: boolean | undefined; +} + +export interface SyncExportParams extends SyncWaitParams { + branch?: string | undefined; + message?: string | undefined; + force?: boolean | undefined; +} + +export interface SyncStashParams extends SyncWaitParams { + new_branch: string; + message: string; +} + +export interface SyncCreateBranchParams { + name: string; +} + +export function gitSyncResource(transport: Transport) { + /** Get the running or most recently finished sync task, or null when the server has none. */ + async function currentTask(options: RequestOptions = {}): Promise { + return fetchOptionalParsed(transport, "/api/ee/remote-sync/current-task", SyncTask, options); + } + + /** Request cancellation of the running sync task, and answer it in the state that left it. */ + async function cancelTask(options: RequestOptions = {}): Promise { + return transport.requestParsed(SyncTask, "/api/ee/remote-sync/current-task/cancel", { + ...options, + method: "POST", + }); + } + + /** Whether Metabase holds content changes the remote has not been told about. */ + async function isDirty(options: RequestOptions = {}): Promise { + const flag = await transport.requestParsed(SyncDirtyFlag, "/api/ee/remote-sync/is-dirty", { + ...options, + }); + return flag.is_dirty; + } + + /** List the objects whose local state differs from the remote. */ + async function dirty(options: RequestOptions = {}): Promise> { + const response = await transport.requestParsed(SyncDirtyList, "/api/ee/remote-sync/dirty", { + ...options, + }); + return { data: response.dirty, total: null }; + } + + /** + * Compare the tracked remote branch against what Metabase last imported. The server caches the + * comparison; `force-refresh` re-reads the remote instead. + */ + async function hasRemoteChanges( + params: SyncRemoteChangesParams = {}, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(SyncRemoteChanges, "/api/ee/remote-sync/has-remote-changes", { + ...options, + query: { "force-refresh": params["force-refresh"] }, + }); + } + + /** + * Import content from the remote into Metabase. The endpoint queues a task and returns at once; + * pass `wait` to poll that task until it reaches a terminal status. A server already up to date + * answers no task id, and there is then nothing to poll. + */ + async function importFromRemote( + params: SyncImportParams = {}, + options: RequestOptions = {}, + ): Promise { + const started = await transport.requestParsed(SyncImportStarted, "/api/ee/remote-sync/import", { + ...options, + method: "POST", + body: { branch: params.branch, force: params.force }, + }); + const message = started.message ?? null; + if (params.wait === undefined || started.task_id === null) { + return { message, task_id: started.task_id }; + } + return { message, task_id: started.task_id, final: await settle(params.wait, options) }; + } + + /** + * Export Metabase's content to the remote. The endpoint queues a task and returns at once; pass + * `wait` to poll that task until it reaches a terminal status. + */ + async function exportToRemote( + params: SyncExportParams = {}, + options: RequestOptions = {}, + ): Promise { + const started = await transport.requestParsed(SyncExportStarted, "/api/ee/remote-sync/export", { + ...options, + method: "POST", + body: { branch: params.branch, message: params.message, force: params.force }, + }); + if (params.wait === undefined) { + return { message: started.message, task_id: started.task_id }; + } + return { + message: started.message, + task_id: started.task_id, + final: await settle(params.wait, options), + }; + } + + /** + * Export Metabase's current content to a branch the remote does not have yet. The endpoint + * queues a task and returns at once; pass `wait` to poll that task to a terminal status. + */ + async function stash( + params: SyncStashParams, + options: RequestOptions = {}, + ): Promise { + const started = await transport.requestParsed(SyncStashStarted, "/api/ee/remote-sync/stash", { + ...options, + method: "POST", + body: { new_branch: params.new_branch, message: params.message }, + }); + if (params.wait === undefined) { + return { status: started.status, message: started.message, task_id: started.task_id }; + } + return { + status: started.status, + message: started.message, + task_id: started.task_id, + final: await settle(params.wait, options), + }; + } + + /** List the branches the configured remote carries. */ + async function branches(options: RequestOptions = {}): Promise> { + const response = await transport.requestParsed(SyncBranchList, "/api/ee/remote-sync/branches", { + ...options, + }); + return { data: response.items, total: null }; + } + + /** Create a branch on the remote and point git-sync at it. */ + async function createBranch( + params: SyncCreateBranchParams, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(SyncBranchCreated, "/api/ee/remote-sync/create-branch", { + ...options, + method: "POST", + body: { name: params.name }, + }); + } + + /** + * Mark one collection as git-synced, or unmark it. The server cascades the flag to descendants + * by location prefix, and may queue an export task to carry the change to the remote. + */ + async function setCollectionSynced( + collectionId: number, + synced: boolean, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(SyncSettingsUpdateResult, "/api/ee/remote-sync/settings", { + ...options, + method: "PUT", + body: { collections: { [collectionId]: synced } }, + }); + } + + /** The collections currently in git-sync's scope. */ + async function syncedCollections( + options: RequestOptions = {}, + ): Promise> { + const data = await listCollectionsWithLibrary(transport, SyncScopeCollection, options); + return { data: data.filter((entry) => entry.is_remote_synced === true), total: null }; + } + + /** The remote's URL, or null when none is configured or the caller may not read it. */ + async function remoteUrl(options: RequestOptions = {}): Promise { + try { + const url = await fetchOptionalParsed( + transport, + "/api/setting/remote-sync-url", + RemoteSyncSetting, + options, + ); + return url === "" ? null : url; + } catch (error) { + if (isRemoteUnreadable(error)) { + return null; + } + throw error; + } + } + + /** The branch git-sync tracks, or null when none is configured or the caller may not read it. */ + async function branch(options: RequestOptions = {}): Promise { + return fetchOptionalParsed( + transport, + "/api/setting/remote-sync-branch", + RemoteSyncSetting, + options, + ); + } + + /** + * Poll the current task until it reaches a terminal status. A server with no task at all + * answers null, which is terminal in the same sense: nothing further will happen. + */ + async function waitForTask( + wait: PollOptions, + options: RequestOptions = {}, + ): Promise { + return settle(wait, options); + } + + async function settle(wait: PollOptions, options: RequestOptions): Promise { + return pollUntil( + async (signal) => currentTask({ ...options, signal }), + (task) => task === null || isSyncTaskTerminal(task.status), + wait, + ); + } + + return { + currentTask, + cancelTask, + isDirty, + dirty, + hasRemoteChanges, + import: importFromRemote, + export: exportToRemote, + stash, + branches, + createBranch, + setCollectionSynced, + syncedCollections, + remoteUrl, + branch, + waitForTask, + }; +} diff --git a/packages/client/src/resources/library.test.ts b/packages/client/src/resources/library.test.ts new file mode 100644 index 0000000..9f8d972 --- /dev/null +++ b/packages/client/src/resources/library.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +// Released servers (v0.59-v0.61) omit `type` and `is_remote_synced` from each `effective_children` +// entry; the collection listing is where both come from. +const UNHYDRATED_LIBRARY = { + id: 10, + name: "Library", + type: "library", + description: null, + location: "/", + effective_children: [ + { id: 11, name: "Data", description: null }, + { id: 12, name: "Metrics", description: null }, + ], +}; + +const LIBRARY_COLLECTIONS = [ + { id: 10, name: "Library", type: "library", location: "/", is_remote_synced: false }, + { id: 11, name: "Data", type: "library-data", location: "/10/", is_remote_synced: false }, + { id: 12, name: "Metrics", type: "library-metrics", location: "/10/", is_remote_synced: true }, +]; + +const HYDRATED_LIBRARY = { + ...UNHYDRATED_LIBRARY, + effective_children: [ + { id: 11, name: "Data", description: null, type: "library-data", is_remote_synced: false }, + { + id: 12, + name: "Metrics", + description: null, + type: "library-metrics", + is_remote_synced: true, + }, + ], +}; + +// A collection unrelated to the Library, carrying values the domain schema does not enumerate. +const UNPINNED_ENUM_COLLECTION = { + id: 51, + name: "Workspace", + location: "/", + namespace: "workspaces", + authority_level: "critical", +}; + +const ABSENT_LIBRARY = { data: null }; + +const TARGET_COLLECTION = { + id: 11, + name: "Data", + type: "library-data", + description: null, + location: "/10/", + is_remote_synced: false, +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const BINARY_WRITE_HEADERS = { + accept: "*/*", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const ROOT_URL = "https://mb.example.com/metabase/api/ee/library/"; +const COLLECTIONS_URL = "https://mb.example.com/metabase/api/collection?include-library=true"; + +const READ_ROOT_CALL = { url: ROOT_URL, method: "GET", headers: JSON_READ_HEADERS, body: null }; +const READ_COLLECTIONS_CALL = { + url: COLLECTIONS_URL, + method: "GET", + headers: JSON_READ_HEADERS, + body: null, +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("library resource wire requests", () => { + it("sends the get request and the collection listing that resolves its children", async () => { + const { mb, capture } = clientOver([ + jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(LIBRARY_COLLECTIONS), + ]); + + await mb.library.get(); + + expect(capture.calls).toEqual([READ_ROOT_CALL, READ_COLLECTIONS_CALL]); + }); + + it("sends the bodyless create POST and refetches when no Library exists", async () => { + const { mb, capture } = clientOver([ + jsonResponse(ABSENT_LIBRARY), + jsonResponse({ id: 10, name: "Library" }), + jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(LIBRARY_COLLECTIONS), + ]); + + await mb.library.create(); + + expect(capture.calls).toEqual([ + READ_ROOT_CALL, + { url: ROOT_URL, method: "POST", headers: JSON_READ_HEADERS, body: null }, + READ_ROOT_CALL, + READ_COLLECTIONS_CALL, + ]); + }); + + it("repeats only the reads and never POSTs when create runs twice against an existing Library", async () => { + const { mb, capture } = clientOver([ + jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(LIBRARY_COLLECTIONS), + jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(LIBRARY_COLLECTIONS), + ]); + + await mb.library.create(); + await mb.library.create(); + + expect(capture.calls).toEqual([ + READ_ROOT_CALL, + READ_COLLECTIONS_CALL, + READ_ROOT_CALL, + READ_COLLECTIONS_CALL, + ]); + }); + + it("resolves the Data collection id from the create reads alone", async () => { + const { mb, capture } = clientOver([ + jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(LIBRARY_COLLECTIONS), + ]); + + await mb.library.ensureDataCollectionId(); + + expect(capture.calls).toEqual([READ_ROOT_CALL, READ_COLLECTIONS_CALL]); + }); + + it("sends the publish request with the target collection and the selectors", async () => { + const { mb, capture } = clientOver([jsonResponse({ target_collection: TARGET_COLLECTION })]); + + await mb.library.publishTables({ collection_id: 11, table_ids: [3, 4], schema_ids: ["1:pub"] }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/data-studio/table/publish-tables", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"collection_id":11,"table_ids":[3,4],"schema_ids":["1:pub"]}', + }, + ]); + }); + + it("sends the unpublish request accepting a non-JSON answer", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 204 })]); + + await mb.library.unpublishTables({ database_ids: [7] }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/ee/data-studio/table/unpublish-tables", + method: "POST", + headers: BINARY_WRITE_HEADERS, + body: '{"database_ids":[7]}', + }, + ]); + }); +}); + +describe("library resource results", () => { + it("resolves each child's type and sync flag from the collection listing", async () => { + const { mb } = clientOver([ + jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(LIBRARY_COLLECTIONS), + ]); + + expect(await mb.library.get()).toEqual(HYDRATED_LIBRARY); + }); + + it("resolves the children past a collection whose enum fields carry unpinned values", async () => { + const { mb } = clientOver([ + jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse([...LIBRARY_COLLECTIONS, UNPINNED_ENUM_COLLECTION]), + ]); + + expect(await mb.library.get()).toEqual(HYDRATED_LIBRARY); + }); + + it("keeps a hydrated response's own values when the listing holds no matching collection", async () => { + const { mb } = clientOver([jsonResponse(HYDRATED_LIBRARY), jsonResponse([])]); + + expect(await mb.library.get()).toEqual(HYDRATED_LIBRARY); + }); + + it("answers null on an instance that has no Library", async () => { + const { mb } = clientOver([jsonResponse(ABSENT_LIBRARY)]); + + expect(await mb.library.get()).toBeNull(); + }); + + it("returns the existing Library rather than creating a second one", async () => { + const { mb } = clientOver([ + jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(LIBRARY_COLLECTIONS), + ]); + + expect(await mb.library.create()).toEqual(HYDRATED_LIBRARY); + }); + + it("throws when the refetch after the create POST still finds no Library", async () => { + const { mb } = clientOver([ + jsonResponse(ABSENT_LIBRARY), + jsonResponse({ id: 10, name: "Library" }), + jsonResponse(ABSENT_LIBRARY), + ]); + + await expect(mb.library.create()).rejects.toThrow( + new Error("Library was not created after POST /api/ee/library/"), + ); + }); + + it("returns the Data collection's id", async () => { + const { mb } = clientOver([ + jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(LIBRARY_COLLECTIONS), + ]); + + expect(await mb.library.ensureDataCollectionId()).toBe(11); + }); + + it("throws when the Library carries no Data collection", async () => { + const { mb } = clientOver([ + jsonResponse({ ...UNHYDRATED_LIBRARY, effective_children: [] }), + jsonResponse(LIBRARY_COLLECTIONS), + ]); + + await expect(mb.library.ensureDataCollectionId()).rejects.toThrow( + new Error("Library has no Data collection to publish into"), + ); + }); + + it("throws when the Data collection's id is not numeric", async () => { + const { mb } = clientOver([ + jsonResponse({ + ...UNHYDRATED_LIBRARY, + effective_children: [{ id: "NuFrFzRZgvqcMGjSjOOJH", name: "Data", type: "library-data" }], + }), + jsonResponse(LIBRARY_COLLECTIONS), + ]); + + await expect(mb.library.ensureDataCollectionId()).rejects.toThrow( + new Error("Library Data collection has a non-numeric id NuFrFzRZgvqcMGjSjOOJH"), + ); + }); + + it("returns the collection the publish landed in", async () => { + const { mb } = clientOver([jsonResponse({ target_collection: TARGET_COLLECTION })]); + + expect(await mb.library.publishTables({ collection_id: 11, table_ids: [3] })).toEqual( + TARGET_COLLECTION, + ); + }); +}); diff --git a/packages/client/src/resources/library.ts b/packages/client/src/resources/library.ts new file mode 100644 index 0000000..a78bad0 --- /dev/null +++ b/packages/client/src/resources/library.ts @@ -0,0 +1,151 @@ +import { z } from "zod"; + +import { Collection } from "../domain/collection"; +import { Library } from "../domain/library"; +import type { RequestOptions, Transport } from "../http/transport"; +import { listCollectionsWithLibrary } from "./collection"; + +// The trailing slash is part of the route: Metabase mounts the Library API under `/library` and +// registers both the read and the create endpoint at `"/"` within it. +const LIBRARY_ROOT_PATH = "/api/ee/library/"; +const PUBLISH_TABLES_PATH = "/api/ee/data-studio/table/publish-tables"; +const UNPUBLISH_TABLES_PATH = "/api/ee/data-studio/table/unpublish-tables"; + +const LIBRARY_DATA_TYPE = "library-data"; + +// An instance with no Library answers the root with `{ data: null }` rather than a 404. +const AbsentLibrary = z.object({ data: z.null() }); +const LibraryOrAbsent = z.union([Library, AbsentLibrary]); + +const PublishTablesResponse = z.object({ target_collection: Collection.nullable() }); + +// Hydrating the Library's children means reading every collection on the instance, so they arrive +// through a projection: a collection whose `namespace` or `authority_level` carries a value outside +// `Collection`'s pinned enums is unrelated to the Library and must not decide whether it resolves. +const LibraryCollectionInfo = Collection.pick({ + id: true, + type: true, + is_remote_synced: true, +}).strip(); +type LibraryCollectionInfo = z.infer; + +export interface LibraryTableSelectors { + table_ids?: number[] | undefined; + database_ids?: number[] | undefined; + schema_ids?: string[] | undefined; +} + +export interface LibraryPublishParams extends LibraryTableSelectors { + collection_id: number; +} + +export function libraryResource(transport: Transport) { + /** Get the Library root and its child collections, or `null` on an instance that has none. */ + async function get(options: RequestOptions = {}): Promise { + const result = await transport.requestParsed(LibraryOrAbsent, LIBRARY_ROOT_PATH, { + ...options, + }); + if (!("effective_children" in result)) { + return null; + } + // GET /api/ee/library/ doesn't send each child's `type` or `is_remote_synced` in + // `effective_children` on released servers (v0.59-v0.61) — the frontend's own LibraryChild + // type omits them too. Resolve both from the collection list so callers can tell the Data + // and Metrics collections apart and see whether each is in the git-sync scope. + const infoById = await libraryCollectionsById(options); + const effective_children = result.effective_children.map((child) => { + if (typeof child.id !== "number") { + return child; + } + const info = infoById.get(child.id); + if (info === undefined) { + return child; + } + return { + ...child, + type: info.type ?? child.type, + is_remote_synced: info.is_remote_synced ?? child.is_remote_synced, + }; + }); + return { ...result, effective_children }; + } + + async function libraryCollectionsById( + options: RequestOptions, + ): Promise> { + const data = await listCollectionsWithLibrary(transport, LibraryCollectionInfo, options); + const byId = new Map(); + for (const collection of data) { + if (typeof collection.id === "number") { + byId.set(collection.id, collection); + } + } + return byId; + } + + /** + * Create the Library subtree (its Data and Metrics collections). Metabase takes no body, rejects + * a second create with a 400, and answers the bare root collection without its + * `effective_children` — so an existing Library short-circuits the call and a fresh one comes + * back from a refetch, which together make this idempotent. + */ + async function create(options: RequestOptions = {}): Promise { + const existing = await get(options); + if (existing !== null) { + return existing; + } + await transport.requestRaw(LIBRARY_ROOT_PATH, { ...options, method: "POST" }); + const created = await get(options); + if (created === null) { + throw new Error(`Library was not created after POST ${LIBRARY_ROOT_PATH}`); + } + return created; + } + + /** The id of the Library's Data collection, creating the Library first when it does not exist. */ + async function ensureDataCollectionId(options: RequestOptions = {}): Promise { + const library = await create(options); + const data = library.effective_children.find((child) => child.type === LIBRARY_DATA_TYPE); + if (data === undefined) { + throw new Error("Library has no Data collection to publish into"); + } + if (typeof data.id !== "number") { + throw new Error(`Library Data collection has a non-numeric id ${String(data.id)}`); + } + return data.id; + } + + /** + * Publish tables — and every upstream table they depend on — into a collection, so they lead the + * data pickers and rank up in search. Answers the collection they landed in. + */ + async function publishTables( + params: LibraryPublishParams, + options: RequestOptions = {}, + ): Promise { + const response = await transport.requestParsed(PublishTablesResponse, PUBLISH_TABLES_PATH, { + ...options, + method: "POST", + body: params, + }); + return response.target_collection; + } + + /** + * Clear the Library collection from tables, and recursively from every downstream table that + * depends on them. The endpoint answers no JSON body. + */ + async function unpublishTables( + params: LibraryTableSelectors, + options: RequestOptions = {}, + ): Promise { + await transport.requestRaw(UNPUBLISH_TABLES_PATH, { + ...options, + method: "POST", + body: params, + expectContentType: "binary", + }); + } + + return { get, create, ensureDataCollectionId, publishTables, unpublishTables }; +} diff --git a/packages/client/src/resources/measure.test.ts b/packages/client/src/resources/measure.test.ts new file mode 100644 index 0000000..6e8069e --- /dev/null +++ b/packages/client/src/resources/measure.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const MEASURE = { + id: 4, + name: "Total revenue", + description: null, + archived: false, + table_id: 11, + definition: { aggregation: [["sum", ["field", 3, null]]] }, + creator_id: 1, + entity_id: "bbbbbbbbbbbbbbbbbbbbb", + created_at: "2026-01-02T03:04:05Z", + updated_at: "2026-01-02T03:04:05Z", +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("measure resource wire requests", () => { + it("sends the list request with no query parameters", async () => { + const { mb, capture } = clientOver([jsonResponse([MEASURE])]); + + await mb.measure.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/measure", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reports no server count for the bare-array list", async () => { + const { mb } = clientOver([jsonResponse([MEASURE])]); + + await expect(mb.measure.list()).resolves.toEqual({ data: [MEASURE], total: null }); + }); + + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(MEASURE)]); + + await mb.measure.get(4); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/measure/4", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the body as JSON", async () => { + const { mb, capture } = clientOver([jsonResponse(MEASURE)]); + + await mb.measure.create({ + name: "Total revenue", + table_id: 11, + definition: { aggregation: [["sum", ["field", 3, null]]] }, + }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/measure", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Total revenue","table_id":11,"definition":{"aggregation":[["sum",["field",3,null]]]}}', + }, + ]); + }); + + it("sends the update request as a PUT carrying only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse(MEASURE)]); + + await mb.measure.update(4, { name: "Renamed", revision_message: "rename" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/measure/4", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Renamed","revision_message":"rename"}', + }, + ]); + }); + + it("sends the archive request as the same PUT with archived and the revision message", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...MEASURE, archived: true })]); + + await mb.measure.archive(4, { revision_message: "deprecated" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/measure/4", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"archived":true,"revision_message":"deprecated"}', + }, + ]); + }); +}); diff --git a/packages/client/src/resources/measure.ts b/packages/client/src/resources/measure.ts new file mode 100644 index 0000000..b81457d --- /dev/null +++ b/packages/client/src/resources/measure.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +import { Measure, type MeasureCreateInput, type MeasureUpdateInput } from "../domain/measure"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// `GET /api/measure` answers a bare array rather than a `{ data, total }` envelope, so the count a +// caller reads off `ListResult` is the array's own length and the server reports none. +const MeasureApiList = z.array(Measure); + +export interface MeasureArchiveParams { + revision_message: string; +} + +export function measureResource(transport: Transport) { + /** List measures. */ + async function list(options: RequestOptions = {}): Promise> { + const data = await transport.requestParsed(MeasureApiList, "/api/measure", { ...options }); + return { data, total: null }; + } + + /** Get one measure by id. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(Measure, `/api/measure/${id}`, { ...options }); + } + + /** Create a measure — a saved aggregation tied to a table — from a full measure body. */ + async function create( + params: MeasureCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Measure, "/api/measure", { + ...options, + method: "POST", + body: params, + }); + } + + /** + * Update a measure by id, patching only the fields the body carries. `revision_message` is + * required and lands in the audit log. + */ + async function update( + id: number, + params: MeasureUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Measure, `/api/measure/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** + * Archive (soft-delete) a measure by id. Metabase models this as an update, not its own endpoint, + * so it carries the same required `revision_message`. + */ + async function archive( + id: number, + params: MeasureArchiveParams, + options: RequestOptions = {}, + ): Promise { + return update(id, { archived: true, revision_message: params.revision_message }, options); + } + + return { list, get, create, update, archive }; +} diff --git a/packages/client/src/resources/notification.test.ts b/packages/client/src/resources/notification.test.ts new file mode 100644 index 0000000..9a7d11c --- /dev/null +++ b/packages/client/src/resources/notification.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import { Notification } from "../domain/notification"; +import { ConfigError } from "../errors"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +import { assertCardAlert, mergeAlertUpdate } from "./notification"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const SEND_HEADERS = { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const STORED = Notification.parse({ + id: 9, + payload_type: "notification/card", + payload_id: 4, + payload: { id: 4, card_id: 94, send_condition: "has_result", send_once: false }, + active: true, + creator_id: 2, + subscriptions: [ + { id: 9, type: "notification-subscription/cron", cron_schedule: "0 0 8 * * ? *" }, + ], + handlers: [ + { + id: 9, + channel_type: "channel/email", + recipients: [ + { id: 10, type: "notification-recipient/raw-value", details: { value: "a@example.com" } }, + ], + }, + ], +}); + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("notification resource wire requests", () => { + it("scopes the listing to the card payload type", async () => { + const { mb, capture } = clientOver([jsonResponse([])]); + + await mb.notification.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/notification?payload_type=notification%2Fcard", + method: "GET", + headers: READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends every list filter the caller supplies alongside the payload type", async () => { + const { mb, capture } = clientOver([jsonResponse([])]); + + await mb.notification.list({ + card_id: 94, + creator_id: 2, + recipient_id: 3, + include_inactive: true, + }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/notification?payload_type=notification%2Fcard&card_id=94&creator_id=2&recipient_id=3&include_inactive=true", + method: "GET", + headers: READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(STORED)]); + + await mb.notification.get(9); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/notification/9", + method: "GET", + headers: READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the body as JSON", async () => { + const { mb, capture } = clientOver([jsonResponse(STORED)]); + + await mb.notification.create({ + payload_type: "notification/card", + payload: { card_id: 94 }, + subscriptions: [{ type: "notification-subscription/cron", cron_schedule: "0 0 8 * * ? *" }], + handlers: [{ channel_type: "channel/email" }], + }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/notification", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"payload_type":"notification/card","payload":{"card_id":94},"subscriptions":[{"type":"notification-subscription/cron","cron_schedule":"0 0 8 * * ? *"}],"handlers":[{"channel_type":"channel/email"}]}', + }, + ]); + }); + + it("reads the stored alert before PUTting the merged body", async () => { + const { mb, capture } = clientOver([ + jsonResponse(STORED), + jsonResponse({ ...STORED, active: false }), + ]); + + await mb.notification.update(9, { active: false }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/notification/9", + method: "GET", + headers: READ_HEADERS, + body: null, + }, + { + url: "https://mb.example.com/metabase/api/notification/9", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: JSON.stringify({ ...STORED, active: false }), + }, + ]); + }); + + it("archives by deactivating the alert", async () => { + const { mb, capture } = clientOver([ + jsonResponse(STORED), + jsonResponse({ ...STORED, active: false }), + ]); + + await mb.notification.archive(9); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/notification/9", + method: "GET", + headers: READ_HEADERS, + body: null, + }, + { + url: "https://mb.example.com/metabase/api/notification/9", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: JSON.stringify({ ...STORED, active: false }), + }, + ]); + }); + + it("checks the alert is a card alert before sending it off-schedule", async () => { + const { mb, capture } = clientOver([jsonResponse(STORED), new Response(null, { status: 204 })]); + + await mb.notification.send(9); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/notification/9", + method: "GET", + headers: READ_HEADERS, + body: null, + }, + { + url: "https://mb.example.com/metabase/api/notification/9/send", + method: "POST", + headers: SEND_HEADERS, + body: null, + }, + ]); + }); + + it("refuses to send a system-event notification, and never reaches the send endpoint", async () => { + const systemEvent = { ...STORED, payload_type: "notification/system-event", payload: null }; + const { mb, capture } = clientOver([jsonResponse(systemEvent)]); + + await expect(mb.notification.send(9)).rejects.toThrow( + new ConfigError( + "notification 9 is a notification/system-event, not a question alert — this operation accepts card alerts only", + ), + ); + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/notification/9", + method: "GET", + headers: READ_HEADERS, + body: null, + }, + ]); + }); +}); + +describe("assertCardAlert", () => { + it("returns a card notification unchanged", () => { + expect(assertCardAlert(STORED)).toEqual(STORED); + }); + + it("rejects a system-event notification, which shares the /api/notification id space", () => { + const systemEvent = Notification.parse({ + ...STORED, + id: 1, + payload_type: "notification/system-event", + payload_id: null, + payload: null, + }); + + expect(() => assertCardAlert(systemEvent)).toThrow( + new ConfigError( + "notification 1 is a notification/system-event, not a question alert — this operation accepts card alerts only", + ), + ); + }); +}); + +describe("mergeAlertUpdate", () => { + it("keeps the notification id and payload id so the server updates in place", () => { + const merged = mergeAlertUpdate(STORED, { active: false }); + + expect(merged).toEqual({ ...STORED, active: false }); + }); + + it("merges a partial payload over the stored one, preserving card_id and the payload id", () => { + const merged = mergeAlertUpdate(STORED, { payload: { send_condition: "goal_above" } }); + + expect(merged).toEqual({ + ...STORED, + payload: { id: 4, card_id: 94, send_condition: "goal_above", send_once: false }, + }); + }); + + it("replaces subscriptions and handlers wholesale, matching the server's spec-diff semantics", () => { + const merged = mergeAlertUpdate(STORED, { + subscriptions: [{ type: "notification-subscription/cron", cron_schedule: "0 0 9 * * ? *" }], + handlers: [ + { + channel_type: "channel/slack", + recipients: [ + { type: "notification-recipient/raw-value", details: { value: "#general" } }, + ], + }, + ], + }); + + expect(merged).toEqual({ + ...STORED, + subscriptions: [{ type: "notification-subscription/cron", cron_schedule: "0 0 9 * * ? *" }], + handlers: [ + { + channel_type: "channel/slack", + recipients: [ + { type: "notification-recipient/raw-value", details: { value: "#general" } }, + ], + }, + ], + }); + }); + + it("refuses to patch the payload of an alert whose card payload was deleted server-side", () => { + const orphaned = Notification.parse({ ...STORED, payload_id: null, payload: null }); + + expect(() => mergeAlertUpdate(orphaned, { payload: { send_once: true } })).toThrow( + new ConfigError("alert 9 has lost its card payload — it can be archived, but not patched"), + ); + }); + + it("still deactivates an alert whose card payload was deleted server-side", () => { + const orphaned = Notification.parse({ ...STORED, payload_id: null, payload: null }); + + expect(mergeAlertUpdate(orphaned, { active: false })).toEqual({ + ...orphaned, + active: false, + }); + }); +}); diff --git a/packages/client/src/resources/notification.ts b/packages/client/src/resources/notification.ts new file mode 100644 index 0000000..8d0a175 --- /dev/null +++ b/packages/client/src/resources/notification.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; + +import { + CARD_PAYLOAD_TYPE, + Notification, + NotificationCardPayload, + type NotificationCardPayloadPatch, + type NotificationCreateInput, + type NotificationUpdateInput, +} from "../domain/notification"; +import { ConfigError } from "../errors"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// `GET /api/notification` answers a bare array rather than a `{ data, total }` envelope, so the +// count a caller reads off `ListResult` is the array's own length and the server reports none. +const NotificationApiList = z.array(Notification); + +export interface NotificationListParams { + card_id?: number | undefined; + creator_id?: number | undefined; + recipient_id?: number | undefined; + include_inactive?: boolean | undefined; +} + +// /api/notification is shared with Metabase's internal system-event notifications, whose ids sit +// alongside the question alerts'. Reading one as an alert would be confusing, and sending one would +// fire an internal Metabase email, so every by-id verb loads the notification through here. +export function assertCardAlert(notification: Notification): Notification { + if (notification.payload_type !== CARD_PAYLOAD_TYPE) { + throw new ConfigError( + `notification ${notification.id} is a ${notification.payload_type}, not a question alert — this operation accepts card alerts only`, + ); + } + return notification; +} + +// `PUT /api/notification/:id` is a spec-diff against the stored row, not a patch: a body whose +// `id` does not match the stored one makes Metabase delete the notification and insert a +// replacement under a fresh id, and the same holds for the nested `payload` row. So every update +// reads the current notification and merges the caller's patch over it, preserving both ids. +export function mergeAlertUpdate( + current: Notification, + patch: NotificationUpdateInput, +): Notification { + return Notification.parse({ + ...current, + ...patch, + id: current.id, + payload: patch.payload === undefined ? current.payload : mergePayload(current, patch.payload), + }); +} + +// A card alert whose payload row was deleted server-side comes back with `payload: null`. It can +// still be deactivated, but there is nothing to merge a payload patch into. +function mergePayload( + current: Notification, + patch: NotificationCardPayloadPatch, +): NotificationCardPayload { + if (current.payload === null) { + throw new ConfigError( + `alert ${current.id} has lost its card payload — it can be archived, but not patched`, + ); + } + return NotificationCardPayload.parse({ ...current.payload, ...patch }); +} + +// Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. +export function notificationResource(transport: Transport) { + /** + * List question alerts, optionally narrowed by the card they watch, their creator, or a + * recipient. `/api/notification` also serves Metabase's internal system-event notifications, so + * the request pins the card payload type. + */ + async function list( + params: NotificationListParams = {}, + options: RequestOptions = {}, + ): Promise> { + const data = await transport.requestParsed(NotificationApiList, "/api/notification", { + ...options, + query: { + payload_type: CARD_PAYLOAD_TYPE, + card_id: params.card_id, + creator_id: params.creator_id, + recipient_id: params.recipient_id, + include_inactive: params.include_inactive, + }, + }); + return { data, total: null }; + } + + /** Get one question alert by id, refusing an id that names a system-event notification. */ + async function get(id: number, options: RequestOptions = {}): Promise { + const notification = await transport.requestParsed(Notification, `/api/notification/${id}`, { + ...options, + }); + return assertCardAlert(notification); + } + + /** Create a question alert — its card payload, schedules and handlers — from a full body. */ + async function create( + params: NotificationCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Notification, "/api/notification", { + ...options, + method: "POST", + body: params, + }); + } + + /** Update a question alert by id, merging the patch over the stored notification. */ + async function update( + id: number, + params: NotificationUpdateInput, + options: RequestOptions = {}, + ): Promise { + const current = await get(id, options); + return transport.requestParsed(Notification, `/api/notification/${id}`, { + ...options, + method: "PUT", + body: mergeAlertUpdate(current, params), + }); + } + + /** Archive a question alert by id, stopping every delivery. Metabase models this as an update. */ + async function archive(id: number, options: RequestOptions = {}): Promise { + return update(id, { active: false }, options); + } + + /** + * Send a question alert now, off-schedule, to every handler on it. The server answers with no + * body. + */ + async function send(id: number, options: RequestOptions = {}): Promise { + await get(id, options); + await transport.requestRaw(`/api/notification/${id}/send`, { + ...options, + method: "POST", + expectContentType: "binary", + }); + } + + return { list, get, create, update, archive, send }; +} diff --git a/packages/client/src/resources/optional-parsed.ts b/packages/client/src/resources/optional-parsed.ts new file mode 100644 index 0000000..dc90111 --- /dev/null +++ b/packages/client/src/resources/optional-parsed.ts @@ -0,0 +1,29 @@ +import type { ZodType } from "zod"; + +import type { RequestOptions, Transport } from "../http/transport"; +import { parseJsonOrPlain } from "../json"; + +const NO_CONTENT_STATUS = 204; + +// Endpoints that answer 204 for "the value is unset" rather than 404, and answer the value itself +// as either JSON or bare text depending on the server version. `null` is the unset value, not an +// error. +export async function fetchOptionalParsed( + client: Transport, + path: string, + schema: ZodType, + options: RequestOptions = {}, +): Promise { + const response = await client.requestRaw(path, { + ...options, + method: "GET", + expectContentType: "binary", + }); + if (response.status === NO_CONTENT_STATUS) { + return null; + } + const text = await response.text(); + return parseJsonOrPlain(text, response.headers.get("content-type"), schema, { + source: response.url, + }); +} diff --git a/packages/client/src/resources/pulse.test.ts b/packages/client/src/resources/pulse.test.ts new file mode 100644 index 0000000..ab8abf6 --- /dev/null +++ b/packages/client/src/resources/pulse.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import { Pulse, type PulseUpdateInput } from "../domain/pulse"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +import { mergePulseUpdate } from "./pulse"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const STORED = Pulse.parse({ + id: 1, + name: "Weekly orders", + creator_id: 2, + dashboard_id: 10, + collection_id: 3, + archived: false, + skip_if_empty: true, + parameters: [], + cards: [ + { + id: 94, + name: "Orders", + dashboard_card_id: 87, + include_csv: false, + include_xls: false, + }, + ], + channels: [ + { + id: 5, + channel_type: "email", + enabled: true, + schedule_type: "daily", + schedule_hour: 8, + schedule_day: null, + schedule_frame: null, + recipients: [{ id: null, email: "team@example.com" }], + }, + ], +}); + +const STORED_ARCHIVED: Pulse = { ...STORED, archived: true }; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("pulse resource wire requests", () => { + it("sends the list request without a query string", async () => { + const { mb, capture } = clientOver([jsonResponse([STORED])]); + + await mb.pulse.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/pulse", + method: "GET", + headers: READ_HEADERS, + body: null, + }, + ]); + }); + + it("narrows the listing to one dashboard's archived subscriptions", async () => { + const { mb, capture } = clientOver([jsonResponse([])]); + + await mb.pulse.list({ dashboard_id: 10, archived: true }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/pulse?dashboard_id=10&archived=true", + method: "GET", + headers: READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(STORED)]); + + await mb.pulse.get(1); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/pulse/1", + method: "GET", + headers: READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the body as JSON", async () => { + const { mb, capture } = clientOver([jsonResponse(STORED)]); + + await mb.pulse.create({ + name: "Weekly orders", + dashboard_id: 10, + cards: [{ id: 94, include_csv: false, include_xls: false }], + channels: [{ channel_type: "email", enabled: true, schedule_type: "daily" }], + }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/pulse", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Weekly orders","dashboard_id":10,"cards":[{"id":94,"include_csv":false,"include_xls":false}],"channels":[{"channel_type":"email","enabled":true,"schedule_type":"daily"}]}', + }, + ]); + }); + + it("reads the stored subscription before PUTting the merged body", async () => { + const { mb, capture } = clientOver([jsonResponse(STORED), jsonResponse(STORED)]); + + await mb.pulse.update(1, { name: "Daily orders" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/pulse/1", + method: "GET", + headers: READ_HEADERS, + body: null, + }, + { + url: "https://mb.example.com/metabase/api/pulse/1", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Daily orders","archived":false,"skip_if_empty":true}', + }, + ]); + }); + + it("archives by carrying the stored skip_if_empty forward alongside archived", async () => { + const { mb, capture } = clientOver([ + jsonResponse(STORED), + jsonResponse({ ...STORED, archived: true }), + ]); + + await mb.pulse.archive(1); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/pulse/1", + method: "GET", + headers: READ_HEADERS, + body: null, + }, + { + url: "https://mb.example.com/metabase/api/pulse/1", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"archived":true,"skip_if_empty":true}', + }, + ]); + }); +}); + +describe("mergePulseUpdate", () => { + it("carries archived and skip_if_empty forward, which the server would otherwise default to false", () => { + expect(mergePulseUpdate(STORED_ARCHIVED, { name: "Daily orders" })).toEqual({ + name: "Daily orders", + archived: true, + skip_if_empty: true, + }); + }); + + it("lets the caller override both of the server-defaulted fields", () => { + expect(mergePulseUpdate(STORED, { archived: true, skip_if_empty: false })).toEqual({ + archived: true, + skip_if_empty: false, + }); + }); + + it("passes the caller's other fields through untouched", () => { + const channels: NonNullable = [ + { + channel_type: "email", + enabled: true, + schedule_type: "weekly", + schedule_hour: 6, + schedule_day: "mon", + recipients: [{ email: "team@example.com" }], + }, + ]; + + expect(mergePulseUpdate(STORED, { channels })).toEqual({ + channels, + archived: false, + skip_if_empty: true, + }); + }); +}); diff --git a/packages/client/src/resources/pulse.ts b/packages/client/src/resources/pulse.ts new file mode 100644 index 0000000..21b96a5 --- /dev/null +++ b/packages/client/src/resources/pulse.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +import { Pulse, type PulseCreateInput, type PulseUpdateInput } from "../domain/pulse"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// `GET /api/pulse` answers a bare array rather than a `{ data, total }` envelope, so the count a +// caller reads off `ListResult` is the array's own length and the server reports none. +const PulseApiList = z.array(Pulse); + +export interface PulseListParams { + dashboard_id?: number | undefined; + archived?: boolean | undefined; +} + +// `PUT /api/pulse/:id` applies the server's schema defaults to keys the body omits, and both +// `archived` and `skip_if_empty` default to false. A patch that leaves them out therefore +// un-archives the pulse and clears skip_if_empty behind the caller's back. Every update carries +// both forward from the stored pulse unless the caller sets them explicitly. The remaining fields +// (`name`, `cards`, `channels`, `collection_id`, `parameters`) are optional server-side and survive +// an omission untouched. +export function mergePulseUpdate(current: Pulse, patch: PulseUpdateInput): PulseUpdateInput { + return { + ...patch, + archived: patch.archived ?? current.archived, + skip_if_empty: patch.skip_if_empty ?? current.skip_if_empty, + }; +} + +// Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. +export function pulseResource(transport: Transport) { + /** List dashboard subscriptions, optionally narrowed to one dashboard or to the archived ones. */ + async function list( + params: PulseListParams = {}, + options: RequestOptions = {}, + ): Promise> { + const data = await transport.requestParsed(PulseApiList, "/api/pulse", { + ...options, + query: { dashboard_id: params.dashboard_id, archived: params.archived }, + }); + return { data, total: null }; + } + + /** Get one dashboard subscription by id. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(Pulse, `/api/pulse/${id}`, { ...options }); + } + + /** Create a dashboard subscription — its cards and its delivery channels — from a full body. */ + async function create(params: PulseCreateInput, options: RequestOptions = {}): Promise { + return transport.requestParsed(Pulse, "/api/pulse", { + ...options, + method: "POST", + body: params, + }); + } + + /** Update a dashboard subscription by id, carrying the server-defaulted flags forward. */ + async function update( + id: number, + params: PulseUpdateInput, + options: RequestOptions = {}, + ): Promise { + const current = await get(id, options); + return transport.requestParsed(Pulse, `/api/pulse/${id}`, { + ...options, + method: "PUT", + body: mergePulseUpdate(current, params), + }); + } + + /** + * Archive a dashboard subscription by id, stopping every delivery. Metabase models this as an + * update, and disables each of the subscription's channels as a side effect. + */ + async function archive(id: number, options: RequestOptions = {}): Promise { + return update(id, { archived: true }, options); + } + + return { list, get, create, update, archive }; +} diff --git a/packages/client/src/resources/search.test.ts b/packages/client/src/resources/search.test.ts new file mode 100644 index 0000000..e942972 --- /dev/null +++ b/packages/client/src/resources/search.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const SEARCH_RESULT = { + id: 7, + name: "Orders", + model: "card", + description: null, + archived: false, + collection: { id: 3, name: "Reports", authority_level: null, type: null }, +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("search resource wire requests", () => { + it("sends every search parameter, repeating the models key once per model", async () => { + const { mb, capture } = clientOver([jsonResponse({ data: [SEARCH_RESULT], total: 1 })]); + + await mb.search.query({ + q: "orders", + models: ["card", "dashboard"], + archived: true, + limit: 20, + offset: 40, + table_db_id: 2, + verified: true, + }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/search?q=orders&models=card&models=dashboard&archived=true&limit=20&offset=40&table_db_id=2&verified=true", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("omits every unset search parameter from the query string", async () => { + const { mb, capture } = clientOver([jsonResponse({ data: [], total: 0 })]); + + await mb.search.query({ q: "orders" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/search?q=orders", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("returns the server's slice alongside its count across the whole result set", async () => { + const { mb } = clientOver([jsonResponse({ data: [SEARCH_RESULT], total: 137 })]); + + const page = await mb.search.query({ q: "orders", limit: 1 }); + + expect(page).toEqual({ data: [SEARCH_RESULT], total: 137 }); + }); +}); diff --git a/packages/client/src/resources/search.ts b/packages/client/src/resources/search.ts new file mode 100644 index 0000000..706383f --- /dev/null +++ b/packages/client/src/resources/search.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +import { type SearchModel, SearchResult } from "../domain/search"; +import type { RequestOptions, Transport } from "../http/transport"; + +// `GET /api/search` applies the window itself and reports the count across the whole result set, +// so `total` is the server's and never the returned slice's length. It is required rather than +// nullable — a server page that cannot say whether more rows remain leaves a caller unable to page. +export interface SearchPage { + data: SearchResult[]; + total: number; +} + +const SearchApiResponse = z + .object({ + data: z.array(SearchResult), + total: z.number().int().nonnegative(), + }) + .loose(); + +export interface SearchParams { + q?: string | undefined; + models?: ReadonlyArray | undefined; + archived?: boolean | undefined; + limit?: number | undefined; + offset?: number | undefined; + table_db_id?: number | undefined; + verified?: boolean | undefined; +} + +export function searchResource(transport: Transport) { + /** + * Search over the instance's content, ranked against `q`. `models` narrows which kinds of entity + * may match, `archived` swaps the active set for the archived one, `table_db_id` restricts to + * items on one database, `verified` to verified content, and `limit`/`offset` are the window the + * server applies before ranking hydration. + */ + async function query( + params: SearchParams = {}, + options: RequestOptions = {}, + ): Promise { + const response = await transport.requestParsed(SearchApiResponse, "/api/search", { + ...options, + query: { + q: params.q, + models: params.models, + archived: params.archived, + limit: params.limit, + offset: params.offset, + table_db_id: params.table_db_id, + verified: params.verified, + }, + }); + return { data: response.data, total: response.total }; + } + + return { query }; +} diff --git a/packages/client/src/resources/segment.test.ts b/packages/client/src/resources/segment.test.ts new file mode 100644 index 0000000..eb41554 --- /dev/null +++ b/packages/client/src/resources/segment.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const SEGMENT = { + id: 4, + name: "Returning customers", + description: null, + archived: false, + table_id: 2, + definition: { filter: ["=", ["field", 11, null], true] }, + creator_id: 1, + entity_id: "bbbbbbbbbbbbbbbbbbbbb", + show_in_getting_started: false, + caveats: null, + points_of_interest: null, + created_at: "2026-01-02T03:04:05.678Z", + updated_at: "2026-01-02T03:04:05.678Z", +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("segment resource wire requests", () => { + it("sends the list request without a query string", async () => { + const { mb, capture } = clientOver([jsonResponse([SEGMENT])]); + + await mb.segment.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/segment", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("wraps the bare list array in a ListResult that claims no server count", async () => { + const { mb } = clientOver([jsonResponse([SEGMENT])]); + + expect(await mb.segment.list()).toEqual({ data: [SEGMENT], total: null }); + }); + + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(SEGMENT)]); + + await mb.segment.get(4); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/segment/4", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the body as JSON", async () => { + const { mb, capture } = clientOver([jsonResponse(SEGMENT)]); + + await mb.segment.create({ + name: "Returning customers", + table_id: 2, + definition: { filter: ["=", ["field", 11, null], true] }, + }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/segment", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Returning customers","table_id":2,"definition":{"filter":["=",["field",11,null],true]}}', + }, + ]); + }); + + it("sends the update request as a PUT carrying only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse(SEGMENT)]); + + await mb.segment.update(4, { name: "Renamed", revision_message: "rename" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/segment/4", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Renamed","revision_message":"rename"}', + }, + ]); + }); + + it("sends the archive request as the same PUT with archived and the revision message", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...SEGMENT, archived: true })]); + + await mb.segment.archive(4, { revision_message: "deprecated" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/segment/4", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"archived":true,"revision_message":"deprecated"}', + }, + ]); + }); +}); diff --git a/packages/client/src/resources/segment.ts b/packages/client/src/resources/segment.ts new file mode 100644 index 0000000..109f2ed --- /dev/null +++ b/packages/client/src/resources/segment.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +import { Segment, type SegmentCreateInput, type SegmentUpdateInput } from "../domain/segment"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// `GET /api/segment` answers a bare array rather than a `{ data, total }` envelope, so the count a +// caller reads off `ListResult` is the array's own length and the server reports none. +const SegmentApiList = z.array(Segment); + +export interface SegmentArchiveParams { + revision_message: string; +} + +// Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. +export function segmentResource(transport: Transport) { + /** List every segment the caller can see. */ + async function list(options: RequestOptions = {}): Promise> { + const data = await transport.requestParsed(SegmentApiList, "/api/segment", { ...options }); + return { data, total: null }; + } + + /** Get one segment by id. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(Segment, `/api/segment/${id}`, { ...options }); + } + + /** Create a segment — a reusable row filter over one table — from a full segment body. */ + async function create( + params: SegmentCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Segment, "/api/segment", { + ...options, + method: "POST", + body: params, + }); + } + + /** + * Update a segment by id, patching only the fields the body carries. `revision_message` is + * required: Metabase records every segment change in its revision history. + */ + async function update( + id: number, + params: SegmentUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Segment, `/api/segment/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** + * Archive (soft-delete) a segment by id. Metabase models this as an update, so it carries the + * same required `revision_message`. + */ + async function archive( + id: number, + params: SegmentArchiveParams, + options: RequestOptions = {}, + ): Promise { + return update(id, { archived: true, revision_message: params.revision_message }, options); + } + + return { list, get, create, update, archive }; +} diff --git a/packages/client/src/resources/setting.test.ts b/packages/client/src/resources/setting.test.ts new file mode 100644 index 0000000..a37cecd --- /dev/null +++ b/packages/client/src/resources/setting.test.ts @@ -0,0 +1,156 @@ +import { assert, describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import { ConfigError } from "../errors"; +import { HttpError } from "../http/errors"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const SETTING = { + key: "site-name", + value: "Acme Analytics", + is_env_setting: false, + env_name: "MB_SITE_NAME", + description: "The name used for this instance of Metabase.", + default: "Metabase", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const BINARY_READ_HEADERS = { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const BINARY_WRITE_HEADERS = { + accept: "*/*", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +// Metabase rejects an unknown key with a 400 that names it as a Clojure keyword. +const UNKNOWN_SETTING_RESPONSE = { message: "Unknown setting: :totally-bogus" }; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +async function thrownBy(run: () => Promise): Promise { + try { + await run(); + } catch (error: unknown) { + return error; + } + throw new Error("expected the call to reject"); +} + +describe("setting resource wire requests", () => { + it("sends the list request", async () => { + const { mb, capture } = clientOver([jsonResponse([SETTING])]); + + await mb.setting.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/setting", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("percent-encodes the key into the get path", async () => { + const { mb, capture } = clientOver([jsonResponse("main")]); + + await mb.setting.get("branch/name with space"); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/setting/branch%2Fname%20with%20space", + method: "GET", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reads an unset setting's 204 as a null value", async () => { + const { mb } = clientOver([new Response(null, { status: 204 })]); + + expect(await mb.setting.get("remote-sync-branch")).toBeNull(); + }); + + it("percent-encodes the key into the set path and wraps the value in the body", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 204 })]); + + await mb.setting.set("branch/name with space", "main"); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/setting/branch%2Fname%20with%20space", + method: "PUT", + headers: BINARY_WRITE_HEADERS, + body: '{"value":"main"}', + }, + ]); + }); + + it("rewrites a get against an unknown key to name the caller's key without the keyword colon", async () => { + const { mb } = clientOver([jsonResponse(UNKNOWN_SETTING_RESPONSE, 400)]); + + const error = await thrownBy(() => mb.setting.get("totally-bogus")); + + expect(error).toBeInstanceOf(ConfigError); + assert(error instanceof ConfigError, "expected ConfigError"); + expect(error.message).toBe("unknown setting: totally-bogus"); + }); + + it("rewrites a set against an unknown key the same way", async () => { + const { mb } = clientOver([jsonResponse(UNKNOWN_SETTING_RESPONSE, 400)]); + + const error = await thrownBy(() => mb.setting.set("totally-bogus", "main")); + + expect(error).toBeInstanceOf(ConfigError); + assert(error instanceof ConfigError, "expected ConfigError"); + expect(error.message).toBe("unknown setting: totally-bogus"); + }); + + it("rethrows a get failure that is not an unknown-setting answer unchanged", async () => { + const { mb } = clientOver([jsonResponse({ message: "boom" }, 500)]); + + const error = await thrownBy(() => mb.setting.get("site-name", { retries: 0 })); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.message).toBe("boom"); + expect(error.status).toBe(500); + }); + + it("rethrows a set failure that is not an unknown-setting answer unchanged", async () => { + const { mb } = clientOver([jsonResponse({ message: "boom" }, 500)]); + + const error = await thrownBy(() => mb.setting.set("site-name", "Acme", { retries: 0 })); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.message).toBe("boom"); + expect(error.status).toBe(500); + }); +}); diff --git a/packages/client/src/resources/setting.ts b/packages/client/src/resources/setting.ts new file mode 100644 index 0000000..4d5605c --- /dev/null +++ b/packages/client/src/resources/setting.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +import { Setting } from "../domain/setting"; +import { ConfigError, errorMessage } from "../errors"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; +import { fetchOptionalParsed } from "./optional-parsed"; + +// `GET /api/setting` answers a bare array rather than a `{ data, total }` envelope, so the count a +// caller reads off `ListResult` is the array's own length and the server reports none. +const SettingApiList = z.array(Setting); + +const UNKNOWN_SETTING_PREFIX = "Unknown setting:"; + +// Metabase rejects an unknown key with a 400 that echoes it back as a Clojure keyword (":foo"). +// Surface the caller's own key instead of the leading colon, and classify it as bad input. +function rethrowSettingError(error: unknown, key: string): never { + if (errorMessage(error).startsWith(UNKNOWN_SETTING_PREFIX)) { + throw new ConfigError(`unknown setting: ${key}`); + } + throw error; +} + +export function settingResource(transport: Transport) { + /** Get all settings and their values, with descriptions and environment-variable names. */ + async function list(options: RequestOptions = {}): Promise> { + const data = await transport.requestParsed(SettingApiList, "/api/setting", { ...options }); + return { data, total: null }; + } + + /** + * Fetch a single setting's value by key. An unset setting answers 204 rather than 404, which + * reaches the caller as `null`; older servers answer the value as bare text rather than JSON. + */ + async function get(key: string, options: RequestOptions = {}): Promise { + const path = `/api/setting/${encodeURIComponent(key)}`; + return fetchOptionalParsed(transport, path, z.unknown(), options).catch((error: unknown) => + rethrowSettingError(error, key), + ); + } + + /** Set a single setting's value by key. The endpoint answers 204 with no body. */ + async function set(key: string, value: unknown, options: RequestOptions = {}): Promise { + await transport + .requestRaw(`/api/setting/${encodeURIComponent(key)}`, { + ...options, + method: "PUT", + body: { value }, + expectContentType: "binary", + }) + .catch((error: unknown) => rethrowSettingError(error, key)); + } + + return { list, get, set }; +} diff --git a/packages/client/src/resources/setup.test.ts b/packages/client/src/resources/setup.test.ts new file mode 100644 index 0000000..b57d944 --- /dev/null +++ b/packages/client/src/resources/setup.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { SetupInput } from "../domain/setup"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +// Setup runs before any credential exists, so the wizard's own token is the only thing that +// authorizes it and the transport still sends the profile's (here absent) API key header. +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const SETUP_INPUT: SetupInput = { + token: "3f2b9a1c-0000-4000-8000-abcdefabcdef", + user: { + first_name: "Ada", + last_name: "Lovelace", + email: "admin@example.com", + password: "correct horse battery staple", + }, + prefs: { site_name: "Acme Analytics", site_locale: "en" }, +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("setup resource wire requests", () => { + it("posts the wizard body verbatim", async () => { + const { mb, capture } = clientOver([jsonResponse({ id: "session-id" })]); + + await mb.setup.create(SETUP_INPUT); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/setup", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: JSON.stringify(SETUP_INPUT), + }, + ]); + }); + + it("returns the parsed setup result", async () => { + const { mb } = clientOver([jsonResponse({ id: "session-id" })]); + + expect(await mb.setup.create(SETUP_INPUT)).toEqual({ id: "session-id" }); + }); +}); diff --git a/packages/client/src/resources/setup.ts b/packages/client/src/resources/setup.ts new file mode 100644 index 0000000..b3e941b --- /dev/null +++ b/packages/client/src/resources/setup.ts @@ -0,0 +1,18 @@ +import { type SetupInput, SetupResult } from "../domain/setup"; +import type { RequestOptions, Transport } from "../http/transport"; + +export function setupResource(transport: Transport) { + /** + * Complete the initial setup wizard on a fresh instance, creating its first admin user and + * applying the instance preferences. The setup token is single-use. + */ + async function create(params: SetupInput, options: RequestOptions = {}): Promise { + return transport.requestParsed(SetupResult, "/api/setup", { + ...options, + method: "POST", + body: params, + }); + } + + return { create }; +} diff --git a/packages/client/src/resources/snippet.test.ts b/packages/client/src/resources/snippet.test.ts new file mode 100644 index 0000000..d923256 --- /dev/null +++ b/packages/client/src/resources/snippet.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const SNIPPET = { + id: 4, + name: "active", + description: null, + content: "WHERE active = true", + archived: false, + collection_id: null, + creator_id: 1, + entity_id: "aaaaaaaaaaaaaaaaaaaaa", + template_tags: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("snippet resource wire requests", () => { + it("sends the list request with the archived filter", async () => { + const { mb, capture } = clientOver([jsonResponse([{ ...SNIPPET, archived: true }])]); + + await mb.snippet.list({ archived: true }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/native-query-snippet?archived=true", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("omits an unset archived filter from the query string", async () => { + const { mb, capture } = clientOver([jsonResponse([SNIPPET])]); + + await mb.snippet.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/native-query-snippet", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("wraps the bare list array in a ListResult that claims no server count", async () => { + const { mb } = clientOver([jsonResponse([SNIPPET])]); + + expect(await mb.snippet.list()).toEqual({ data: [SNIPPET], total: null }); + }); + + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(SNIPPET)]); + + await mb.snippet.get(4); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/native-query-snippet/4", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the body as JSON", async () => { + const { mb, capture } = clientOver([jsonResponse(SNIPPET)]); + + await mb.snippet.create({ name: "active", content: "WHERE active = true" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/native-query-snippet", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"active","content":"WHERE active = true"}', + }, + ]); + }); + + it("sends the update request as a PUT carrying only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...SNIPPET, name: "renamed" })]); + + await mb.snippet.update(4, { name: "renamed" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/native-query-snippet/4", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"renamed"}', + }, + ]); + }); + + it("sends the archive request as the same PUT with archived set", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...SNIPPET, archived: true })]); + + await mb.snippet.archive(4); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/native-query-snippet/4", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"archived":true}', + }, + ]); + }); +}); diff --git a/packages/client/src/resources/snippet.ts b/packages/client/src/resources/snippet.ts new file mode 100644 index 0000000..4414e75 --- /dev/null +++ b/packages/client/src/resources/snippet.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import { Snippet, type SnippetCreateInput, type SnippetUpdateInput } from "../domain/snippet"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// `GET /api/native-query-snippet` answers a bare array rather than a `{ data, total }` envelope, so +// the count a caller reads off `ListResult` is the array's own length and the server reports none. +const SnippetApiList = z.array(Snippet); + +export interface SnippetListParams { + archived?: boolean | undefined; +} + +export function snippetResource(transport: Transport) { + /** List native query snippets. `archived` swaps the listing to archived snippets. */ + async function list( + params: SnippetListParams = {}, + options: RequestOptions = {}, + ): Promise> { + const data = await transport.requestParsed(SnippetApiList, "/api/native-query-snippet", { + ...options, + query: { archived: params.archived }, + }); + return { data, total: null }; + } + + /** Get one native query snippet by id. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(Snippet, `/api/native-query-snippet/${id}`, { ...options }); + } + + /** Create a native query snippet from a name, its SQL fragment, and an optional collection. */ + async function create( + params: SnippetCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Snippet, "/api/native-query-snippet", { + ...options, + method: "POST", + body: params, + }); + } + + /** Update a native query snippet by id, patching only the fields the body carries. */ + async function update( + id: number, + params: SnippetUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Snippet, `/api/native-query-snippet/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** + * Archive (soft-delete) a native query snippet by id. Metabase models this as an update, not its + * own endpoint. + */ + async function archive(id: number, options: RequestOptions = {}): Promise { + return update(id, { archived: true }, options); + } + + return { list, get, create, update, archive }; +} diff --git a/packages/client/src/resources/table.test.ts b/packages/client/src/resources/table.test.ts new file mode 100644 index 0000000..8df9a0e --- /dev/null +++ b/packages/client/src/resources/table.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +import type { CsvFile } from "./csv-upload"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const FIELD = { + id: 101, + table_id: 11, + name: "total", + display_name: "Total", + description: null, + base_type: "type/Float", + semantic_type: null, + fk_target_field_id: null, +}; + +const TABLE = { + id: 11, + name: "orders", + display_name: "Orders", + description: null, + db_id: 1, + schema: "public", + entity_type: "entity/TransactionTable", +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const BINARY_READ_HEADERS = { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const CSV_TEXT = "id,total\n1,9\n"; + +const CSV_FILE: CsvFile = { + filename: "rows.csv", + bytes: new TextEncoder().encode(CSV_TEXT), +}; + +const CSV_FORM_BODY = { + parts: [{ name: "file", value: CSV_TEXT, filename: "rows.csv", contentType: "text/csv" }], +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("table resource wire requests", () => { + it("sends the list request with no query parameters", async () => { + const { mb, capture } = clientOver([jsonResponse([TABLE])]); + + await mb.table.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/table", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reports no total for the bare-array listing, which the server does not count", async () => { + const { mb } = clientOver([jsonResponse([TABLE])]); + + expect(await mb.table.list()).toEqual({ data: [TABLE], total: null }); + }); + + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(TABLE)]); + + await mb.table.get(11); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/table/11", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the update request as a PUT carrying only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...TABLE, display_name: "Customers" })]); + + await mb.table.update(11, { display_name: "Customers" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/table/11", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"display_name":"Customers"}', + }, + ]); + }); + + it("sends the query-metadata request", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...TABLE, fields: [FIELD] })]); + + await mb.table.queryMetadata(11); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/table/11/query_metadata", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("posts the append-csv request with the file as multipart form data", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 200 })]); + + await mb.table.appendCsv(11, CSV_FILE); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/table/11/append-csv", + method: "POST", + headers: BINARY_READ_HEADERS, + body: CSV_FORM_BODY, + }, + ]); + }); + + it("posts the replace-csv request with the file as multipart form data", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 200 })]); + + await mb.table.replaceCsv(11, CSV_FILE); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/table/11/replace-csv", + method: "POST", + headers: BINARY_READ_HEADERS, + body: CSV_FORM_BODY, + }, + ]); + }); + + it("confirms an append against the table it was asked for", async () => { + const { mb } = clientOver([new Response(null, { status: 200 })]); + + expect(await mb.table.appendCsv(11, CSV_FILE)).toEqual({ table_id: 11, action: "append" }); + }); + + it("confirms a replace against the table it was asked for", async () => { + const { mb } = clientOver([new Response(null, { status: 200 })]); + + expect(await mb.table.replaceCsv(11, CSV_FILE)).toEqual({ table_id: 11, action: "replace" }); + }); +}); diff --git a/packages/client/src/resources/table.ts b/packages/client/src/resources/table.ts new file mode 100644 index 0000000..78a68fc --- /dev/null +++ b/packages/client/src/resources/table.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; + +import { Table, TableQueryMetadata, type TableUpdateInput } from "../domain/table"; +import type { UploadUpdateAction, UploadUpdateResult } from "../domain/upload"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +import { buildCsvFormData, type CsvFile } from "./csv-upload"; + +// `GET /api/table` answers a bare array rather than a `{ data, total }` envelope, so the count a +// caller reads off `ListResult` is the array's own length and the server reports none. +const TableApiList = z.array(Table); + +const UPLOAD_UPDATE_PATHS: Record = { + append: "append-csv", + replace: "replace-csv", +}; + +export function tableResource(transport: Transport) { + /** List every table the caller can see, across all databases. */ + async function list(options: RequestOptions = {}): Promise> { + const data = await transport.requestParsed(TableApiList, "/api/table", { ...options }); + return { data, total: null }; + } + + /** Get one table by id, without its fields. */ + async function get(id: number, options: RequestOptions = {}): Promise
{ + return transport.requestParsed(Table, `/api/table/${id}`, { ...options }); + } + + /** Update a table by id, patching only the fields the body carries. */ + async function update( + id: number, + params: TableUpdateInput, + options: RequestOptions = {}, + ): Promise
{ + return transport.requestParsed(Table, `/api/table/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** Get a table by id with its fields hydrated — the metadata the query builder runs on. */ + async function queryMetadata( + id: number, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(TableQueryMetadata, `/api/table/${id}/query_metadata`, { + ...options, + }); + } + + async function updateFromCsv( + id: number, + action: UploadUpdateAction, + file: CsvFile, + options: RequestOptions, + ): Promise { + await transport.requestRaw(`/api/table/${id}/${UPLOAD_UPDATE_PATHS[action]}`, { + ...options, + method: "POST", + body: buildCsvFormData(file), + expectContentType: "binary", + }); + return { table_id: id, action }; + } + + /** + * Inserts the rows of an uploaded CSV file into the table identified by `id`. The table must have + * been created by uploading a CSV file. + * + * The file may be at most 50 MB; larger uploads are rejected with a 413 response. + */ + async function appendCsv( + id: number, + file: CsvFile, + options: RequestOptions = {}, + ): Promise { + return updateFromCsv(id, "append", file, options); + } + + /** + * Replaces the contents of the table identified by `id` with the rows of an uploaded CSV file. + * The table must have been created by uploading a CSV file. + * + * The file may be at most 50 MB; larger uploads are rejected with a 413 response. + */ + async function replaceCsv( + id: number, + file: CsvFile, + options: RequestOptions = {}, + ): Promise { + return updateFromCsv(id, "replace", file, options); + } + + return { list, get, update, queryMetadata, appendCsv, replaceCsv }; +} diff --git a/packages/client/src/resources/timeline-event.test.ts b/packages/client/src/resources/timeline-event.test.ts new file mode 100644 index 0000000..cbb4607 --- /dev/null +++ b/packages/client/src/resources/timeline-event.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const EVENT = { + id: 11, + name: "v2 launch", + description: null, + timestamp: "2026-07-01T00:00:00Z", + timezone: "UTC", + time_matters: false, + icon: "star", + timeline_id: 3, + archived: false, + creator_id: 1, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const BINARY_READ_HEADERS = { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("timeline-event resource wire requests", () => { + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(EVENT)]); + + await mb.timelineEvent.get(11); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/timeline-event/11", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the body as JSON", async () => { + const { mb, capture } = clientOver([jsonResponse(EVENT)]); + + await mb.timelineEvent.create({ + name: "v2 launch", + timestamp: "2026-07-01T00:00:00Z", + timezone: "UTC", + time_matters: false, + timeline_id: 3, + }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/timeline-event", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"v2 launch","timestamp":"2026-07-01T00:00:00Z","timezone":"UTC","time_matters":false,"timeline_id":3}', + }, + ]); + }); + + it("sends the update request as a PUT carrying only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse(EVENT)]); + + await mb.timelineEvent.update(11, { name: "v2.1 launch" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/timeline-event/11", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"v2.1 launch"}', + }, + ]); + }); + + it("sends the archive request as the same PUT with archived set", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...EVENT, archived: true })]); + + await mb.timelineEvent.archive(11); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/timeline-event/11", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"archived":true}', + }, + ]); + }); + + // `archive` writes a patch it did not receive, so the value it hands back has to be the server's + // answer rather than the body it sent or the event as it stood before the write. + it("returns the archived event the server answered with", async () => { + const { mb } = clientOver([jsonResponse({ ...EVENT, archived: true })]); + + expect(await mb.timelineEvent.archive(11)).toEqual({ ...EVENT, archived: true }); + }); + + it("sends the delete request as a bodiless DELETE accepting any content type", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 204 })]); + + await mb.timelineEvent.delete(11); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/timeline-event/11", + method: "DELETE", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); +}); diff --git a/packages/client/src/resources/timeline-event.ts b/packages/client/src/resources/timeline-event.ts new file mode 100644 index 0000000..739362c --- /dev/null +++ b/packages/client/src/resources/timeline-event.ts @@ -0,0 +1,60 @@ +import { + TimelineEvent, + type TimelineEventCreateInput, + type TimelineEventUpdateInput, +} from "../domain/timeline"; +import type { RequestOptions, Transport } from "../http/transport"; + +export function timelineEventResource(transport: Transport) { + /** Get one timeline event by id. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(TimelineEvent, `/api/timeline-event/${id}`, { ...options }); + } + + /** Create a timeline event on an existing timeline. */ + async function create( + params: TimelineEventCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(TimelineEvent, "/api/timeline-event", { + ...options, + method: "POST", + body: params, + }); + } + + /** + * Update a timeline event by id, patching only the fields the body carries. `timeline_id` moves + * the event to another timeline. + */ + async function update( + id: number, + params: TimelineEventUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(TimelineEvent, `/api/timeline-event/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** + * Archive (soft-delete) a timeline event by id. Metabase models this as an update, not its own + * endpoint. + */ + async function archive(id: number, options: RequestOptions = {}): Promise { + return update(id, { archived: true }, options); + } + + /** Permanently delete a timeline event by id. The server answers with no body. */ + async function remove(id: number, options: RequestOptions = {}): Promise { + await transport.requestRaw(`/api/timeline-event/${id}`, { + ...options, + method: "DELETE", + expectContentType: "binary", + }); + } + + return { get, create, update, archive, delete: remove }; +} diff --git a/packages/client/src/resources/timeline.test.ts b/packages/client/src/resources/timeline.test.ts new file mode 100644 index 0000000..14c3737 --- /dev/null +++ b/packages/client/src/resources/timeline.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const TIMELINE = { + id: 3, + name: "Releases", + description: null, + icon: "star", + collection_id: null, + archived: false, + default: false, + creator_id: 1, + entity_id: "aaaaaaaaaaaaaaaaaaaaa", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +const EVENT = { + id: 11, + name: "v2 launch", + description: null, + timestamp: "2026-07-01T00:00:00Z", + timezone: "UTC", + time_matters: false, + icon: "star", + timeline_id: 3, + archived: false, + creator_id: 1, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const BINARY_READ_HEADERS = { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("timeline resource wire requests", () => { + it("sends the list request with the archived parameter", async () => { + const { mb, capture } = clientOver([jsonResponse([TIMELINE])]); + + await mb.timeline.list({ archived: true }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/timeline?archived=true", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("omits an unset archived parameter from the query string", async () => { + const { mb, capture } = clientOver([jsonResponse([TIMELINE])]); + + await mb.timeline.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/timeline", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reports no server count for the bare-array listing", async () => { + const { mb } = clientOver([jsonResponse([TIMELINE])]); + + const result = await mb.timeline.list(); + + expect(result).toEqual({ data: [TIMELINE], total: null }); + }); + + it("sends the get request without asking for events", async () => { + const { mb, capture } = clientOver([jsonResponse(TIMELINE)]); + + await mb.timeline.get(3); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/timeline/3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the events request as the timeline read that hydrates them", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...TIMELINE, events: [EVENT] })]); + + await mb.timeline.events(3, { archived: true }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/timeline/3?include=events&archived=true", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("returns the hydrated events rather than the timeline that carried them", async () => { + const { mb } = clientOver([jsonResponse({ ...TIMELINE, events: [EVENT] })]); + + const result = await mb.timeline.events(3); + + expect(result).toEqual({ data: [EVENT], total: null }); + }); + + it("sends the create request with the body as JSON", async () => { + const { mb, capture } = clientOver([jsonResponse(TIMELINE)]); + + await mb.timeline.create({ name: "Releases" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/timeline", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Releases"}', + }, + ]); + }); + + it("sends the update request as a PUT carrying only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse(TIMELINE)]); + + await mb.timeline.update(3, { name: "Product releases" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/timeline/3", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"Product releases"}', + }, + ]); + }); + + it("sends the archive request as the same PUT with archived set", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...TIMELINE, archived: true })]); + + await mb.timeline.archive(3); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/timeline/3", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"archived":true}', + }, + ]); + }); + + it("sends the delete request as a bodiless DELETE accepting any content type", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 204 })]); + + await mb.timeline.delete(3); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/timeline/3", + method: "DELETE", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); +}); diff --git a/packages/client/src/resources/timeline.ts b/packages/client/src/resources/timeline.ts new file mode 100644 index 0000000..4458b19 --- /dev/null +++ b/packages/client/src/resources/timeline.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; + +import { + Timeline, + type TimelineCreateInput, + type TimelineEvent, + type TimelineUpdateInput, +} from "../domain/timeline"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// `GET /api/timeline` answers a bare array rather than a `{ data, total }` envelope, so the count a +// caller reads off `ListResult` is the array's own length and the server reports none. +const TimelineApiList = z.array(Timeline); + +// `include=events` is what fills the `events` field the bare timeline leaves optional, so the +// hydrated read parses against a schema that requires it rather than trusting the query string. +const TimelineWithEvents = Timeline.required({ events: true }); + +export interface TimelineListParams { + archived?: boolean | undefined; +} + +export interface TimelineEventsParams { + archived?: boolean | undefined; +} + +export function timelineResource(transport: Transport) { + /** List timelines. `archived` swaps the active listing for the archived one. */ + async function list( + params: TimelineListParams = {}, + options: RequestOptions = {}, + ): Promise> { + const data = await transport.requestParsed(TimelineApiList, "/api/timeline", { + ...options, + query: { archived: params.archived }, + }); + return { data, total: null }; + } + + /** Get one timeline by id, without its events. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(Timeline, `/api/timeline/${id}`, { ...options }); + } + + /** + * List the events on one timeline. Metabase has no endpoint of its own for these: the same + * timeline read hydrates them on request, and `archived` chooses which of them come back. + */ + async function events( + id: number, + params: TimelineEventsParams = {}, + options: RequestOptions = {}, + ): Promise> { + const timeline = await transport.requestParsed(TimelineWithEvents, `/api/timeline/${id}`, { + ...options, + query: { include: "events", archived: params.archived }, + }); + return { data: timeline.events, total: null }; + } + + /** Create a timeline from a full timeline body. */ + async function create( + params: TimelineCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Timeline, "/api/timeline", { + ...options, + method: "POST", + body: params, + }); + } + + /** + * Update a timeline by id, patching only the fields the body carries. Changing `archived` + * cascades to every event on the timeline. + */ + async function update( + id: number, + params: TimelineUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Timeline, `/api/timeline/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** + * Archive (soft-delete) a timeline and all its events by id. Metabase models this as an update, + * not its own endpoint. + */ + async function archive(id: number, options: RequestOptions = {}): Promise { + return update(id, { archived: true }, options); + } + + /** Permanently delete a timeline and all its events by id. The server answers with no body. */ + async function remove(id: number, options: RequestOptions = {}): Promise { + await transport.requestRaw(`/api/timeline/${id}`, { + ...options, + method: "DELETE", + expectContentType: "binary", + }); + } + + return { list, get, events, create, update, archive, delete: remove }; +} diff --git a/packages/client/src/resources/transform-job.test.ts b/packages/client/src/resources/transform-job.test.ts new file mode 100644 index 0000000..2f2195d --- /dev/null +++ b/packages/client/src/resources/transform-job.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { + captureFetch, + type FetchScript, + jsonResponse, + TEST_USER_AGENT, +} from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const SCHEDULE = "0 0 0 * * ?"; + +const JOB = { + id: 3, + name: "Nightly", + description: null, + schedule: SCHEDULE, + ui_display_type: "cron/raw", + entity_id: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +const TRANSFORM = { + id: 7, + name: "Daily orders", + description: null, + source: { type: "query", query: { database: 1, type: "native", native: { query: "select 1" } } }, + target: { type: "table", database: 1, schema: "public", name: "daily_orders" }, + source_type: "native", + entity_id: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + creator_id: 1, + collection_id: null, +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const BINARY_READ_HEADERS = { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: FetchScript) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("transform-job resource wire requests", () => { + it("sends the list request", async () => { + const { mb, capture } = clientOver([jsonResponse([JOB])]); + + await mb.transformJob.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform-job", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reports no total for the listing, which the server does not count", async () => { + const { mb } = clientOver([jsonResponse([JOB])]); + + expect(await mb.transformJob.list()).toEqual({ data: [JOB], total: null }); + }); + + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(JOB)]); + + await mb.transformJob.get(3); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform-job/3", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the body it was given", async () => { + const { mb, capture } = clientOver([jsonResponse(JOB)]); + + await mb.transformJob.create({ name: JOB.name, schedule: SCHEDULE }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform-job", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: JSON.stringify({ name: JOB.name, schedule: SCHEDULE }), + }, + ]); + }); + + it("sends the update request as a PUT carrying only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...JOB, name: "renamed" })]); + + await mb.transformJob.update(3, { name: "renamed" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform-job/3", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"renamed"}', + }, + ]); + }); + + it("sends the delete request as a bodiless DELETE accepting any content type", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 204 })]); + + await mb.transformJob.delete(3); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform-job/3", + method: "DELETE", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the run request as a POST carrying the run_all flag", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ message: "Job run started", job_run_id: 11 }), + ]); + + await mb.transformJob.run(3, { run_all: true }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform-job/3/run", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"run_all":true}', + }, + ]); + }); + + it("reads the numeric run id head answers", async () => { + const { mb } = clientOver([jsonResponse({ message: "Job run started", job_run_id: 11 })]); + + expect(await mb.transformJob.run(3)).toEqual({ message: "Job run started", job_run_id: 11 }); + }); + + it("reads the opaque stub id released servers answer", async () => { + const { mb } = clientOver([ + jsonResponse({ message: "Job run started", job_run_id: "job-3-1767225600000" }), + ]); + + expect(await mb.transformJob.run(3)).toEqual({ + message: "Job run started", + job_run_id: "job-3-1767225600000", + }); + }); + + it("reads the null run id a server answers when it started nothing", async () => { + const { mb } = clientOver([jsonResponse({ message: "Job run started", job_run_id: null })]); + + expect(await mb.transformJob.run(3)).toEqual({ message: "Job run started", job_run_id: null }); + }); + + it("sends the transforms request against the job id", async () => { + const { mb, capture } = clientOver([jsonResponse([TRANSFORM])]); + + expect(await mb.transformJob.transforms(3)).toEqual({ data: [TRANSFORM], total: null }); + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform-job/3/transforms", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends set-active against the idless active path, not the job path", async () => { + const { mb, capture } = clientOver([jsonResponse({ updated: 2, failed: 0 })]); + + expect(await mb.transformJob.setActive(false)).toEqual({ updated: 2, failed: 0 }); + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform-job/active", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"active":false}', + }, + ]); + }); +}); diff --git a/packages/client/src/resources/transform-job.ts b/packages/client/src/resources/transform-job.ts new file mode 100644 index 0000000..42a6fd3 --- /dev/null +++ b/packages/client/src/resources/transform-job.ts @@ -0,0 +1,122 @@ +import { z } from "zod"; + +import { Transform } from "../domain/transform"; +import { + TransformJob, + TransformJobActiveResult, + type TransformJobCreateInput, + TransformJobRunResult, + type TransformJobUpdateInput, +} from "../domain/transform-job"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// `GET /api/transform-job` answers a bare array rather than a `{ data, total }` envelope, so the +// count a caller reads off `ListResult` is the array's own length and the server reports none. +const TransformJobApiList = z.array(TransformJob); + +// `GET /api/transform-job/{id}/transforms` answers a bare array of transforms. `resources/transform` +// declares the same shape for its own listings; the two files may not reach into each other, and a +// module both could import would be the bucket module the layout rules forbid. +const TransformApiList = z.array(Transform); + +export interface TransformJobRunParams { + /** Re-run the whole plan, including dependencies that are already fresh. */ + run_all?: boolean | undefined; +} + +// Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. +export function transformJobResource(transport: Transport) { + /** List every transform job the caller can see. */ + async function list(options: RequestOptions = {}): Promise> { + const data = await transport.requestParsed(TransformJobApiList, "/api/transform-job", { + ...options, + }); + return { data, total: null }; + } + + /** Get one transform job by id. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(TransformJob, `/api/transform-job/${id}`, { ...options }); + } + + /** Create a transform job — a schedule plus the tags it runs — from a full body. */ + async function create( + params: TransformJobCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(TransformJob, "/api/transform-job", { + ...options, + method: "POST", + body: params, + }); + } + + /** Update a transform job by id, patching only the fields the body carries. */ + async function update( + id: number, + params: TransformJobUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(TransformJob, `/api/transform-job/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** Delete a transform job by id, leaving the transforms it ran untouched. */ + async function remove(id: number, options: RequestOptions = {}): Promise { + await transport.requestRaw(`/api/transform-job/${id}`, { + ...options, + method: "DELETE", + expectContentType: "binary", + }); + } + + /** + * Start a run of a transform job by id. The job runs every transform carrying one of its tags, + * plus those transforms' dependencies; `run_all` re-runs dependencies that are already fresh. + */ + async function run( + id: number, + params: TransformJobRunParams = {}, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(TransformJobRunResult, `/api/transform-job/${id}/run`, { + ...options, + method: "POST", + body: { run_all: params.run_all }, + }); + } + + /** List the transforms a job would execute, resolved from the job's tags. */ + async function transforms( + id: number, + options: RequestOptions = {}, + ): Promise> { + const data = await transport.requestParsed( + TransformApiList, + `/api/transform-job/${id}/transforms`, + { ...options }, + ); + return { data, total: null }; + } + + /** + * Flip the active flag on every transform job at once. Inactive jobs do not run on schedule; + * manual runs ignore the flag. The path carries no id and is a different endpoint from `update`. + */ + async function setActive( + active: boolean, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(TransformJobActiveResult, "/api/transform-job/active", { + ...options, + method: "PUT", + body: { active }, + }); + } + + return { list, get, create, update, delete: remove, run, transforms, setActive }; +} diff --git a/packages/client/src/resources/transform-tag.test.ts b/packages/client/src/resources/transform-tag.test.ts new file mode 100644 index 0000000..e5e74ad --- /dev/null +++ b/packages/client/src/resources/transform-tag.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { + captureFetch, + type FetchScript, + jsonResponse, + TEST_USER_AGENT, +} from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const TAG = { + id: 5, + name: "nightly", + entity_id: null, + built_in_type: null, +}; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const BINARY_READ_HEADERS = { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: FetchScript) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("transform-tag resource wire requests", () => { + it("sends the list request", async () => { + const { mb, capture } = clientOver([jsonResponse([TAG])]); + + await mb.transformTag.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform-tag", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reports no total for the listing, which the server does not count", async () => { + const { mb } = clientOver([jsonResponse([TAG])]); + + expect(await mb.transformTag.list()).toEqual({ data: [TAG], total: null }); + }); + + it("sends the create request with the body it was given", async () => { + const { mb, capture } = clientOver([jsonResponse(TAG)]); + + await mb.transformTag.create({ name: TAG.name }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform-tag", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"nightly"}', + }, + ]); + }); + + it("sends the update request as a PUT carrying the new name", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...TAG, name: "renamed" })]); + + await mb.transformTag.update(5, { name: "renamed" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform-tag/5", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"renamed"}', + }, + ]); + }); + + it("sends the delete request as a bodiless DELETE accepting any content type", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 204 })]); + + await mb.transformTag.delete(5); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform-tag/5", + method: "DELETE", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); +}); diff --git a/packages/client/src/resources/transform-tag.ts b/packages/client/src/resources/transform-tag.ts new file mode 100644 index 0000000..f3cc64e --- /dev/null +++ b/packages/client/src/resources/transform-tag.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +import { + TransformTag, + type TransformTagCreateInput, + type TransformTagUpdateInput, +} from "../domain/transform-tag"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; + +// `GET /api/transform-tag` answers a bare array rather than a `{ data, total }` envelope, so the +// count a caller reads off `ListResult` is the array's own length and the server reports none. +const TransformTagApiList = z.array(TransformTag); + +// Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. +export function transformTagResource(transport: Transport) { + /** List every transform tag the caller can see, built-in tags included. */ + async function list(options: RequestOptions = {}): Promise> { + const data = await transport.requestParsed(TransformTagApiList, "/api/transform-tag", { + ...options, + }); + return { data, total: null }; + } + + /** Create a transform tag, the label a transform job matches transforms on. */ + async function create( + params: TransformTagCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(TransformTag, "/api/transform-tag", { + ...options, + method: "POST", + body: params, + }); + } + + /** Rename a transform tag by id. */ + async function update( + id: number, + params: TransformTagUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(TransformTag, `/api/transform-tag/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** Delete a transform tag by id, detaching it from every transform and job carrying it. */ + async function remove(id: number, options: RequestOptions = {}): Promise { + await transport.requestRaw(`/api/transform-tag/${id}`, { + ...options, + method: "DELETE", + expectContentType: "binary", + }); + } + + return { list, create, update, delete: remove }; +} diff --git a/packages/client/src/resources/transform.test.ts b/packages/client/src/resources/transform.test.ts new file mode 100644 index 0000000..fde759c --- /dev/null +++ b/packages/client/src/resources/transform.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { + captureFetch, + type FetchScript, + jsonResponse, + TEST_USER_AGENT, +} from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const SOURCE = { + type: "query", + query: { database: 1, type: "native", native: { query: "select 1" } }, +} as const; + +const TARGET = { type: "table", database: 1, schema: "public", name: "daily_orders" } as const; + +const TRANSFORM = { + id: 7, + name: "Daily orders", + description: null, + source: SOURCE, + target: TARGET, + source_type: "native", + entity_id: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + creator_id: 1, + collection_id: null, +}; + +const TRANSFORM_WITH_TARGET_TABLE_ID = { ...TRANSFORM, target_table_id: 42 }; + +const TRANSFORM_WITH_HYDRATED_TABLE = { ...TRANSFORM, table: { id: 42, name: "daily_orders" } }; + +const RUN = { + id: 31, + transform_id: 7, + run_method: "manual", + status: "started", + is_active: true, + start_time: "2026-01-01T00:00:00Z", + end_time: null, + message: null, + user_id: 1, +}; + +const SUCCEEDED_RUN = { + ...RUN, + status: "succeeded", + is_active: false, + end_time: "2026-01-01T00:01:00Z", +}; + +const FAILED_RUN = { + ...SUCCEEDED_RUN, + status: "failed", + message: "relation does not exist", +}; + +const KICKOFF = { message: "Transform run started", run_id: 31 }; + +const JSON_REQUEST_HEADERS = { + accept: "application/json", + "content-type": "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const BINARY_READ_HEADERS = { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +const IMMEDIATE_POLL = { intervalMs: 1, timeoutMs: 1_000 }; + +// Short enough that the table never registers within it, and paired with a script that keeps +// answering so the wait ends on the deadline rather than on an exhausted queue. +const EXPIRING_POLL = { intervalMs: 1, timeoutMs: 5 }; + +function clientOver(responses: FetchScript) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +function repeated(body: unknown, times: number): FetchScript { + return Array.from({ length: times }, () => () => jsonResponse(body)); +} + +describe("transform resource wire requests", () => { + it("sends the list request", async () => { + const { mb, capture } = clientOver([jsonResponse([TRANSFORM])]); + + await mb.transform.list(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reports no total for the listing, which the server does not count", async () => { + const { mb } = clientOver([jsonResponse([TRANSFORM])]); + + expect(await mb.transform.list()).toEqual({ data: [TRANSFORM], total: null }); + }); + + it("sends the get request", async () => { + const { mb, capture } = clientOver([jsonResponse(TRANSFORM)]); + + await mb.transform.get(7); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform/7", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the create request with the body it was given", async () => { + const { mb, capture } = clientOver([jsonResponse(TRANSFORM)]); + + await mb.transform.create({ name: TRANSFORM.name, source: SOURCE, target: TARGET }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform", + method: "POST", + headers: JSON_REQUEST_HEADERS, + body: JSON.stringify({ name: TRANSFORM.name, source: SOURCE, target: TARGET }), + }, + ]); + }); + + it("sends the update request as a PUT carrying only the patched fields", async () => { + const { mb, capture } = clientOver([jsonResponse({ ...TRANSFORM, name: "renamed" })]); + + await mb.transform.update(7, { name: "renamed" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform/7", + method: "PUT", + headers: JSON_REQUEST_HEADERS, + body: '{"name":"renamed"}', + }, + ]); + }); + + it("sends the delete request as a bodiless DELETE accepting any content type", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 204 })]); + + await mb.transform.delete(7); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform/7", + method: "DELETE", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the output-table drop as a DELETE on the transform's table", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 204 })]); + + await mb.transform.deleteTable(7); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform/7/table", + method: "DELETE", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the cancel request as a bodiless POST accepting any content type", async () => { + const { mb, capture } = clientOver([new Response(null, { status: 204 })]); + + await mb.transform.cancel(7); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform/7/cancel", + method: "POST", + headers: BINARY_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the dependencies request", async () => { + const { mb, capture } = clientOver([jsonResponse([TRANSFORM])]); + + expect(await mb.transform.dependencies(7)).toEqual({ data: [TRANSFORM], total: null }); + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform/7/dependencies", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the run read against the run id rather than the transform id", async () => { + const { mb, capture } = clientOver([jsonResponse(RUN)]); + + expect(await mb.transform.getRun(31)).toEqual(RUN); + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform/run/31", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("sends the run history request with the transform filter plus the paging window", async () => { + const { mb, capture } = clientOver([jsonResponse({ data: [RUN], total: 12 })]); + + const pages = mb.transform.runPages( + { "transform-ids": 7 }, + { offset: 10, max: 2, pageSize: 2 }, + ); + await pages[Symbol.asyncIterator]().next(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform/run?transform-ids=7&limit=2&offset=10", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("starts a run and returns without polling when no wait is given", async () => { + const { mb, capture } = clientOver([jsonResponse(KICKOFF)]); + + expect(await mb.transform.run(7)).toEqual({ + message: "Transform run started", + run_id: 31, + final: null, + }); + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform/7/run", + method: "POST", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("polls the run to a terminal status when a wait schedule is given", async () => { + const { mb, capture } = clientOver([ + jsonResponse(KICKOFF), + jsonResponse(RUN), + jsonResponse(SUCCEEDED_RUN), + ]); + + expect(await mb.transform.run(7, { wait: IMMEDIATE_POLL })).toEqual({ + message: "Transform run started", + run_id: 31, + final: SUCCEEDED_RUN, + }); + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform/7/run", + method: "POST", + headers: JSON_READ_HEADERS, + body: null, + }, + { + url: "https://mb.example.com/metabase/api/transform/run/31", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + { + url: "https://mb.example.com/metabase/api/transform/run/31", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("does not poll a run the server never started", async () => { + const { mb, capture } = clientOver([ + jsonResponse({ message: "Transform run started", run_id: null }), + ]); + + expect(await mb.transform.run(7, { wait: IMMEDIATE_POLL })).toEqual({ + message: "Transform run started", + run_id: null, + final: null, + }); + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/transform/7/run", + method: "POST", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("reads the output table off target_table_id, as v61 and later report it", async () => { + const { mb } = clientOver([ + jsonResponse(KICKOFF), + jsonResponse(SUCCEEDED_RUN), + jsonResponse(TRANSFORM_WITH_TARGET_TABLE_ID), + ]); + + expect(await mb.transform.run(7, { wait: IMMEDIATE_POLL, syncTarget: true })).toEqual({ + message: "Transform run started", + run_id: 31, + final: SUCCEEDED_RUN, + target_table_id: 42, + }); + }); + + it("reads the output table off the hydrated table, as v59 and v60 report it", async () => { + const { mb } = clientOver([ + jsonResponse(KICKOFF), + jsonResponse(SUCCEEDED_RUN), + jsonResponse(TRANSFORM_WITH_HYDRATED_TABLE), + ]); + + expect(await mb.transform.run(7, { wait: IMMEDIATE_POLL, syncTarget: true })).toEqual({ + message: "Transform run started", + run_id: 31, + final: SUCCEEDED_RUN, + target_table_id: 42, + }); + }); + + it("waits for the run itself when only the output table was asked for", async () => { + const { mb, capture } = clientOver([ + jsonResponse(KICKOFF), + jsonResponse(SUCCEEDED_RUN), + jsonResponse(TRANSFORM_WITH_TARGET_TABLE_ID), + ]); + + await mb.transform.run(7, { syncTarget: true }); + + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/transform/7/run", + "https://mb.example.com/metabase/api/transform/run/31", + "https://mb.example.com/metabase/api/transform/7", + ]); + }); + + it("reports no output table for a run that failed, without waiting for one", async () => { + const { mb, capture } = clientOver([jsonResponse(KICKOFF), jsonResponse(FAILED_RUN)]); + + expect(await mb.transform.run(7, { wait: IMMEDIATE_POLL, syncTarget: true })).toEqual({ + message: "Transform run started", + run_id: 31, + final: FAILED_RUN, + target_table_id: null, + }); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://mb.example.com/metabase/api/transform/7/run", + "https://mb.example.com/metabase/api/transform/run/31", + ]); + }); + + it("reports no output table when the wait runs out before the table is registered", async () => { + const { mb } = clientOver([ + jsonResponse(KICKOFF), + jsonResponse(SUCCEEDED_RUN), + ...repeated(TRANSFORM, 50), + ]); + + expect(await mb.transform.run(7, { wait: EXPIRING_POLL, syncTarget: true })).toEqual({ + message: "Transform run started", + run_id: 31, + final: SUCCEEDED_RUN, + target_table_id: null, + }); + }); +}); diff --git a/packages/client/src/resources/transform.ts b/packages/client/src/resources/transform.ts new file mode 100644 index 0000000..66415ff --- /dev/null +++ b/packages/client/src/resources/transform.ts @@ -0,0 +1,219 @@ +import { z } from "zod"; + +import { + isTransformRunFailed, + isTransformRunTerminal, + Transform, + type TransformCreateInput, + TransformRun, + type TransformRunResult, + type TransformUpdateInput, +} from "../domain/transform"; +import { TimeoutError } from "../errors"; +import type { RequestOptions, Transport } from "../http/transport"; +import type { ListResult } from "../list"; +import { type Page, type PaginateOptions, paginatePages } from "../paginate"; +import { type PollOptions, pollUntil } from "../poll"; + +// `GET /api/transform` and `GET /api/transform/{id}/dependencies` both answer a bare array rather +// than a `{ data, total }` envelope, so the count a caller reads off `ListResult` is the array's +// own length and the server reports none. +const TransformApiList = z.array(Transform); + +// The run endpoint answers as soon as the run is queued, and says nothing about how it went. +const TransformRunKickoff = z.object({ + message: z.string(), + run_id: z.number().int().positive().nullable(), +}); + +export interface TransformRunParams { + /** Poll the run to a terminal status. Without it the call returns once the run is queued. */ + wait?: PollOptions | undefined; + /** + * After a successful run, additionally poll until the run's output table is registered and + * report its id. Implies waiting for the run itself. + */ + syncTarget?: boolean | undefined; +} + +export interface TransformRunPageParams { + "transform-ids"?: number | undefined; +} + +// The walk's own settings, minus the query the method builds from `TransformRunPageParams`. +export type TransformRunPageOptions = Omit; + +// Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. +export function transformResource(transport: Transport) { + /** List every transform the caller can see. */ + async function list(options: RequestOptions = {}): Promise> { + const data = await transport.requestParsed(TransformApiList, "/api/transform", { ...options }); + return { data, total: null }; + } + + /** Get one transform by id. */ + async function get(id: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(Transform, `/api/transform/${id}`, { ...options }); + } + + /** Create a transform — a query plus the warehouse table it writes — from a full body. */ + async function create( + params: TransformCreateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Transform, "/api/transform", { + ...options, + method: "POST", + body: params, + }); + } + + /** Update a transform by id, patching only the fields the body carries. */ + async function update( + id: number, + params: TransformUpdateInput, + options: RequestOptions = {}, + ): Promise { + return transport.requestParsed(Transform, `/api/transform/${id}`, { + ...options, + method: "PUT", + body: params, + }); + } + + /** Delete a transform by id, leaving any table it already materialized in place. */ + async function remove(id: number, options: RequestOptions = {}): Promise { + await transport.requestRaw(`/api/transform/${id}`, { + ...options, + method: "DELETE", + expectContentType: "binary", + }); + } + + /** Drop a transform's materialized output table, keeping the transform definition. */ + async function deleteTable(id: number, options: RequestOptions = {}): Promise { + await transport.requestRaw(`/api/transform/${id}/table`, { + ...options, + method: "DELETE", + expectContentType: "binary", + }); + } + + /** List the upstream transforms a transform depends on. */ + async function dependencies( + id: number, + options: RequestOptions = {}, + ): Promise> { + const data = await transport.requestParsed( + TransformApiList, + `/api/transform/${id}/dependencies`, + { ...options }, + ); + return { data, total: null }; + } + + /** Request cancellation of a transform's current run. */ + async function cancel(id: number, options: RequestOptions = {}): Promise { + await transport.requestRaw(`/api/transform/${id}/cancel`, { + ...options, + method: "POST", + expectContentType: "binary", + }); + } + + /** Get one transform run by run id — not by the id of the transform that produced it. */ + async function getRun(runId: number, options: RequestOptions = {}): Promise { + return transport.requestParsed(TransformRun, `/api/transform/run/${runId}`, { ...options }); + } + + /** Walk the run history, newest first, optionally narrowed to a single transform. */ + function runPages( + params: TransformRunPageParams = {}, + options: TransformRunPageOptions = {}, + ): AsyncIterable> { + return paginatePages(transport, "/api/transform/run", TransformRun, { + query: { "transform-ids": params["transform-ids"] }, + ...(options.offset !== undefined && { offset: options.offset }), + ...(options.max !== undefined && { max: options.max }), + ...(options.pageSize !== undefined && { pageSize: options.pageSize }), + ...(options.signal !== undefined && { signal: options.signal }), + }); + } + + /** + * Start a run of a transform by id. The server queues the run and answers at once; `wait` polls + * it to a terminal status, and `syncTarget` follows that with the output table's registration. + * A server that answers no run id started nothing, and there is then nothing to poll. + */ + async function run( + id: number, + params: TransformRunParams = {}, + options: RequestOptions = {}, + ): Promise { + const kickoff = await transport.requestParsed(TransformRunKickoff, `/api/transform/${id}/run`, { + ...options, + method: "POST", + }); + const schedule = params.syncTarget === true ? (params.wait ?? {}) : params.wait; + if (schedule === undefined || kickoff.run_id === null) { + return { message: kickoff.message, run_id: kickoff.run_id, final: null }; + } + + const runId = kickoff.run_id; + const final = await pollUntil( + async (signal) => getRun(runId, { ...options, signal }), + (candidate) => isTransformRunTerminal(candidate.status), + schedule, + ); + if (params.syncTarget !== true) { + return { message: kickoff.message, run_id: runId, final }; + } + + const failed = isTransformRunFailed(final.status); + const targetTableId = failed ? null : await awaitTargetTableId(id, schedule, options); + return { message: kickoff.message, run_id: runId, final, target_table_id: targetTableId }; + } + + // A successful run registers its own output table — Metabase syncs the single materialized table + // as part of run completion, so no explicit database sync is needed. The linkage surfaces as + // `target_table_id` on v61+ and as the hydrated `table.id` on v59/v60; poll until either lands. + // A poll timeout answers null rather than throwing, because the table may still be syncing and + // the run itself already succeeded. + async function awaitTargetTableId( + id: number, + wait: PollOptions, + options: RequestOptions, + ): Promise { + try { + const linked = await pollUntil( + async (signal) => get(id, { ...options, signal }), + (transform) => linkedTableId(transform) !== null, + wait, + ); + return linkedTableId(linked); + } catch (error) { + if (error instanceof TimeoutError) { + return null; + } + throw error; + } + } + + return { + list, + get, + create, + update, + delete: remove, + deleteTable, + dependencies, + cancel, + getRun, + runPages, + run, + }; +} + +function linkedTableId(transform: Transform): number | null { + return transform.target_table_id ?? transform.table?.id ?? null; +} diff --git a/packages/client/src/resources/upload.test.ts b/packages/client/src/resources/upload.test.ts new file mode 100644 index 0000000..1415ee2 --- /dev/null +++ b/packages/client/src/resources/upload.test.ts @@ -0,0 +1,129 @@ +import { assert, describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import { ResponseShapeError } from "../errors"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, TEST_USER_AGENT } from "../testing/fetch-capture"; + +import type { CsvFile } from "./csv-upload"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const CSV_TEXT = "id,name\n1,alice\n"; + +const CSV_FILE: CsvFile = { + filename: "people.csv", + bytes: new TextEncoder().encode(CSV_TEXT), +}; + +const BINARY_READ_HEADERS = { + accept: "*/*", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +function createdResponse(modelId: string, tableId: string | null): Response { + const headers = tableId === null ? {} : { "metabase-table-id": tableId }; + return new Response(modelId, { headers }); +} + +async function thrownBy(run: () => Promise): Promise { + try { + await run(); + } catch (error: unknown) { + return error; + } + throw new Error("expected the call to reject"); +} + +describe("upload resource wire requests", () => { + it("posts the csv as multipart form data alongside the collection id", async () => { + const { mb, capture } = clientOver([createdResponse("123", "45")]); + + await mb.upload.createFromCsv(CSV_FILE, { collection_id: "root" }); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/upload/csv", + method: "POST", + headers: BINARY_READ_HEADERS, + body: { + parts: [ + { + name: "file", + value: CSV_TEXT, + filename: "people.csv", + contentType: "text/csv", + }, + { name: "collection_id", value: "root", filename: null, contentType: null }, + ], + }, + }, + ]); + }); + + it("reads the model id from the body and the table id from the header", async () => { + const { mb } = clientOver([createdResponse(" 123\n", "45")]); + + await expect(mb.upload.createFromCsv(CSV_FILE, { collection_id: "5" })).resolves.toEqual({ + model_id: 123, + table_id: 45, + }); + }); + + it("rejects a response body that is not an integer", async () => { + const { mb } = clientOver([createdResponse("abc", "45")]); + + const error = await thrownBy(() => + mb.upload.createFromCsv(CSV_FILE, { collection_id: "root" }), + ); + + assert(error instanceof ResponseShapeError, "expected a ResponseShapeError"); + expect(error.message).toBe('upload succeeded but the response body was not an integer: "abc"'); + expect(error.developerDetail).toEqual({ + kind: "decoded", + source: "response body", + value: "abc", + }); + }); + + it("reports an empty response body as a response-shape failure", async () => { + const { mb } = clientOver([createdResponse("", "45")]); + + const error = await thrownBy(() => + mb.upload.createFromCsv(CSV_FILE, { collection_id: "root" }), + ); + + assert(error instanceof ResponseShapeError, "expected a ResponseShapeError"); + expect(error.message).toBe("upload succeeded but the response body was empty"); + expect(error.category).toBe("response-shape"); + }); + + it("rejects a response without the table-id header", async () => { + const { mb } = clientOver([createdResponse("123", null)]); + + const error = await thrownBy(() => + mb.upload.createFromCsv(CSV_FILE, { collection_id: "root" }), + ); + + assert(error instanceof ResponseShapeError, "expected a ResponseShapeError"); + expect(error.message).toBe("upload succeeded but the metabase-table-id header was empty"); + expect(error.developerDetail).toEqual({ + kind: "decoded", + source: "metabase-table-id header", + value: null, + }); + }); +}); diff --git a/packages/client/src/resources/upload.ts b/packages/client/src/resources/upload.ts new file mode 100644 index 0000000..c3e2c85 --- /dev/null +++ b/packages/client/src/resources/upload.ts @@ -0,0 +1,66 @@ +import { UploadResult } from "../domain/upload"; +import { ResponseShapeError } from "../errors"; +import type { RequestOptions, Transport } from "../http/transport"; + +import { buildCsvFormData, type CsvFile } from "./csv-upload"; + +const UPLOAD_TABLE_ID_HEADER = "metabase-table-id"; + +export interface UploadCsvParams { + collection_id: string; +} + +export function uploadResource(transport: Transport) { + /** + * Create a table and model populated with the values from the attached CSV. Returns the model ID + * if successful. + * + * The file may be at most 50 MB; larger uploads are rejected with a 413 response. + */ + async function createFromCsv( + file: CsvFile, + params: UploadCsvParams, + options: RequestOptions = {}, + ): Promise { + const form = buildCsvFormData(file); + form.append("collection_id", params.collection_id); + const response = await transport.requestRaw("/api/upload/csv", { + ...options, + method: "POST", + body: form, + expectContentType: "binary", + }); + return parseCreateUploadResult(await response.text(), response.headers); + } + + return { createFromCsv }; +} + +function parseCreateUploadResult(bodyText: string, headers: Headers): UploadResult { + return UploadResult.parse({ + model_id: parseResponseInteger(bodyText, "response body"), + table_id: parseResponseInteger( + headers.get(UPLOAD_TABLE_ID_HEADER), + `${UPLOAD_TABLE_ID_HEADER} header`, + ), + }); +} + +function parseResponseInteger(value: string | null, source: string): number { + const trimmed = value?.trim() ?? ""; + if (trimmed === "") { + throw new ResponseShapeError(`upload succeeded but the ${source} was empty`, { + kind: "decoded", + source, + value, + }); + } + const parsed = Number(trimmed); + if (!Number.isInteger(parsed)) { + throw new ResponseShapeError( + `upload succeeded but the ${source} was not an integer: ${JSON.stringify(trimmed)}`, + { kind: "decoded", source, value }, + ); + } + return parsed; +} diff --git a/packages/client/src/resources/user.test.ts b/packages/client/src/resources/user.test.ts new file mode 100644 index 0000000..285c3eb --- /dev/null +++ b/packages/client/src/resources/user.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials } from "../http/transport"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; + +const CREDENTIALS: ClientCredentials = { + url: "https://mb.example.com/metabase", + credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, +}; + +const CURRENT_USER = { + id: 1, + email: "admin@example.com", + common_name: "Ada Lovelace", + is_superuser: true, +}; + +const JSON_READ_HEADERS = { + accept: "application/json", + "user-agent": TEST_USER_AGENT, + "x-api-key": "mb_wire_test_key", +}; + +function clientOver(responses: Array) { + const capture = captureFetch(responses); + const mb = createClient(CREDENTIALS, { + userAgent: TEST_USER_AGENT, + fetchImpl: capture.fetch, + }); + return { mb, capture }; +} + +describe("user resource wire requests", () => { + it("sends the current-user request", async () => { + const { mb, capture } = clientOver([jsonResponse(CURRENT_USER)]); + + await mb.user.current(); + + expect(capture.calls).toEqual([ + { + url: "https://mb.example.com/metabase/api/user/current", + method: "GET", + headers: JSON_READ_HEADERS, + body: null, + }, + ]); + }); + + it("returns the parsed current user", async () => { + const { mb } = clientOver([jsonResponse(CURRENT_USER)]); + + expect(await mb.user.current()).toEqual(CURRENT_USER); + }); +}); diff --git a/packages/client/src/resources/user.ts b/packages/client/src/resources/user.ts new file mode 100644 index 0000000..c360dfe --- /dev/null +++ b/packages/client/src/resources/user.ts @@ -0,0 +1,11 @@ +import { CurrentUser } from "../domain/user"; +import type { RequestOptions, Transport } from "../http/transport"; + +export function userResource(transport: Transport) { + /** Get the user the request's credentials authenticate as. */ + async function current(options: RequestOptions = {}): Promise { + return transport.requestParsed(CurrentUser, "/api/user/current", { ...options }); + } + + return { current }; +} diff --git a/packages/client/src/signal.test.ts b/packages/client/src/signal.test.ts new file mode 100644 index 0000000..ec234f2 --- /dev/null +++ b/packages/client/src/signal.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { AbortError } from "./errors"; +import { abortReason, combineAborts, throwIfAborted } from "./signal"; + +describe("combineAborts", () => { + it("aborts as soon as one composed source does, carrying that source's reason", () => { + const caller = new AbortController(); + const clientWide = new AbortController(); + const combined = combineAborts(caller.signal, clientWide.signal); + expect(combined.aborted).toBe(false); + + const reason = new AbortError("interrupted"); + clientWide.abort(reason); + + expect(combined.aborted).toBe(true); + expect(abortReason(combined)).toBe(reason); + }); + + it("skips absent sources instead of composing them", () => { + const caller = new AbortController(); + const combined = combineAborts(undefined, caller.signal, undefined); + + caller.abort(new AbortError("interrupted")); + + expect(combined.aborted).toBe(true); + }); + + it("returns a signal that never aborts when every source is absent", () => { + expect(combineAborts(undefined, undefined).aborted).toBe(false); + }); +}); + +describe("throwIfAborted", () => { + it("throws the first aborted signal's reason and ignores the live ones", () => { + const live = new AbortController(); + const aborted = new AbortController(); + const reason = new AbortError("interrupted"); + aborted.abort(reason); + + expect(() => throwIfAborted(live.signal, undefined, aborted.signal)).toThrowError(reason); + }); +}); + +describe("abortReason", () => { + it("returns an existing MetabaseError reason unchanged", () => { + const controller = new AbortController(); + const original = new AbortError("interrupted"); + controller.abort(original); + + expect(abortReason(controller.signal)).toBe(original); + }); + + it("wraps a string reason in an AbortError preserving the message", () => { + const controller = new AbortController(); + controller.abort("custom-reason"); + + const wrapped = abortReason(controller.signal); + expect(wrapped).toBeInstanceOf(AbortError); + expect(wrapped.message).toBe("custom-reason"); + }); + + it("converts a DOMException-style reason to an AbortError inside the taxonomy", () => { + const controller = new AbortController(); + controller.abort(); + + const wrapped = abortReason(controller.signal); + expect(wrapped).toBeInstanceOf(AbortError); + expect(wrapped.category).toBe("abort"); + }); +}); diff --git a/packages/client/src/signal.ts b/packages/client/src/signal.ts new file mode 100644 index 0000000..75429f4 --- /dev/null +++ b/packages/client/src/signal.ts @@ -0,0 +1,24 @@ +import { AbortError, errorMessage, MetabaseError } from "./errors"; + +export function combineAborts(...signals: Array): AbortSignal { + return AbortSignal.any(signals.filter((signal) => signal !== undefined)); +} + +export function throwIfAborted(...signals: Array): void { + for (const signal of signals) { + if (signal?.aborted) { + throw abortReason(signal); + } + } +} + +export function abortReason(signal: AbortSignal): MetabaseError { + const reason: unknown = signal.reason; + if (reason instanceof MetabaseError) { + return reason; + } + if (reason instanceof Error || typeof reason === "string") { + return new AbortError(errorMessage(reason) || "aborted"); + } + return new AbortError("aborted"); +} diff --git a/packages/client/src/testing/fake-client.test.ts b/packages/client/src/testing/fake-client.test.ts new file mode 100644 index 0000000..fc7d8db --- /dev/null +++ b/packages/client/src/testing/fake-client.test.ts @@ -0,0 +1,132 @@ +import { assert, describe, expect, it } from "vitest"; +import type { ZodError } from "zod"; +import { z } from "zod"; + +import { ResponseShapeError } from "../errors"; +import { type PaginatedEnvelope, paginatePages } from "../paginate"; + +import { type ClientCredentials, createTransport } from "../http/transport"; +import { createFakeClient, type FakeClientCall } from "./fake-client"; +import { captureFetch, jsonResponse, TEST_USER_AGENT } from "./fetch-capture"; + +const CONFIG: ClientCredentials = { + url: "https://m.example.com", + credential: { kind: "apiKey", apiKey: "mb_test_key_abcdef0123" }, +}; + +const PATH = "/api/user/current"; +const PingResponse = z.object({ id: z.number().int(), email: z.string() }); + +const Row = z.object({ id: z.number().int(), name: z.string() }); +type Row = z.infer; + +const ROWS: ReadonlyArray = [ + { id: 1, name: "one" }, + { id: 2, name: "two" }, + { id: 3, name: "three" }, + { id: 4, name: "four" }, + { id: 5, name: "five" }, +]; + +interface ShapeFailure { + name: string; + category: string; + userMessage: string; + method: string; + url: string; + status: number; + zodIssues: ZodError["issues"]; + serverTag: string | null; +} + +function shapeFailure(error: unknown): ShapeFailure { + assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); + const detail = error.developerDetail; + assert(detail.kind === "zod", "expected a schema-parse detail"); + return { + name: error.name, + category: error.category, + userMessage: error.userMessage, + method: detail.method, + url: detail.url, + status: detail.status, + zodIssues: detail.zodIssues, + serverTag: detail.serverTag, + }; +} + +function numericQuery(call: FakeClientCall, key: string): number { + const value = call.options?.query?.[key]; + assert(typeof value === "number", `expected a numeric ${key} in the query`); + return value; +} + +function rowPage(call: FakeClientCall): PaginatedEnvelope { + const offset = numericQuery(call, "offset"); + const limit = numericQuery(call, "limit"); + return { data: ROWS.slice(offset, offset + limit), total: ROWS.length }; +} + +describe("createFakeClient", () => { + it("rejects a body the schema refuses with the error the real client would raise", async () => { + const body = { id: "not-a-number", email: "a@b.com" }; + const real = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: captureFetch([jsonResponse(body)]).fetch, + }); + const { client: fake } = createFakeClient({ + routes: [{ path: PATH, reply: { kind: "body", body } }], + }); + + const realError = await real + .requestParsed(PingResponse, PATH) + .catch((caught: unknown) => caught); + const fakeError = await fake + .requestParsed(PingResponse, PATH) + .catch((caught: unknown) => caught); + + expect(shapeFailure(fakeError)).toEqual({ ...shapeFailure(realError), url: PATH }); + }); + + it("serves a different body per method on one path", async () => { + const { client } = createFakeClient({ + routes: [ + { path: PATH, reply: { kind: "body", body: { id: 1, email: "read@b.com" } } }, + { + method: "POST", + path: PATH, + reply: { kind: "body", body: { id: 2, email: "written@b.com" } }, + }, + ], + }); + + expect(await client.requestParsed(PingResponse, PATH)).toEqual({ id: 1, email: "read@b.com" }); + expect(await client.requestParsed(PingResponse, PATH, { method: "POST" })).toEqual({ + id: 2, + email: "written@b.com", + }); + }); + + it("throws naming the method and path when no route matches", async () => { + const { client } = createFakeClient({ + routes: [{ path: PATH, reply: { kind: "body", body: { id: 1, email: "read@b.com" } } }], + }); + + await expect(client.requestParsed(PingResponse, PATH, { method: "DELETE" })).rejects.toThrow( + `unexpected request: DELETE ${PATH}`, + ); + }); + + it("walks a paginated endpoint page by page when the reply is a function of the request", async () => { + const { client } = createFakeClient({ + routes: [{ path: "/api/row", reply: { kind: "respond", respond: rowPage } }], + }); + + const pages: Row[][] = []; + for await (const page of paginatePages(client, "/api/row", Row, { pageSize: 2 })) { + pages.push(page.items); + } + + expect(pages).toEqual([[ROWS[0], ROWS[1]], [ROWS[2], ROWS[3]], [ROWS[4]]]); + }); +}); diff --git a/packages/client/src/testing/fake-client.ts b/packages/client/src/testing/fake-client.ts new file mode 100644 index 0000000..36417a5 --- /dev/null +++ b/packages/client/src/testing/fake-client.ts @@ -0,0 +1,90 @@ +import type { ZodType } from "zod"; + +import { + type Transport, + DEFAULT_METHOD, + type HttpMethod, + type TransportRequestOptions, +} from "../http/transport"; +import { NO_SERVER_TAG, parseJsonResponse } from "../http/response-shape"; + +const FAKE_STATUS = 200; + +export interface FakeClientCall { + readonly method: HttpMethod; + readonly path: string; + readonly options: TransportRequestOptions | undefined; +} + +export type FakeResponder = (call: FakeClientCall) => unknown; + +export interface FakeBodyReply { + readonly kind: "body"; + readonly body: unknown; +} + +export interface FakeErrorReply { + readonly kind: "error"; + readonly error: Error; +} + +export interface FakeRespondReply { + readonly kind: "respond"; + readonly respond: FakeResponder; +} + +export type FakeReply = FakeBodyReply | FakeErrorReply | FakeRespondReply; + +export interface FakeRoute { + readonly method?: HttpMethod; + readonly path: string; + readonly reply: FakeReply; +} + +export interface FakeClientPlan { + readonly routes?: ReadonlyArray; +} + +export interface FakeClient { + readonly client: Transport; + readonly calls: ReadonlyArray; +} + +export function createFakeClient(plan: FakeClientPlan = {}): FakeClient { + const calls: FakeClientCall[] = []; + const client: Transport = { + async requestParsed( + schema: ZodType, + path: string, + options?: TransportRequestOptions, + ): Promise { + const call: FakeClientCall = { method: options?.method ?? DEFAULT_METHOD, path, options }; + calls.push(call); + const route = plan.routes?.find( + (candidate) => + candidate.path === call.path && (candidate.method ?? DEFAULT_METHOD) === call.method, + ); + if (route === undefined) { + throw new Error(`unexpected request: ${call.method} ${call.path}`); + } + if (route.reply.kind === "error") { + throw route.reply.error; + } + const body = route.reply.kind === "body" ? route.reply.body : route.reply.respond(call); + // Serializing mirrors the real boundary, where a body only ever reaches a schema as bytes. + return parseJsonResponse(JSON.stringify(body), schema, { + method: call.method, + url: call.path, + status: FAKE_STATUS, + getServerTag: NO_SERVER_TAG, + }); + }, + async requestRaw() { + throw new Error("requestRaw not implemented in fake client"); + }, + async requestStream() { + throw new Error("requestStream not implemented in fake client"); + }, + }; + return { client, calls }; +} diff --git a/src/core/http/fetch-capture.ts b/packages/client/src/testing/fetch-capture.ts similarity index 64% rename from src/core/http/fetch-capture.ts rename to packages/client/src/testing/fetch-capture.ts index 39aa1cf..c9469b1 100644 --- a/src/core/http/fetch-capture.ts +++ b/packages/client/src/testing/fetch-capture.ts @@ -1,11 +1,30 @@ // Test-only fetch double (the keyring-mock pattern): scripted responses plus a capture of every // call, shared by the client/oauth/logout suites so each doesn't grow its own drifting stub. +// A caller identity no production code could produce, so a hardcoded fallback cannot fake it. +export const TEST_USER_AGENT = "some-embedder/9.9.9"; + +// One `FormData` entry, read back as data. `filename`/`contentType` are null for a plain text +// field and carry the file's own values for a file part, so a multipart body deep-equals against a +// literal instead of against a boundary string the runtime picks. +export interface CapturedFormPart { + name: string; + value: string; + filename: string | null; + contentType: string | null; +} + +export interface CapturedFormBody { + parts: CapturedFormPart[]; +} + +export type CapturedBody = string | CapturedFormBody | null; + export interface CapturedFetchCall { url: string; method: string; headers: Record; - body: string | null; + body: CapturedBody; } type ResponseFactory = () => Response | Promise; @@ -24,7 +43,7 @@ export function captureFetch(script: FetchScript): FetchCapture { url: typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url, method: init?.method ?? "GET", headers: headersToRecord(init?.headers), - body: bodyText(init?.body), + body: await capturedBody(init?.body), }); const next = queue.shift(); if (next === undefined) { @@ -45,7 +64,7 @@ export function jsonResponse(body: unknown, status = 200): Response { }); } -function bodyText(body: RequestInit["body"]): string | null { +async function capturedBody(body: RequestInit["body"]): Promise { if (body === undefined || body === null) { return null; } @@ -55,9 +74,29 @@ function bodyText(body: RequestInit["body"]): string | null { if (body instanceof URLSearchParams) { return body.toString(); } + if (body instanceof FormData) { + return { parts: await formParts(body) }; + } throw new Error("captureFetch: unsupported request body type"); } +async function formParts(form: FormData): Promise { + const parts: CapturedFormPart[] = []; + for (const [name, value] of form) { + if (typeof value === "string") { + parts.push({ name, value, filename: null, contentType: null }); + continue; + } + parts.push({ + name, + value: await value.text(), + filename: value.name, + contentType: value.type, + }); + } + return parts; +} + function headersToRecord(init: RequestInit["headers"]): Record { const result: Record = {}; if (!init) { diff --git a/src/core/url.test.ts b/packages/client/src/url.test.ts similarity index 99% rename from src/core/url.test.ts rename to packages/client/src/url.test.ts index c688036..98f7bad 100644 --- a/src/core/url.test.ts +++ b/packages/client/src/url.test.ts @@ -29,7 +29,6 @@ describe("normalizeUrl", () => { expect(error).toBeInstanceOf(ConfigError); assert(error instanceof ConfigError, "expected ConfigError"); expect(error.message).toBe("URL must start with http:// or https://"); - expect(error.exitCode).toBe(2); }); }); diff --git a/src/core/url.ts b/packages/client/src/url.ts similarity index 100% rename from src/core/url.ts rename to packages/client/src/url.ts diff --git a/src/core/version/capabilities.test.ts b/packages/client/src/version/capabilities.test.ts similarity index 93% rename from src/core/version/capabilities.test.ts rename to packages/client/src/version/capabilities.test.ts index 07fcb11..8801707 100644 --- a/src/core/version/capabilities.test.ts +++ b/packages/client/src/version/capabilities.test.ts @@ -69,7 +69,7 @@ describe("checkCapabilities", () => { expect(checkCapabilities(infoFor({ major: 58 }), required)).toEqual({ reason: "version-too-old", detail: - "This command requires Metabase v60+ (this server is v0.58.0). Upgrade Metabase or pin mb-cli to an older release.", + "This operation requires Metabase v60+ (this server is v0.58.0). Upgrade Metabase to use it.", }); }); @@ -83,7 +83,7 @@ describe("checkCapabilities", () => { ).toEqual({ reason: "missing-token-feature", detail: - "This command requires the 'transforms' premium feature (not enabled on this server).", + "This operation requires the 'transforms' premium feature (not enabled on this server).", }); }); diff --git a/src/core/version/capabilities.ts b/packages/client/src/version/capabilities.ts similarity index 69% rename from src/core/version/capabilities.ts rename to packages/client/src/version/capabilities.ts index 8130e0c..a944298 100644 --- a/src/core/version/capabilities.ts +++ b/packages/client/src/version/capabilities.ts @@ -1,8 +1,16 @@ -import { BASELINE_CAPABILITIES, type Capabilities } from "../../runtime/capabilities"; +import { z } from "zod"; import type { ServerInfo } from "./probe"; -export { BASELINE_CAPABILITIES, type Capabilities }; +export const Capabilities = z.object({ + minVersion: z.number(), + tokenFeature: z.string().optional(), +}); +export type Capabilities = z.infer; + +export const BASELINE_CAPABILITIES: Capabilities = Object.freeze({ + minVersion: 58, +}); export function mergeCapabilities(overrides?: Partial): Capabilities { if (overrides === undefined) { @@ -36,7 +44,7 @@ export function checkCapabilities( if (info.version.major < required.minVersion) { return { reason: "version-too-old", - detail: `This command requires Metabase v${required.minVersion}+ (this server is ${info.version.tag}). Upgrade Metabase or pin mb-cli to an older release.`, + detail: `This operation requires Metabase v${required.minVersion}+ (this server is ${info.version.tag}). Upgrade Metabase to use it.`, }; } @@ -45,7 +53,7 @@ export function checkCapabilities( if (!enabled) { return { reason: "missing-token-feature", - detail: `This command requires the '${required.tokenFeature}' premium feature (not enabled on this server).`, + detail: `This operation requires the '${required.tokenFeature}' premium feature (not enabled on this server).`, }; } } diff --git a/src/core/version/preflight-error.ts b/packages/client/src/version/preflight-error.ts similarity index 94% rename from src/core/version/preflight-error.ts rename to packages/client/src/version/preflight-error.ts index 380c66a..a0b5ea6 100644 --- a/src/core/version/preflight-error.ts +++ b/packages/client/src/version/preflight-error.ts @@ -5,7 +5,6 @@ import type { PreflightFailure } from "./capabilities"; export class CapabilityError extends MetabaseError { readonly category = "capability"; readonly isRetryable = false; - readonly exitCode = 2; readonly developerDetail: PreflightFailure; constructor(failure: PreflightFailure) { diff --git a/src/core/version/probe.test.ts b/packages/client/src/version/probe.test.ts similarity index 72% rename from src/core/version/probe.test.ts rename to packages/client/src/version/probe.test.ts index 01b86ea..a1a45fe 100644 --- a/src/core/version/probe.test.ts +++ b/packages/client/src/version/probe.test.ts @@ -1,26 +1,27 @@ import { describe, expect, it } from "vitest"; -import { createFakeClient, type FakeClientCall } from "../http/fake-client"; +import { createFakeClient, type FakeClientCall, type FakeClientPlan } from "../testing/fake-client"; import { probeServer, PROBE_PATH, PROBE_TIMEOUT_MS } from "./probe"; const EXPECTED_PROBE_CALL: FakeClientCall = { + method: "GET", path: PROBE_PATH, options: { timeoutMs: PROBE_TIMEOUT_MS, retries: 0 }, }; -function planning(response: unknown): ReadonlyMap { - return new Map([[PROBE_PATH, response]]); +function planning(response: unknown): FakeClientPlan { + return { routes: [{ path: PROBE_PATH, reply: { kind: "body", body: response } }] }; } describe("probeServer", () => { it("parses the version tag and passes token-features through", async () => { - const { client } = createFakeClient({ - responses: planning({ + const { client } = createFakeClient( + planning({ version: { tag: "v1.58.7", date: "2025-12-15", hash: "abc1234" }, "token-features": { advanced_permissions: true, audit_app: true, embedding: true }, }), - }); + ); expect(await probeServer(client)).toEqual({ version: { tag: "v1.58.7", major: 58, patch: 7 }, tokenFeatures: { advanced_permissions: true, audit_app: true, embedding: true }, @@ -28,17 +29,13 @@ describe("probeServer", () => { }); it("queries the probe endpoint with retries disabled and a short timeout", async () => { - const { client, calls } = createFakeClient({ - responses: planning({ version: { tag: "v0.58.7" } }), - }); + const { client, calls } = createFakeClient(planning({ version: { tag: "v0.58.7" } })); await probeServer(client); expect(calls).toEqual([EXPECTED_PROBE_CALL]); }); it("returns tokenFeatures: null when the server omits the field", async () => { - const { client } = createFakeClient({ - responses: planning({ version: { tag: "v0.58.7" } }), - }); + const { client } = createFakeClient(planning({ version: { tag: "v0.58.7" } })); expect(await probeServer(client)).toEqual({ version: { tag: "v0.58.7", major: 58, patch: 7 }, tokenFeatures: null, @@ -46,12 +43,12 @@ describe("probeServer", () => { }); it("returns version null for an unparseable head/nightly tag, token-features still passed through", async () => { - const { client } = createFakeClient({ - responses: planning({ + const { client } = createFakeClient( + planning({ version: { tag: "vUNKNOWN" }, "token-features": { transforms: true }, }), - }); + ); expect(await probeServer(client)).toEqual({ version: null, tokenFeatures: { transforms: true }, diff --git a/src/core/version/probe.ts b/packages/client/src/version/probe.ts similarity index 72% rename from src/core/version/probe.ts rename to packages/client/src/version/probe.ts index e713dbc..bf1254d 100644 --- a/src/core/version/probe.ts +++ b/packages/client/src/version/probe.ts @@ -1,5 +1,5 @@ -import { SessionProperties, type TokenFeatures } from "../../domain/session-properties"; -import type { Client } from "../http/client"; +import { SessionProperties, type TokenFeatures } from "../domain/session-properties"; +import type { Transport } from "../http/transport"; import { tryParseTag, type ParsedVersion } from "./tag"; @@ -15,7 +15,7 @@ interface ProbeOptions { retries?: number; } -export async function probeServer(client: Client, opts: ProbeOptions = {}): Promise { +export async function probeServer(client: Transport, opts: ProbeOptions = {}): Promise { const properties = await client.requestParsed(SessionProperties, PROBE_PATH, { timeoutMs: PROBE_TIMEOUT_MS, retries: opts.retries ?? 0, diff --git a/src/core/version/tag.test.ts b/packages/client/src/version/tag.test.ts similarity index 87% rename from src/core/version/tag.test.ts rename to packages/client/src/version/tag.test.ts index 145ba15..4b348a9 100644 --- a/src/core/version/tag.test.ts +++ b/packages/client/src/version/tag.test.ts @@ -27,6 +27,8 @@ describe("tryParseTag", () => { ["major prefix outside 0|1", "v2.58.7"], ["wholly malformed", "vLOCAL_DEV"], ["a head/nightly build tag", "vUNKNOWN"], + ["a locally built jar tag that would read as v1", "v0.1.0-SNAPSHOT"], + ["a snapshot build of a released line", "v0.59.12-SNAPSHOT"], ])("returns null on %s", (_label, input) => { expect(tryParseTag(input)).toBeNull(); }); diff --git a/packages/client/src/version/tag.ts b/packages/client/src/version/tag.ts new file mode 100644 index 0000000..e54a5d9 --- /dev/null +++ b/packages/client/src/version/tag.ts @@ -0,0 +1,30 @@ +import { parse as parseSemver } from "semver"; +import { z } from "zod"; + +export const ParsedVersion = z.object({ + tag: z.string(), + major: z.number().int().nonnegative(), + patch: z.number().int().nonnegative(), +}); +export type ParsedVersion = z.infer; + +// A dev build reports a tag that either fails semver outright ("vUNKNOWN", "vLOCAL_DEV") or parses +// to a number that means nothing — a locally built jar reports "v0.1.0-SNAPSHOT", which would read +// as Metabase v1 and make every version gate fire against a server that actually carries the newest +// features. Metabase itself treats any "-SNAPSHOT" tag as "no version", so we do too. +const DEV_BUILD_SUFFIX = "-SNAPSHOT"; + +export function tryParseTag(tag: string): ParsedVersion | null { + if (tag.endsWith(DEV_BUILD_SUFFIX)) { + return null; + } + const parsed = parseSemver(tag); + if (parsed === null || (parsed.major !== 0 && parsed.major !== 1)) { + return null; + } + return { + tag, + major: parsed.minor, + patch: parsed.patch, + }; +} diff --git a/tests/fixtures/user/sample.json b/packages/client/tests/fixtures/user/sample.json similarity index 100% rename from tests/fixtures/user/sample.json rename to packages/client/tests/fixtures/user/sample.json diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..52d43ea --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"] +} diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts new file mode 100644 index 0000000..d74c979 --- /dev/null +++ b/packages/client/tsdown.config.ts @@ -0,0 +1,46 @@ +import { readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { defineConfig } from "tsdown"; + +import manifest from "./package.json" with { type: "json" }; + +const OUT_DIR = "dist"; +const DIST_PREFIX = `./${OUT_DIR}/`; +const SRC_PREFIX = "./src/"; +const JS_SUFFIX = ".js"; +const TS_SUFFIX = ".ts"; +const TEST_SUFFIX = ".test.ts"; + +// The export map is the surface, so it is also the entry list: a module the map does not name has no +// business getting its own file in `dist`, and one it does name has to have one. +function entryPoints(): string[] { + return Object.values(manifest.exports).flatMap((target) => { + if (target === null || !target.startsWith(DIST_PREFIX)) { + return []; + } + const stem = target.slice(DIST_PREFIX.length, -JS_SUFFIX.length); + const source = `${SRC_PREFIX}${stem}${TS_SUFFIX}`; + return source.includes("*") ? expandWildcard(source) : [source]; + }); +} + +function expandWildcard(pattern: string): string[] { + const dir = dirname(pattern); + return readdirSync(join(import.meta.dirname, dir)) + .filter((name) => name.endsWith(TS_SUFFIX) && !name.endsWith(TEST_SUFFIX)) + .map((name) => `${dir}/${name}`) + .toSorted(); +} + +export default defineConfig({ + entry: entryPoints(), + outDir: OUT_DIR, + format: "esm", + platform: "node", + target: "node20.3", + dts: true, + // The export map names `.js`, which for a `"type": "module"` package is already unambiguously ESM. + fixedExtension: false, + clean: true, +}); diff --git a/scripts/e2e-matrix.ts b/scripts/e2e-matrix.ts index 0dc34d5..cbbb746 100644 --- a/scripts/e2e-matrix.ts +++ b/scripts/e2e-matrix.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"; import { execa } from "execa"; -import { errorMessage } from "../src/core/errors"; +import { errorMessage } from "@metabase/client/errors"; interface Stack { id: string; @@ -37,7 +37,7 @@ const DEFAULT_PARALLELISM = 2; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, ".."); const COMPOSE_FILE = resolve(REPO_ROOT, "tests/e2e/docker-compose.yml"); -const DIST_CLI = resolve(REPO_ROOT, "dist", "cli.mjs"); +const DIST_CLI = resolve(REPO_ROOT, "packages", "cli", "dist", "cli.mjs"); interface CliOptions { stacks: Stack[]; diff --git a/scripts/sync-representations.ts b/scripts/sync-representations.ts index 18b792c..03b21a3 100644 --- a/scripts/sync-representations.ts +++ b/scripts/sync-representations.ts @@ -1,4 +1,6 @@ -// Idempotent — CI runs this and asserts no diff. Requires `npm` and `tar` on PATH. +// Requires `npm` and `tar` on PATH; run by hand, no workflow invokes it. Idempotent, and the sole +// owner of the formatting of what it writes — `.oxfmtrc.json` ignores +// `packages/cli/src/core/schema/data/**`, so oxfmt never reformats a vendored payload. import { execFileSync } from "node:child_process"; import { promises as fs } from "node:fs"; import { mkdtempSync } from "node:fs"; @@ -9,7 +11,7 @@ import { fileURLToPath } from "node:url"; import yaml from "js-yaml"; import { z } from "zod"; -import { isNotFoundError } from "../src/core/errors"; +import { isFileNotFoundError } from "@metabase/client/errors"; const YamlObject = z.record(z.string(), z.unknown()); @@ -17,7 +19,7 @@ const REPRESENTATIONS_VERSION = "1.1.7"; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, ".."); -const DATA_DIR = resolve(REPO_ROOT, "src/core/schema/data"); +const DATA_DIR = resolve(REPO_ROOT, "packages/cli/src/core/schema/data"); const COMMON_DIR = resolve(DATA_DIR, "schemas/common"); async function main(): Promise { @@ -56,7 +58,7 @@ async function copyLicense(packageRoot: string): Promise { try { text = await fs.readFile(sourceLicense, "utf8"); } catch (error) { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { return; } throw error; diff --git a/src/commands/alert/patch.test.ts b/src/commands/alert/patch.test.ts deleted file mode 100644 index 5853890..0000000 --- a/src/commands/alert/patch.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { ConfigError } from "../../core/errors"; -import { Notification } from "../../domain/notification"; - -import { assertCardAlert, mergeAlertUpdate } from "./patch"; - -const STORED = Notification.parse({ - id: 9, - payload_type: "notification/card", - payload_id: 4, - payload: { id: 4, card_id: 94, send_condition: "has_result", send_once: false }, - active: true, - creator_id: 2, - subscriptions: [ - { id: 9, type: "notification-subscription/cron", cron_schedule: "0 0 8 * * ? *" }, - ], - handlers: [ - { - id: 9, - channel_type: "channel/email", - recipients: [ - { id: 10, type: "notification-recipient/raw-value", details: { value: "a@example.com" } }, - ], - }, - ], -}); - -describe("assertCardAlert", () => { - it("returns a card notification unchanged", () => { - expect(assertCardAlert(STORED)).toEqual(STORED); - }); - - it("rejects a system-event notification, which shares the /api/notification id space", () => { - const systemEvent = Notification.parse({ - ...STORED, - id: 1, - payload_type: "notification/system-event", - payload_id: null, - payload: null, - }); - - expect(() => assertCardAlert(systemEvent)).toThrow( - new ConfigError( - "notification 1 is a notification/system-event, not a question alert — `mb alert` manages card alerts only", - ), - ); - }); -}); - -describe("mergeAlertUpdate", () => { - it("keeps the notification id and payload id so the server updates in place", () => { - const merged = mergeAlertUpdate(STORED, { active: false }); - - expect(merged).toEqual({ ...STORED, active: false }); - }); - - it("merges a partial payload over the stored one, preserving card_id and the payload id", () => { - const merged = mergeAlertUpdate(STORED, { payload: { send_condition: "goal_above" } }); - - expect(merged).toEqual({ - ...STORED, - payload: { id: 4, card_id: 94, send_condition: "goal_above", send_once: false }, - }); - }); - - it("replaces subscriptions and handlers wholesale, matching the server's spec-diff semantics", () => { - const merged = mergeAlertUpdate(STORED, { - subscriptions: [{ type: "notification-subscription/cron", cron_schedule: "0 0 9 * * ? *" }], - handlers: [ - { - channel_type: "channel/slack", - recipients: [ - { type: "notification-recipient/raw-value", details: { value: "#general" } }, - ], - }, - ], - }); - - expect(merged).toEqual({ - ...STORED, - subscriptions: [{ type: "notification-subscription/cron", cron_schedule: "0 0 9 * * ? *" }], - handlers: [ - { - channel_type: "channel/slack", - recipients: [ - { type: "notification-recipient/raw-value", details: { value: "#general" } }, - ], - }, - ], - }); - }); - - it("refuses to patch the payload of an alert whose card payload was deleted server-side", () => { - const orphaned = Notification.parse({ ...STORED, payload_id: null, payload: null }); - - expect(() => mergeAlertUpdate(orphaned, { payload: { send_once: true } })).toThrow( - new ConfigError("alert 9 has lost its card payload — it can be archived, but not patched"), - ); - }); - - it("still deactivates an alert whose card payload was deleted server-side", () => { - const orphaned = Notification.parse({ ...STORED, payload_id: null, payload: null }); - - expect(mergeAlertUpdate(orphaned, { active: false })).toEqual({ - ...orphaned, - active: false, - }); - }); -}); diff --git a/src/commands/alert/patch.ts b/src/commands/alert/patch.ts deleted file mode 100644 index 67921b7..0000000 --- a/src/commands/alert/patch.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { ConfigError } from "../../core/errors"; -import type { Client } from "../../core/http/client"; -import { - CARD_PAYLOAD_TYPE, - Notification, - NotificationCardPayload, - type NotificationCardPayloadPatch, - type NotificationUpdateInput, -} from "../../domain/notification"; - -// /api/notification is shared with Metabase's internal system-event notifications, whose ids sit -// alongside the alerts'. Reading one through `mb alert` would be confusing, and sending one would -// fire an internal Metabase email, so every by-id verb loads the alert through here. -export async function fetchAlert(client: Client, id: number): Promise { - return assertCardAlert(await client.requestParsed(Notification, `/api/notification/${id}`)); -} - -export function assertCardAlert(notification: Notification): Notification { - if (notification.payload_type !== CARD_PAYLOAD_TYPE) { - throw new ConfigError( - `notification ${notification.id} is a ${notification.payload_type}, not a question alert — \`mb alert\` manages card alerts only`, - ); - } - return notification; -} - -// `PUT /api/notification/:id` is a spec-diff against the stored row, not a patch: a body whose -// `id` does not match the stored one makes Metabase delete the notification and insert a -// replacement under a fresh id, and the same holds for the nested `payload` row. So every update -// reads the current notification and merges the caller's patch over it, preserving both ids. -export function mergeAlertUpdate( - current: Notification, - patch: NotificationUpdateInput, -): Notification { - return Notification.parse({ - ...current, - ...patch, - id: current.id, - payload: patch.payload === undefined ? current.payload : mergePayload(current, patch.payload), - }); -} - -// A card alert whose payload row was deleted server-side comes back with `payload: null`. It can -// still be deactivated, but there is nothing to merge a payload patch into. -function mergePayload( - current: Notification, - patch: NotificationCardPayloadPatch, -): NotificationCardPayload { - if (current.payload === null) { - throw new ConfigError( - `alert ${current.id} has lost its card payload — it can be archived, but not patched`, - ); - } - return NotificationCardPayload.parse({ ...current.payload, ...patch }); -} - -export async function patchAlert( - client: Client, - id: number, - patch: NotificationUpdateInput, -): Promise { - const current = await fetchAlert(client, id); - return client.requestParsed(Notification, `/api/notification/${id}`, { - method: "PUT", - body: mergeAlertUpdate(current, patch), - }); -} diff --git a/src/commands/collection/listing.ts b/src/commands/collection/listing.ts deleted file mode 100644 index 8f5a8c1..0000000 --- a/src/commands/collection/listing.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z, type ZodType } from "zod"; - -import type { Client } from "../../core/http/client"; - -export const COLLECTIONS_PATH = "/api/collection"; - -export async function fetchCollectionsWithLibrary( - client: Client, - itemSchema: ZodType, -): Promise { - return await client.requestParsed(z.array(itemSchema), COLLECTIONS_PATH, { - query: { "include-library": true }, - }); -} diff --git a/src/commands/dashboard/preflight.ts b/src/commands/dashboard/preflight.ts deleted file mode 100644 index d38f8ef..0000000 --- a/src/commands/dashboard/preflight.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { z } from "zod"; - -import { ChainedRequestError, ConfigError, MetabaseError } from "../../core/errors"; -import type { Client } from "../../core/http/client"; -import { HttpError } from "../../core/http/errors"; -import { ValidationIssue, ValidationOutcome } from "../../core/schema/validate"; -import { Card } from "../../domain/card"; -import { writeJson } from "../../output/render"; - -const PreflightDashcard = z.looseObject({ - card_id: z.number().int().nullable().optional(), -}); - -interface CardReference { - cardId: number; - path: string; -} - -type CardCheck = { status: "ok" } | { status: "error"; message: string }; - -export function collectDashcardCardReferences( - dashcards: ReadonlyArray | null | undefined, -): CardReference[] { - if (dashcards === undefined || dashcards === null) { - return []; - } - const refs: CardReference[] = []; - dashcards.forEach((dashcard, index) => { - const parsed = PreflightDashcard.safeParse(dashcard); - if (!parsed.success) { - return; - } - const cardId = parsed.data.card_id; - if (typeof cardId === "number" && cardId > 0) { - refs.push({ cardId, path: `/dashcards/${index}/card_id` }); - } - }); - return refs; -} - -export async function preflightDashcardCardReferences( - client: Client, - dashcards: ReadonlyArray | null | undefined, -): Promise { - const references = collectDashcardCardReferences(dashcards); - if (references.length === 0) { - return; - } - const grouped = new Map(); - for (const ref of references) { - const list = grouped.get(ref.cardId); - if (list === undefined) { - grouped.set(ref.cardId, [ref]); - } else { - list.push(ref); - } - } - const checks = await Promise.all( - Array.from(grouped.entries()).map(async ([cardId, refs]) => ({ - refs, - result: await classifyCardReference(client, cardId), - })), - ); - const errors: ValidationIssue[] = []; - for (const check of checks) { - if (check.result.status === "ok") { - continue; - } - for (const ref of check.refs) { - errors.push({ path: ref.path, message: check.result.message }); - } - } - if (errors.length === 0) { - return; - } - const outcome: ValidationOutcome = { ok: false, errors }; - writeJson(outcome); - throw new ConfigError( - `dashboard card-reference pre-flight failed: ${errors.length} error(s) — fix the dashcard card_id values listed above`, - ); -} - -export function wrapChainedDashboardWriteError(error: unknown, dashboardId: number): unknown { - if (!(error instanceof MetabaseError)) { - return error; - } - const prefix = `dashboard ${dashboardId} created but follow-up PUT /api/dashboard/${dashboardId} failed`; - const suffix = "dashcards not applied"; - const message = `${prefix}: ${error.userMessage}; ${suffix}`; - if (error instanceof HttpError) { - return new HttpError({ - status: error.status, - statusText: error.developerDetail.statusText, - method: error.developerDetail.method, - url: error.developerDetail.url, - responseHeaders: error.developerDetail.responseHeaders, - rawBody: error.developerDetail.body, - overrideUserMessage: message, - }); - } - return new ChainedRequestError(message, error); -} - -async function classifyCardReference(client: Client, cardId: number): Promise { - try { - const card = await client.requestParsed(Card, `/api/card/${cardId}`); - if (card.archived) { - return { status: "error", message: `card ${cardId} is archived` }; - } - return { status: "ok" }; - } catch (error) { - if (!(error instanceof HttpError)) { - throw error; - } - if (error.status === 404) { - return { status: "error", message: `card ${cardId} not found` }; - } - if (error.status === 401 || error.status === 403) { - return { status: "error", message: `card ${cardId} is not readable: ${error.userMessage}` }; - } - throw error; - } -} diff --git a/src/commands/document/list.ts b/src/commands/document/list.ts deleted file mode 100644 index 7b93ff9..0000000 --- a/src/commands/document/list.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -import { Document, DocumentCompact, documentView } from "../../domain/document"; -import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { defineMetabaseCommand } from "../runtime"; - -const DocumentApiList = z.object({ items: z.array(Document) }).loose(); - -export const DocumentListEnvelope = listEnvelopeSchema(DocumentCompact); - -export default defineMetabaseCommand({ - meta: { name: "list", description: "List documents" }, - capabilities: { minVersion: 58 }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, - outputSchema: DocumentListEnvelope, - examples: ["mb document list", "mb document list --json"], - async run({ ctx, getClient }) { - const client = await getClient(); - const response = await client.requestParsed(DocumentApiList, "/api/document"); - renderList(wrapList(response.items), documentView, ctx); - }, -}); diff --git a/src/commands/git-sync/branches.ts b/src/commands/git-sync/branches.ts deleted file mode 100644 index 873b899..0000000 --- a/src/commands/git-sync/branches.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { z } from "zod"; - -import type { ResourceView } from "../../domain/view"; -import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { defineMetabaseCommand } from "../runtime"; - -import { REMOTE_SYNC_PATHS } from "./poll-task"; - -const SyncBranchesApiResponse = z.object({ - items: z.array(z.string()), -}); - -const BranchRow = z.object({ name: z.string() }); -type BranchRow = z.infer; - -const branchView: ResourceView = { - compactPick: BranchRow, - tableColumns: [{ key: "name", label: "Branch" }], -}; - -export const SyncBranchListEnvelope = listEnvelopeSchema(BranchRow); - -export default defineMetabaseCommand({ - meta: { name: "branches", description: "List branches on the configured git remote" }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, - outputSchema: SyncBranchListEnvelope, - examples: ["mb git-sync branches", "mb git-sync branches --json"], - async run({ ctx, getClient }) { - const client = await getClient(); - const response = await client.requestParsed( - SyncBranchesApiResponse, - REMOTE_SYNC_PATHS.branches, - ); - const rows: BranchRow[] = response.items.map((name) => ({ name })); - renderList(wrapList(rows), branchView, ctx); - }, -}); diff --git a/src/commands/git-sync/dirty.ts b/src/commands/git-sync/dirty.ts deleted file mode 100644 index 0abbbe3..0000000 --- a/src/commands/git-sync/dirty.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { z } from "zod"; - -import { SyncDirtyItem, SyncDirtyItemCompact, syncDirtyItemView } from "../../domain/git-sync"; -import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { defineMetabaseCommand } from "../runtime"; - -import { REMOTE_SYNC_PATHS } from "./poll-task"; - -const SyncDirtyApiResponse = z.object({ - dirty: z.array(SyncDirtyItem), -}); - -export const SyncDirtyListEnvelope = listEnvelopeSchema(SyncDirtyItemCompact); - -export default defineMetabaseCommand({ - meta: { name: "dirty", description: "List objects with unsynced local changes" }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, - outputSchema: SyncDirtyListEnvelope, - examples: ["mb git-sync dirty", "mb git-sync dirty --json"], - async run({ ctx, getClient }) { - const client = await getClient(); - const response = await client.requestParsed(SyncDirtyApiResponse, REMOTE_SYNC_PATHS.dirty); - renderList(wrapList(response.dirty), syncDirtyItemView, ctx); - }, -}); diff --git a/src/commands/git-sync/has-remote-changes.ts b/src/commands/git-sync/has-remote-changes.ts deleted file mode 100644 index 392221a..0000000 --- a/src/commands/git-sync/has-remote-changes.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { z } from "zod"; - -import type { ResourceView } from "../../domain/view"; -import { renderSummary } from "../../output/render"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { defineMetabaseCommand } from "../runtime"; - -import { REMOTE_SYNC_PATHS } from "./poll-task"; - -export const HasRemoteChangesResult = z.object({ - has_changes: z.boolean(), - remote_version: z.string().nullable(), - local_version: z.string().nullable(), - cached: z.boolean(), -}); -type HasRemoteChangesResult = z.infer; - -const hasRemoteChangesView: ResourceView = { - compactPick: HasRemoteChangesResult, - tableColumns: [ - { key: "has_changes", label: "Has changes" }, - { key: "remote_version", label: "Remote" }, - { key: "local_version", label: "Local" }, - { key: "cached", label: "Cached" }, - ], -}; - -export default defineMetabaseCommand({ - meta: { - name: "has-remote-changes", - description: "Check whether the remote branch has unimported changes", - }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, - args: { - ...outputFlags, - ...profileFlag, - ...connectionFlags, - forceRefresh: { - type: "boolean", - description: "Bypass the in-memory cache and re-check the remote", - default: false, - alias: "force-refresh", - }, - }, - outputSchema: HasRemoteChangesResult, - examples: [ - "mb git-sync has-remote-changes", - "mb git-sync has-remote-changes --force-refresh --json", - ], - async run({ args, ctx, getClient }) { - const client = await getClient(); - const result = await client.requestParsed( - HasRemoteChangesResult, - REMOTE_SYNC_PATHS.hasRemoteChanges, - { query: { "force-refresh": args.forceRefresh } }, - ); - const base = result.has_changes - ? `The remote branch has changes not yet imported (remote ${result.remote_version ?? "?"}, local ${result.local_version ?? "?"}).` - : "Up to date with the remote branch."; - renderSummary(result, hasRemoteChangesView, result.cached ? `${base} (cached)` : base, ctx); - }, -}); diff --git a/src/commands/git-sync/import.ts b/src/commands/git-sync/import.ts deleted file mode 100644 index b89181c..0000000 --- a/src/commands/git-sync/import.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { z } from "zod"; - -import { SyncTask } from "../../domain/git-sync"; -import type { ResourceView } from "../../domain/view"; -import { renderSummary } from "../../output/render"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { defineMetabaseCommand } from "../runtime"; -import { gitSyncWaitFlags, parseWaitFlags } from "../wait-flags"; - -import { formatSyncTask, pollSyncTask, REMOTE_SYNC_PATHS, throwIfFailedTask } from "./poll-task"; - -const SyncImportKickoff = z.object({ - status: z.literal("success"), - task_id: z.number().int().positive().nullable(), - message: z.string().nullable().optional(), -}); - -export const SyncImportResult = z.object({ - message: z.string().nullable(), - task_id: z.number().int().positive().nullable(), - final: SyncTask.nullable().optional(), -}); -type SyncImportResult = z.infer; - -const syncImportView: ResourceView = { - compactPick: SyncImportResult, - tableColumns: [ - { key: "task_id", label: "Task ID" }, - { key: "message", label: "Message" }, - ], -}; - -interface ImportRequestBody { - branch?: string; - force?: boolean; -} - -export default defineMetabaseCommand({ - meta: { - name: "import", - description: "Import content from the configured git remote into Metabase", - }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, - args: { - ...outputFlags, - ...profileFlag, - ...connectionFlags, - branch: { - type: "string", - description: "Branch to import from (defaults to remote-sync-branch setting)", - alias: "b", - }, - force: { - type: "boolean", - description: "Discard local Metabase-side dirty changes (LOSSY)", - default: false, - }, - ...gitSyncWaitFlags, - }, - outputSchema: SyncImportResult, - examples: [ - "mb git-sync import", - "mb git-sync import --branch main --json", - "mb git-sync import --force --no-wait", - ], - async run({ args, ctx, getClient }) { - const wait = parseWaitFlags(args); - const body: ImportRequestBody = {}; - if (args.branch !== undefined && args.branch !== "") { - body.branch = args.branch; - } - if (args.force) { - body.force = true; - } - - const client = await getClient(); - const kickoff = await client.requestParsed(SyncImportKickoff, REMOTE_SYNC_PATHS.import, { - method: "POST", - body, - }); - - if (!wait.enabled || kickoff.task_id === null) { - const result: SyncImportResult = { - message: kickoff.message ?? null, - task_id: kickoff.task_id, - }; - const text = - kickoff.task_id === null - ? (kickoff.message ?? "Already up to date; nothing to import.") - : `Started import task #${kickoff.task_id}.`; - renderSummary(result, syncImportView, text, ctx); - return; - } - - const final = await pollSyncTask(client, wait.schedule); - const result: SyncImportResult = { - message: kickoff.message ?? null, - task_id: kickoff.task_id, - final, - }; - const text = - final === null ? `Import task #${kickoff.task_id} finished.` : formatSyncTask(final); - renderSummary(result, syncImportView, text, ctx); - throwIfFailedTask(final, "import"); - }, -}); diff --git a/src/commands/git-sync/poll-task.test.ts b/src/commands/git-sync/poll-task.test.ts deleted file mode 100644 index 7b53ef1..0000000 --- a/src/commands/git-sync/poll-task.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { SyncTask, type SyncTaskStatus } from "../../domain/git-sync"; - -import { formatSyncTask, isFailure, isTerminal } from "./poll-task"; - -const ALL_STATUSES: SyncTaskStatus[] = [ - "running", - "successful", - "errored", - "cancelled", - "timed-out", - "conflict", -]; - -describe("isTerminal", () => { - it("returns false only for the running status", () => { - const terminal = ALL_STATUSES.filter((status) => isTerminal(status)); - expect(terminal).toEqual(["successful", "errored", "cancelled", "timed-out", "conflict"]); - }); -}); - -describe("isFailure", () => { - it("returns true only for errored, timed-out, and conflict (not successful or cancelled)", () => { - const failures = ALL_STATUSES.filter((status) => isFailure(status)); - expect(failures).toEqual(["errored", "timed-out", "conflict"]); - }); -}); - -describe("formatSyncTask", () => { - function task(overrides: Partial): SyncTask { - return SyncTask.parse({ - id: 3, - sync_task_type: "export", - status: "running", - progress: null, - started_at: "2026-05-21T00:00:00Z", - ...overrides, - }); - } - - it("renders a running export with its progress as a rounded percent", () => { - expect(formatSyncTask(task({ status: "running", progress: 0.45 }))).toBe( - "Export task #3 is running (45%).", - ); - }); - - it("omits the percent when a running task reports no progress", () => { - expect(formatSyncTask(task({ sync_task_type: "import", id: 7, progress: null }))).toBe( - "Import task #7 is running.", - ); - }); - - it("renders a succeeded task", () => { - expect(formatSyncTask(task({ status: "successful" }))).toBe("Export task #3 succeeded."); - }); - - it("appends the error message for an errored task", () => { - expect(formatSyncTask(task({ status: "errored", error_message: "remote rejected" }))).toBe( - "Export task #3 errored: remote rejected.", - ); - }); - - it("renders a cancelled task without an error suffix", () => { - expect(formatSyncTask(task({ sync_task_type: "import", id: 5, status: "cancelled" }))).toBe( - "Import task #5 was cancelled.", - ); - }); -}); diff --git a/src/commands/git-sync/poll-task.ts b/src/commands/git-sync/poll-task.ts deleted file mode 100644 index e95ccba..0000000 --- a/src/commands/git-sync/poll-task.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { z, type ZodType } from "zod"; - -import { SyncTask, type SyncTaskStatus } from "../../domain/git-sync"; -import type { Client } from "../../core/http/client"; -import type { ResourceView } from "../../domain/view"; -import { parseJsonOrPlain } from "../../runtime/json"; -import { pollUntil, type PollOptions } from "../../runtime/poll"; - -const TERMINAL_STATUSES = new Set([ - "successful", - "errored", - "cancelled", - "timed-out", - "conflict", -]); - -const FAILURE_STATUSES = new Set(["errored", "timed-out", "conflict"]); - -export const REMOTE_SYNC_PATHS = { - currentTask: "/api/ee/remote-sync/current-task", - cancelTask: "/api/ee/remote-sync/current-task/cancel", - isDirty: "/api/ee/remote-sync/is-dirty", - hasRemoteChanges: "/api/ee/remote-sync/has-remote-changes", - dirty: "/api/ee/remote-sync/dirty", - import: "/api/ee/remote-sync/import", - export: "/api/ee/remote-sync/export", - stash: "/api/ee/remote-sync/stash", - branches: "/api/ee/remote-sync/branches", - createBranch: "/api/ee/remote-sync/create-branch", - settings: "/api/ee/remote-sync/settings", -} as const; - -export const SyncTaskIdle = z.object({ status: z.literal("idle") }); -export type SyncTaskIdle = z.infer; - -export const SyncTaskOrIdle = z.union([SyncTask, SyncTaskIdle]); -export type SyncTaskOrIdle = z.infer; - -export const syncTaskIdleView: ResourceView = { - compactPick: SyncTaskIdle, - tableColumns: [{ key: "status", label: "Status" }], -}; - -export function isTerminal(status: SyncTaskStatus): boolean { - return TERMINAL_STATUSES.has(status); -} - -export function isFailure(status: SyncTaskStatus): boolean { - return FAILURE_STATUSES.has(status); -} - -export async function fetchOptionalParsed( - client: Client, - path: string, - schema: ZodType, -): Promise { - const response = await client.requestRaw(path, { method: "GET", expectContentType: "binary" }); - if (response.status === 204) { - return null; - } - const text = await response.text(); - return parseJsonOrPlain(text, response.headers.get("content-type"), schema, { - source: response.url, - }); -} - -export async function fetchCurrentTask(client: Client): Promise { - return await fetchOptionalParsed(client, REMOTE_SYNC_PATHS.currentTask, SyncTask); -} - -export async function pollSyncTask(client: Client, opts: PollOptions): Promise { - return await pollUntil( - async () => fetchCurrentTask(client), - (task) => task === null || isTerminal(task.status), - { backoff: "exponential", ...opts }, - ); -} - -export function throwIfFailedTask(final: SyncTask | null, verb: string): void { - if (final === null || !isFailure(final.status)) { - return; - } - const detail = final.error_message ? `: ${final.error_message}` : ""; - throw new Error(`git-sync ${verb} ${final.status}${detail}`); -} - -export function formatSyncTask(task: SyncTask): string { - const kind = task.sync_task_type === "export" ? "Export" : "Import"; - const label = `${kind} task #${task.id}`; - const detail = task.error_message ? `: ${task.error_message}` : ""; - switch (task.status) { - case "running": { - const percent = task.progress === null ? "" : ` (${Math.round(task.progress * 100)}%)`; - return `${label} is running${percent}.`; - } - case "successful": { - return `${label} succeeded.`; - } - case "errored": { - return `${label} errored${detail}.`; - } - case "timed-out": { - return `${label} timed out${detail}.`; - } - case "conflict": { - return `${label} hit conflicts${detail}.`; - } - case "cancelled": { - return `${label} was cancelled.`; - } - } -} diff --git a/src/commands/git-sync/sync-scope.test.ts b/src/commands/git-sync/sync-scope.test.ts deleted file mode 100644 index ca9cd33..0000000 --- a/src/commands/git-sync/sync-scope.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { Client } from "../../core/http/client"; -import { HttpError } from "../../core/http/errors"; -import { Collection } from "../../domain/collection"; - -import { fetchRemoteSyncUrlIfReadable, fetchSyncedCollections, syncScopeHint } from "./sync-scope"; - -const REMOTE_URL = "https://github.com/acme/metabase-sync.git"; - -function collection(overrides: Partial): Collection { - return Collection.parse({ id: 51, name: "Data", ...overrides }); -} - -function clientRespondingWith(requestRaw: Client["requestRaw"]): Client { - return { - requestRaw, - requestParsed: () => { - throw new Error("requestParsed is not used by fetchRemoteSyncUrlIfReadable"); - }, - requestStream: () => { - throw new Error("requestStream is not used by fetchRemoteSyncUrlIfReadable"); - }, - }; -} - -function clientListing(rows: unknown): Client { - return { - requestParsed: async (schema) => schema.parse(rows), - requestRaw: () => { - throw new Error("requestRaw is not used by fetchSyncedCollections"); - }, - requestStream: () => { - throw new Error("requestStream is not used by fetchSyncedCollections"); - }, - }; -} - -function settingResponse(jsonBody: string): Response { - return new Response(jsonBody, { - status: 200, - headers: { "content-type": "application/json" }, - }); -} - -function settingError(status: number): HttpError { - return new HttpError({ - status, - statusText: "", - method: "GET", - url: "https://mb.example.test/api/setting/remote-sync-url", - responseHeaders: {}, - rawBody: null, - }); -} - -describe("syncScopeHint", () => { - it("returns the add-collection recipe when the collection is unsynced and a remote is configured", () => { - const hint = syncScopeHint(collection({ is_remote_synced: false }), REMOTE_URL); - expect(hint).toBe( - 'Note: collection 51 "Data" is not marked for git-sync, ' + - `so \`mb git-sync export\` will not carry it (or its published tables' metadata) to ${REMOTE_URL}. ` + - "Add it with: mb git-sync add-collection 51", - ); - }); - - it("returns null when the collection is already synced", () => { - expect(syncScopeHint(collection({ is_remote_synced: true }), REMOTE_URL)).toBeNull(); - }); - - it("returns null when the server does not report the flag", () => { - expect(syncScopeHint(collection({}), REMOTE_URL)).toBeNull(); - }); - - it("returns null when no remote sync url is configured", () => { - expect(syncScopeHint(collection({ is_remote_synced: false }), null)).toBeNull(); - }); - - it("returns null without a collection", () => { - expect(syncScopeHint(null, REMOTE_URL)).toBeNull(); - }); -}); - -describe("fetchSyncedCollections", () => { - it("returns id and name of only the collections flagged for sync", async () => { - const client = clientListing([ - { id: 51, name: "Data", is_remote_synced: false }, - { id: 4, name: "Ops", is_remote_synced: true }, - { id: "root", name: "Our analytics", is_remote_synced: false }, - { id: 9, name: "Legacy", is_remote_synced: null }, - ]); - await expect(fetchSyncedCollections(client)).resolves.toEqual([{ id: 4, name: "Ops" }]); - }); -}); - -describe("fetchRemoteSyncUrlIfReadable", () => { - it("returns the configured url", async () => { - const client = clientRespondingWith(async () => settingResponse(JSON.stringify(REMOTE_URL))); - await expect(fetchRemoteSyncUrlIfReadable(client)).resolves.toBe(REMOTE_URL); - }); - - it("normalizes an empty-string setting to null", async () => { - const client = clientRespondingWith(async () => settingResponse('""')); - await expect(fetchRemoteSyncUrlIfReadable(client)).resolves.toBeNull(); - }); - - it("returns null when the setting is unset", async () => { - const client = clientRespondingWith(async () => settingResponse("null")); - await expect(fetchRemoteSyncUrlIfReadable(client)).resolves.toBeNull(); - }); - - it("returns null when the caller may not read settings (403)", async () => { - const client = clientRespondingWith(async () => { - throw settingError(403); - }); - await expect(fetchRemoteSyncUrlIfReadable(client)).resolves.toBeNull(); - }); - - it("returns null when the setting is not registered on the server (404)", async () => { - const client = clientRespondingWith(async () => { - throw settingError(404); - }); - await expect(fetchRemoteSyncUrlIfReadable(client)).resolves.toBeNull(); - }); - - it("rethrows other HTTP errors", async () => { - const client = clientRespondingWith(async () => { - throw settingError(500); - }); - const result = fetchRemoteSyncUrlIfReadable(client); - await expect(result).rejects.toBeInstanceOf(HttpError); - await expect(result).rejects.toThrow("Metabase returned 500."); - }); -}); diff --git a/src/commands/git-sync/sync-scope.ts b/src/commands/git-sync/sync-scope.ts deleted file mode 100644 index 198ff2c..0000000 --- a/src/commands/git-sync/sync-scope.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { z } from "zod"; - -import { errorMessage } from "../../core/errors"; -import type { Client } from "../../core/http/client"; -import { HttpError } from "../../core/http/errors"; -import { Collection } from "../../domain/collection"; -import { warn } from "../../output/notice"; - -import { fetchCollectionsWithLibrary } from "../collection/listing"; -import { fetchOptionalParsed } from "./poll-task"; - -const REMOTE_SYNC_URL_SETTING_PATH = "/api/setting/remote-sync-url"; - -const RemoteSyncUrl = z.string().nullable(); - -export const SyncedCollection = Collection.pick({ id: true, name: true }).strip(); -export type SyncedCollection = z.infer; - -const SyncScopeCollection = Collection.pick({ - id: true, - name: true, - is_remote_synced: true, -}).strip(); - -export async function fetchSyncedCollections(client: Client): Promise { - const collections = await fetchCollectionsWithLibrary(client, SyncScopeCollection); - return collections - .filter((collection) => collection.is_remote_synced === true) - .map((collection) => ({ id: collection.id, name: collection.name })); -} - -// The setting is admin-readable only, and unregistered on servers without the remote-sync -// module; both cases mean "no usable remote", not a failure of the caller's command. -export async function fetchRemoteSyncUrlIfReadable(client: Client): Promise { - try { - const url = await fetchOptionalParsed(client, REMOTE_SYNC_URL_SETTING_PATH, RemoteSyncUrl); - return url === "" ? null : url; - } catch (error) { - if (error instanceof HttpError && (error.status === 403 || error.status === 404)) { - return null; - } - throw error; - } -} - -function isOutsideSyncScope(collection: Collection | null): collection is Collection { - return collection !== null && collection.is_remote_synced === false; -} - -export function syncScopeHint( - collection: Collection | null, - remoteSyncUrl: string | null, -): string | null { - if (!isOutsideSyncScope(collection) || remoteSyncUrl === null) { - return null; - } - return ( - `Note: collection ${collection.id} "${collection.name}" is not marked for git-sync, ` + - `so \`mb git-sync export\` will not carry it (or its published tables' metadata) to ${remoteSyncUrl}. ` + - `Add it with: mb git-sync add-collection ${collection.id}` - ); -} - -// Advisory only: the command's mutation has already succeeded by the time this runs, so a -// failed scope lookup is reported on stderr instead of failing the command. -export async function warnIfOutsideSyncScope( - client: Client, - collection: Collection | null, -): Promise { - if (!isOutsideSyncScope(collection)) { - return; - } - try { - const remoteSyncUrl = await fetchRemoteSyncUrlIfReadable(client); - const hint = syncScopeHint(collection, remoteSyncUrl); - if (hint !== null) { - warn(hint); - } - } catch (error) { - warn(`Could not check git-sync scope for collection ${collection.id}: ${errorMessage(error)}`); - } -} diff --git a/src/commands/library/resolve.ts b/src/commands/library/resolve.ts deleted file mode 100644 index 0e83391..0000000 --- a/src/commands/library/resolve.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { z } from "zod"; - -import type { Client } from "../../core/http/client"; -import { Collection } from "../../domain/collection"; -import { Library } from "../../domain/library"; - -import { fetchCollectionsWithLibrary } from "../collection/listing"; - -export const LIBRARY_ROOT_PATH = "/api/ee/library/"; -const LIBRARY_DATA_TYPE = "library-data"; - -const AbsentLibrary = z.object({ data: z.null() }); -const LibraryOrAbsent = z.union([Library, AbsentLibrary]); - -const LibraryCollectionEntry = Collection.pick({ - id: true, - type: true, - is_remote_synced: true, -}).strip(); -type LibraryCollectionInfo = z.infer; - -export async function fetchLibrary(client: Client): Promise { - const result = await client.requestParsed(LibraryOrAbsent, LIBRARY_ROOT_PATH); - if (!("effective_children" in result)) { - return null; - } - // GET /api/ee/library/ doesn't send each child's `type` or `is_remote_synced` in - // `effective_children` on released servers (v0.59-v0.61) — the frontend's own LibraryChild - // type omits them too. Resolve both from the collection list so callers can tell the Data - // and Metrics collections apart and see whether each is in the git-sync scope. - const infoById = await fetchLibraryCollectionInfo(client); - const effective_children = result.effective_children.map((child) => { - if (typeof child.id !== "number") { - return child; - } - const info = infoById.get(child.id); - if (info === undefined) { - return child; - } - return { - ...child, - type: info.type ?? child.type, - is_remote_synced: info.is_remote_synced ?? child.is_remote_synced, - }; - }); - return { ...result, effective_children }; -} - -async function fetchLibraryCollectionInfo( - client: Client, -): Promise> { - const collections = await fetchCollectionsWithLibrary(client, LibraryCollectionEntry); - const infoById = new Map(); - for (const collection of collections) { - if (typeof collection.id === "number") { - infoById.set(collection.id, collection); - } - } - return infoById; -} - -export async function createLibrary(client: Client): Promise { - const existing = await fetchLibrary(client); - if (existing !== null) { - return existing; - } - await client.requestRaw(LIBRARY_ROOT_PATH, { method: "POST" }); - const created = await fetchLibrary(client); - if (created === null) { - throw new Error(`Library was not created after POST ${LIBRARY_ROOT_PATH}`); - } - return created; -} - -export async function ensureLibraryDataCollectionId(client: Client): Promise { - const library = await createLibrary(client); - const data = library.effective_children.find((child) => child.type === LIBRARY_DATA_TYPE); - if (data === undefined) { - throw new Error("Library has no Data collection to publish into"); - } - if (typeof data.id !== "number") { - throw new Error(`Library Data collection has a non-numeric id ${String(data.id)}`); - } - return data.id; -} diff --git a/src/commands/measure/list.ts b/src/commands/measure/list.ts deleted file mode 100644 index d51b062..0000000 --- a/src/commands/measure/list.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -import { Measure, MeasureCompact, measureView } from "../../domain/measure"; -import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { defineMetabaseCommand } from "../runtime"; - -const MeasureApiList = z.array(Measure); - -export const MeasureListEnvelope = listEnvelopeSchema(MeasureCompact); - -export default defineMetabaseCommand({ - meta: { name: "list", description: "List measures" }, - capabilities: { minVersion: 59 }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, - outputSchema: MeasureListEnvelope, - examples: ["mb measure list", "mb measure list --json"], - async run({ ctx, getClient }) { - const client = await getClient(); - const items = await client.requestParsed(MeasureApiList, "/api/measure"); - renderList(wrapList(items), measureView, ctx); - }, -}); diff --git a/src/commands/segment/list.ts b/src/commands/segment/list.ts deleted file mode 100644 index c0e1d69..0000000 --- a/src/commands/segment/list.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -import { Segment, SegmentCompact, segmentView } from "../../domain/segment"; -import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { defineMetabaseCommand } from "../runtime"; - -const SegmentApiList = z.array(Segment); - -export const SegmentListEnvelope = listEnvelopeSchema(SegmentCompact); - -export default defineMetabaseCommand({ - meta: { name: "list", description: "List segments" }, - capabilities: { minVersion: 58 }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, - outputSchema: SegmentListEnvelope, - examples: ["mb segment list", "mb segment list --json"], - async run({ ctx, getClient }) { - const client = await getClient(); - const items = await client.requestParsed(SegmentApiList, "/api/segment"); - renderList(wrapList(items), segmentView, ctx); - }, -}); diff --git a/src/commands/setting/key.test.ts b/src/commands/setting/key.test.ts deleted file mode 100644 index d4d2e01..0000000 --- a/src/commands/setting/key.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { assert, describe, expect, it } from "vitest"; - -import { ConfigError } from "../../core/errors"; -import { HttpError } from "../../core/http/errors"; - -import { parseSettingKey, rethrowSettingError } from "./key"; - -function thrownBy(run: () => void): unknown { - try { - run(); - } catch (error: unknown) { - return error; - } - throw new Error("expected the call to throw"); -} - -describe("parseSettingKey", () => { - it("trims and returns a valid kebab-case key", () => { - expect(parseSettingKey(" remote-sync-branch ")).toBe("remote-sync-branch"); - }); - - it("rejects a key that is not a kebab-case identifier", () => { - const error = thrownBy(() => parseSettingKey("not a key!")); - expect(error).toBeInstanceOf(ConfigError); - assert(error instanceof ConfigError, "expected ConfigError"); - expect(error.message).toBe( - 'invalid setting key: "not a key!" (expected kebab-case identifier)', - ); - expect(error.exitCode).toBe(2); - }); -}); - -describe("rethrowSettingError", () => { - it("rewrites an unknown-setting server error to use the user's key without the keyword colon", () => { - const serverError = new HttpError({ - status: 400, - statusText: "Bad Request", - method: "GET", - url: "https://m.example.com/api/setting/totally-bogus", - responseHeaders: new Headers({ "content-type": "application/json" }), - rawBody: JSON.stringify({ message: "Unknown setting: :totally-bogus" }), - }); - - const error = thrownBy(() => rethrowSettingError(serverError, "totally-bogus")); - expect(error).toBeInstanceOf(ConfigError); - assert(error instanceof ConfigError, "expected ConfigError"); - expect(error.message).toBe("unknown setting: totally-bogus"); - expect(error.exitCode).toBe(2); - }); - - it("rethrows an unrelated error unchanged", () => { - const serverError = new HttpError({ - status: 500, - statusText: "Server Error", - method: "GET", - url: "https://m.example.com/api/setting/site-name", - responseHeaders: new Headers(), - rawBody: null, - }); - - const error = thrownBy(() => rethrowSettingError(serverError, "site-name")); - expect(error).toBe(serverError); - }); -}); diff --git a/src/commands/setting/key.ts b/src/commands/setting/key.ts deleted file mode 100644 index b0e97bf..0000000 --- a/src/commands/setting/key.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ConfigError, errorMessage } from "../../core/errors"; - -const SETTING_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/; -const UNKNOWN_SETTING_PREFIX = "Unknown setting:"; - -export function parseSettingKey(value: string): string { - const trimmed = value.trim(); - if (!SETTING_KEY_PATTERN.test(trimmed)) { - throw new ConfigError(`invalid setting key: "${value}" (expected kebab-case identifier)`); - } - return trimmed; -} - -// Metabase echoes an unknown setting back as a Clojure keyword (":foo"); surface the user's -// key instead of leaking the leading colon, and treat it as an input error (exit 2). -export function rethrowSettingError(error: unknown, key: string): never { - if (errorMessage(error).startsWith(UNKNOWN_SETTING_PREFIX)) { - throw new ConfigError(`unknown setting: ${key}`); - } - throw error; -} diff --git a/src/commands/setting/list.ts b/src/commands/setting/list.ts deleted file mode 100644 index b7070ee..0000000 --- a/src/commands/setting/list.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -import { Setting, SettingCompact, settingView } from "../../domain/setting"; -import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { defineMetabaseCommand } from "../runtime"; - -const SettingApiList = z.array(Setting); - -export const SettingListEnvelope = listEnvelopeSchema(SettingCompact); - -export default defineMetabaseCommand({ - meta: { name: "list", description: "List all settings (admin)" }, - capabilities: { minVersion: 58 }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, - outputSchema: SettingListEnvelope, - examples: ["mb setting list", "mb setting list --json"], - async run({ ctx, getClient }) { - const client = await getClient(); - const settings = await client.requestParsed(SettingApiList, "/api/setting"); - renderList(wrapList(settings), settingView, ctx); - }, -}); diff --git a/src/commands/subscription/patch.test.ts b/src/commands/subscription/patch.test.ts deleted file mode 100644 index e9256f8..0000000 --- a/src/commands/subscription/patch.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { Pulse, type PulseUpdateInput } from "../../domain/pulse"; - -import { mergeSubscriptionUpdate } from "./patch"; - -const STORED = Pulse.parse({ - id: 1, - name: "Weekly orders", - creator_id: 2, - dashboard_id: 10, - collection_id: 4, - archived: true, - skip_if_empty: true, - parameters: [], - cards: [ - { - id: 94, - name: "Orders by status", - dashboard_card_id: 87, - include_csv: false, - include_xls: false, - }, - ], - channels: [ - { - channel_type: "email", - enabled: false, - schedule_type: "daily", - schedule_hour: 8, - schedule_day: null, - schedule_frame: null, - recipients: [{ email: "team@example.com" }], - }, - ], -}); - -describe("mergeSubscriptionUpdate", () => { - it("carries archived and skip_if_empty forward, which the server would otherwise default to false", () => { - expect(mergeSubscriptionUpdate(STORED, { name: "Daily orders" })).toEqual({ - name: "Daily orders", - archived: true, - skip_if_empty: true, - }); - }); - - it("lets the caller override both of the server-defaulted fields", () => { - expect(mergeSubscriptionUpdate(STORED, { archived: false, skip_if_empty: false })).toEqual({ - archived: false, - skip_if_empty: false, - }); - }); - - it("passes the caller's other fields through untouched", () => { - const channels: NonNullable = [ - { - channel_type: "email", - enabled: true, - schedule_type: "weekly", - schedule_hour: 6, - schedule_day: "mon", - recipients: [{ email: "team@example.com" }], - }, - ]; - - expect(mergeSubscriptionUpdate(STORED, { channels })).toEqual({ - channels, - archived: true, - skip_if_empty: true, - }); - }); -}); diff --git a/src/commands/subscription/patch.ts b/src/commands/subscription/patch.ts deleted file mode 100644 index f9f111b..0000000 --- a/src/commands/subscription/patch.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Client } from "../../core/http/client"; -import { Pulse, type PulseUpdateInput } from "../../domain/pulse"; - -// `PUT /api/pulse/:id` applies the server's schema defaults to keys the body omits, and both -// `archived` and `skip_if_empty` default to false. A patch that leaves them out therefore -// un-archives the subscription and clears skip_if_empty behind the caller's back. Every update -// carries both forward from the stored pulse unless the caller sets them explicitly. The -// remaining fields (`name`, `cards`, `channels`, `collection_id`, `parameters`) are optional -// server-side and survive an omission untouched. -export function mergeSubscriptionUpdate(current: Pulse, patch: PulseUpdateInput): PulseUpdateInput { - return { - ...patch, - archived: patch.archived ?? current.archived, - skip_if_empty: patch.skip_if_empty ?? current.skip_if_empty, - }; -} - -export async function patchSubscription( - client: Client, - id: number, - patch: PulseUpdateInput, -): Promise { - const current = await client.requestParsed(Pulse, `/api/pulse/${id}`); - return client.requestParsed(Pulse, `/api/pulse/${id}`, { - method: "PUT", - body: mergeSubscriptionUpdate(current, patch), - }); -} diff --git a/src/commands/transform-job/list.ts b/src/commands/transform-job/list.ts deleted file mode 100644 index 42b55e2..0000000 --- a/src/commands/transform-job/list.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -import { TransformJob, TransformJobCompact, transformJobView } from "../../domain/transform-job"; -import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { defineMetabaseCommand } from "../runtime"; - -const TransformJobApiList = z.array(TransformJob); - -export const TransformJobListEnvelope = listEnvelopeSchema(TransformJobCompact); - -export default defineMetabaseCommand({ - meta: { name: "list", description: "List transform jobs" }, - capabilities: { minVersion: 59 }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, - outputSchema: TransformJobListEnvelope, - examples: ["mb transform-job list", "mb transform-job list --json"], - async run({ ctx, getClient }) { - const client = await getClient(); - const items = await client.requestParsed(TransformJobApiList, "/api/transform-job"); - renderList(wrapList(items), transformJobView, ctx); - }, -}); diff --git a/src/commands/transform-tag/list.ts b/src/commands/transform-tag/list.ts deleted file mode 100644 index 8763614..0000000 --- a/src/commands/transform-tag/list.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -import { TransformTag, TransformTagCompact, transformTagView } from "../../domain/transform-tag"; -import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { defineMetabaseCommand } from "../runtime"; - -const TransformTagApiList = z.array(TransformTag); - -export const TransformTagListEnvelope = listEnvelopeSchema(TransformTagCompact); - -export default defineMetabaseCommand({ - meta: { name: "list", description: "List transform tags" }, - capabilities: { minVersion: 59 }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, - outputSchema: TransformTagListEnvelope, - examples: ["mb transform-tag list", "mb transform-tag list --json"], - async run({ ctx, getClient }) { - const client = await getClient(); - const items = await client.requestParsed(TransformTagApiList, "/api/transform-tag"); - renderList(wrapList(items), transformTagView, ctx); - }, -}); diff --git a/src/commands/transform/list.ts b/src/commands/transform/list.ts deleted file mode 100644 index a2c3bc3..0000000 --- a/src/commands/transform/list.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; - -import { Transform, TransformCompact, transformView } from "../../domain/transform"; -import { renderList } from "../../output/render"; -import { listEnvelopeSchema, wrapList } from "../../output/types"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { defineMetabaseCommand } from "../runtime"; - -const TransformApiList = z.array(Transform); - -export const TransformListEnvelope = listEnvelopeSchema(TransformCompact); - -export default defineMetabaseCommand({ - meta: { name: "list", description: "List transforms" }, - capabilities: { minVersion: 59 }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, - outputSchema: TransformListEnvelope, - examples: ["mb transform list", "mb transform list --json"], - async run({ ctx, getClient }) { - const client = await getClient(); - const items = await client.requestParsed(TransformApiList, "/api/transform"); - renderList(wrapList(items), transformView, ctx); - }, -}); diff --git a/src/commands/transform/run.ts b/src/commands/transform/run.ts deleted file mode 100644 index bb11613..0000000 --- a/src/commands/transform/run.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { z } from "zod"; - -import { TimeoutError } from "../../core/errors"; -import type { Client } from "../../core/http/client"; -import { Transform, TransformRun } from "../../domain/transform"; -import type { ResourceView } from "../../domain/view"; -import { renderSummary } from "../../output/render"; -import { pollUntil } from "../../runtime/poll"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { parseId } from "../parse-id"; -import { defineMetabaseCommand } from "../runtime"; -import { parseWaitFlags, waitFlags, type WaitSchedule } from "../wait-flags"; - -export const RUN_TERMINAL_STATUSES = new Set(["succeeded", "failed", "timeout", "canceled"]); -const RUN_FAILURE_STATUSES = new Set(["failed", "timeout", "canceled"]); - -const TransformRunKickoff = z.object({ - message: z.string(), - run_id: z.number().int().positive().nullable(), -}); - -export const TransformRunResult = z.object({ - message: z.string(), - run_id: z.number().int().positive().nullable(), - final: TransformRun.nullable(), - target_table_id: z.number().int().nullable().optional(), -}); -export type TransformRunResultJson = z.infer; - -const transformRunResultView: ResourceView = { - compactPick: TransformRunResult, - tableColumns: [ - { key: "run_id", label: "Run ID" }, - { key: "message", label: "Message" }, - { key: "target_table_id", label: "Target table" }, - ], -}; - -export default defineMetabaseCommand({ - meta: { name: "run", description: "Trigger a transform run by id" }, - details: - "Starts a run and returns immediately. --wait polls the run to a terminal status. --sync additionally waits until the run's output table is registered and returns its `target_table_id`, so you can build MBQL cards against it — the run registers the table itself, so no separate `db sync-schema` is needed; --sync implies waiting for the run.", - capabilities: { minVersion: 59 }, - args: { - ...outputFlags, - ...profileFlag, - ...connectionFlags, - ...waitFlags, - sync: { - type: "boolean", - description: - "After a successful run, wait until the output table is registered and return its id (implies --wait)", - default: false, - }, - id: { type: "positional", description: "Transform id", required: true }, - }, - outputSchema: TransformRunResult, - examples: [ - "mb transform run 1", - "mb transform run 1 --wait --json", - "mb transform run 1 --sync --json", - ], - async run({ args, ctx, getClient }) { - const id = parseId(args.id); - const syncTarget = args.sync === true; - const wait = parseWaitFlags(args); - const waitForRun = wait.enabled || syncTarget; - const client = await getClient(); - const kickoff = await client.requestParsed(TransformRunKickoff, `/api/transform/${id}/run`, { - method: "POST", - }); - - if (!waitForRun) { - const started = - kickoff.run_id === null - ? kickoff.message - : `Started run ${kickoff.run_id} for transform ${id}.`; - renderSummary( - { message: kickoff.message, run_id: kickoff.run_id, final: null }, - transformRunResultView, - started, - ctx, - ); - return; - } - - if (kickoff.run_id === null) { - renderSummary( - { message: kickoff.message, run_id: null, final: null }, - transformRunResultView, - kickoff.message, - ctx, - ); - throw new Error(`transform run did not start: ${kickoff.message}`); - } - - const runId = kickoff.run_id; - - const final = await pollUntil( - async () => client.requestParsed(TransformRun, `/api/transform/run/${runId}`), - (run) => RUN_TERMINAL_STATUSES.has(run.status), - wait.schedule, - ); - - const failed = RUN_FAILURE_STATUSES.has(final.status); - const targetTableId = - syncTarget && !failed ? await awaitTargetTableId(client, id, wait.schedule) : undefined; - - const result: TransformRunResultJson = { message: kickoff.message, run_id: runId, final }; - if (syncTarget) { - result.target_table_id = targetTableId ?? null; - } - - renderSummary( - result, - transformRunResultView, - summaryLine(id, runId, final.status, syncTarget, targetTableId), - ctx, - ); - - if (failed) { - throw new Error(`transform run ${runId} ${final.status}`); - } - }, -}); - -// A successful run registers its own output table — Metabase syncs the single materialized -// table as part of run completion, so no explicit db sync is needed here. The linkage surfaces -// as `target_table_id` on v61+ and as the hydrated `table.id` on v59/v60; poll until either -// lands. Returns null on poll timeout (the table is still syncing) rather than failing the run. -async function awaitTargetTableId( - client: Client, - transformId: number, - schedule: WaitSchedule, -): Promise { - try { - const linked = await pollUntil( - async () => client.requestParsed(Transform, `/api/transform/${transformId}`), - (transform) => linkedTableId(transform) !== null, - schedule, - ); - return linkedTableId(linked); - } catch (error) { - if (error instanceof TimeoutError) { - return null; - } - throw error; - } -} - -function linkedTableId(transform: Transform): number | null { - return transform.target_table_id ?? transform.table?.id ?? null; -} - -function summaryLine( - transformId: number, - runId: number, - status: string, - syncTarget: boolean, - targetTableId: number | null | undefined, -): string { - const base = `Run ${runId} of transform ${transformId} ${status}.`; - if (!syncTarget) { - return base; - } - return targetTableId === null || targetTableId === undefined - ? `${base} Output table not registered before the wait timeout (it may still be syncing).` - : `${base} Output table ${targetTableId} registered.`; -} diff --git a/src/commands/upload/requests.test.ts b/src/commands/upload/requests.test.ts deleted file mode 100644 index 10794c9..0000000 --- a/src/commands/upload/requests.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseCreateUploadResult } from "./requests"; - -describe("parseCreateUploadResult", () => { - it("reads the model id from the body and the table id from the header", () => { - const headers = new Headers({ "metabase-table-id": "45" }); - expect(parseCreateUploadResult(" 123\n", headers)).toEqual({ model_id: 123, table_id: 45 }); - }); - - it("throws when the body is not an integer", () => { - expect(() => - parseCreateUploadResult("abc", new Headers({ "metabase-table-id": "45" })), - ).toThrow('upload succeeded but the response body was not an integer: "abc"'); - }); - - it("throws when the table-id header is absent", () => { - expect(() => parseCreateUploadResult("123", new Headers())).toThrow( - "upload succeeded but the metabase-table-id header was empty", - ); - }); -}); diff --git a/src/commands/upload/requests.ts b/src/commands/upload/requests.ts deleted file mode 100644 index 036e57b..0000000 --- a/src/commands/upload/requests.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Client } from "../../core/http/client"; -import { buildCsvFormData, type CsvFile } from "../../runtime/upload"; - -import { UploadResult, type UploadUpdateAction, type UploadUpdateResult } from "./results"; - -export const UPLOAD_TABLE_ID_HEADER = "metabase-table-id"; - -const UPLOAD_UPDATE_PATHS: Record = { - append: "append-csv", - replace: "replace-csv", -}; - -export async function updateTableFromCsv( - client: Client, - tableId: number, - action: UploadUpdateAction, - file: CsvFile, -): Promise { - await client.requestRaw(`/api/table/${tableId}/${UPLOAD_UPDATE_PATHS[action]}`, { - method: "POST", - body: buildCsvFormData(file), - expectContentType: "binary", - }); - return { table_id: tableId, action }; -} - -export function parseCreateUploadResult(bodyText: string, headers: Headers): UploadResult { - return UploadResult.parse({ - model_id: parseResponseInteger(bodyText, "response body"), - table_id: parseResponseInteger( - headers.get(UPLOAD_TABLE_ID_HEADER), - `${UPLOAD_TABLE_ID_HEADER} header`, - ), - }); -} - -function parseResponseInteger(value: string | null, source: string): number { - const trimmed = value?.trim() ?? ""; - if (trimmed === "") { - throw new Error(`upload succeeded but the ${source} was empty`); - } - const parsed = Number(trimmed); - if (!Number.isInteger(parsed)) { - throw new Error( - `upload succeeded but the ${source} was not an integer: ${JSON.stringify(trimmed)}`, - ); - } - return parsed; -} diff --git a/src/core/auth/verify.ts b/src/core/auth/verify.ts deleted file mode 100644 index ed593c5..0000000 --- a/src/core/auth/verify.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { CurrentUser } from "../../domain/user"; -import { errorMessage, MetabaseError, NetworkError, TimeoutError } from "../errors"; -import { createClient } from "../http/client"; -import { HttpError } from "../http/errors"; -import { probeServer, PROBE_TIMEOUT_MS, type ServerInfo } from "../version/probe"; - -import type { Credential, CredentialRefresher } from "./credential"; -import { type ProbedUser, type ProfileFailureKind } from "./profile-record"; - -const VERIFY_TIMEOUT_MS = 15_000; -const USER_PATH = "/api/user/current"; - -export type VerifyWhich = "user" | "server"; - -export interface VerifySuccess { - ok: true; - user: ProbedUser; - server: ServerInfo; -} - -export interface VerifyFailure { - ok: false; - which: VerifyWhich; - kind: ProfileFailureKind; - status?: number; - message: string; -} - -export type Verification = VerifySuccess | VerifyFailure; - -export async function verifyAndProbe( - url: string, - credential: Credential, - refresh?: CredentialRefresher, -): Promise { - const client = createClient( - { url, credential }, - refresh === undefined ? {} : { refreshCredential: refresh }, - ); - const userPromise = client.requestParsed(CurrentUser, USER_PATH, { - timeoutMs: VERIFY_TIMEOUT_MS, - retries: 0, - }); - const serverPromise = probeServer(client); - const [userResult, serverResult] = await Promise.allSettled([userPromise, serverPromise]); - - if (userResult.status === "rejected") { - return failure(userResult.reason, "user"); - } - if (serverResult.status === "rejected") { - return failure(serverResult.reason, "server"); - } - - const probed: ProbedUser = { - id: userResult.value.id, - name: userResult.value.common_name, - isAdmin: userResult.value.is_superuser, - }; - return { ok: true, user: probed, server: serverResult.value }; -} - -function failure(error: unknown, which: VerifyWhich): VerifyFailure { - if (error instanceof HttpError) { - const kind = error.status === 401 || error.status === 403 ? "auth" : "server"; - return { ok: false, which, kind, status: error.status, message: error.userMessage }; - } - if (error instanceof NetworkError || error instanceof TimeoutError) { - return { ok: false, which, kind: "network", message: error.userMessage }; - } - if (error instanceof MetabaseError) { - return { ok: false, which, kind: "server", message: error.userMessage }; - } - return { ok: false, which, kind: "server", message: errorMessage(error) }; -} - -export { PROBE_TIMEOUT_MS, VERIFY_TIMEOUT_MS }; diff --git a/src/core/http/client.test.ts b/src/core/http/client.test.ts deleted file mode 100644 index 57c647a..0000000 --- a/src/core/http/client.test.ts +++ /dev/null @@ -1,658 +0,0 @@ -import { setTimeout as delay } from "node:timers/promises"; - -import { assert, describe, expect, it } from "vitest"; -import { z } from "zod"; - -import packageJson from "../../../package.json" with { type: "json" }; -import type { OAuthCredential } from "../auth/credential"; -import { NetworkError, ResponseShapeError, TimeoutError } from "../errors"; -import { type ClientCredentials, createClient } from "./client"; -import { HttpError } from "./errors"; -import { captureFetch, jsonResponse } from "./fetch-capture"; - -const CONFIG: ClientCredentials = { - url: "https://m.example.com", - credential: { kind: "apiKey", apiKey: "mb_test_key_abcdef0123" }, -}; - -const PingResponse = z.object({ id: z.number().int(), email: z.string() }); - -const HANGING_FETCH: typeof fetch = (_input, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => { - reject(new DOMException("Aborted", "AbortError")); - }); - }); - -describe("createClient.requestParsed", () => { - it("returns parsed JSON on a 200 response", async () => { - const fakeFetch = captureFetch([jsonResponse({ id: 1, email: "a@b.com" })]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const result = await client.requestParsed(PingResponse, "/api/user/current"); - - expect(result).toEqual({ id: 1, email: "a@b.com" }); - expect(fakeFetch.calls).toEqual([ - { - url: "https://m.example.com/api/user/current", - method: "GET", - headers: { - "x-api-key": "mb_test_key_abcdef0123", - accept: "application/json", - "user-agent": `metabase-cli/${packageJson.version}`, - }, - body: null, - }, - ]); - }); - - it("retries 5xx responses and returns the eventual success body", async () => { - const fakeFetch = captureFetch([ - new Response("oops", { status: 500 }), - new Response("oops again", { status: 502 }), - jsonResponse({ id: 7, email: "u@m.com" }), - ]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const result = await client.requestParsed(PingResponse, "/api/user/current", { retries: 3 }); - - expect(result).toEqual({ id: 7, email: "u@m.com" }); - expect(fakeFetch.calls.length).toBe(3); - }); - - it("throws HttpError when retries are exhausted", async () => { - const fakeFetch = captureFetch([ - new Response('{"message":"server boom"}', { - status: 500, - headers: { "content-type": "application/json" }, - }), - new Response('{"message":"server boom"}', { - status: 500, - headers: { "content-type": "application/json" }, - }), - new Response('{"message":"server boom"}', { - status: 500, - headers: { "content-type": "application/json" }, - }), - ]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const error = await client - .requestParsed(PingResponse, "/api/user/current", { retries: 2 }) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(HttpError); - assert(error instanceof HttpError, "expected HttpError"); - expect(error.status).toBe(500); - expect(error.userMessage).toBe("server boom"); - expect(fakeFetch.calls.length).toBe(3); - }); - - it("retries on 429 with Retry-After header", async () => { - const fakeFetch = captureFetch([ - new Response("rate", { status: 429, headers: { "retry-after": "0" } }), - jsonResponse({ id: 1, email: "a@b.com" }), - ]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const result = await client.requestParsed(PingResponse, "/api/user/current", { retries: 1 }); - - expect(result).toEqual({ id: 1, email: "a@b.com" }); - expect(fakeFetch.calls.length).toBe(2); - }); - - it("retries network failures", async () => { - const fakeFetch = captureFetch([ - new TypeError("fetch failed"), - jsonResponse({ id: 2, email: "x@y.com" }), - ]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const result = await client.requestParsed(PingResponse, "/api/user/current", { retries: 2 }); - - expect(result).toEqual({ id: 2, email: "x@y.com" }); - expect(fakeFetch.calls.length).toBe(2); - }); - - it("maps ECONNREFUSED to an actionable message and carries the code as the cause", async () => { - const failure = new TypeError("fetch failed"); - failure.cause = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:1"), { - code: "ECONNREFUSED", - }); - const fakeFetch = captureFetch([failure]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const error = await client - .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(NetworkError); - assert(error instanceof NetworkError, "expected NetworkError"); - expect(error.userMessage).toBe( - "Could not reach Metabase: Connection refused by m.example.com — is Metabase running and is the port correct?", - ); - expect(error.developerDetail).toEqual({ - method: "GET", - url: "https://m.example.com/api/user/current", - cause: "ECONNREFUSED", - }); - }); - - it("maps ENOTFOUND to a host-not-found message", async () => { - const failure = new TypeError("fetch failed"); - failure.cause = Object.assign(new Error("getaddrinfo ENOTFOUND m.example.com"), { - code: "ENOTFOUND", - }); - const fakeFetch = captureFetch([failure]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const error = await client - .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(NetworkError); - assert(error instanceof NetworkError, "expected NetworkError"); - expect(error.userMessage).toBe( - "Could not reach Metabase: Host not found: m.example.com — check the URL.", - ); - expect(error.developerDetail).toEqual({ - method: "GET", - url: "https://m.example.com/api/user/current", - cause: "ENOTFOUND", - }); - }); - - it("falls back to the raw fetch message when the cause carries no error code", async () => { - const fakeFetch = captureFetch([new TypeError("fetch failed")]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const error = await client - .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(NetworkError); - assert(error instanceof NetworkError, "expected NetworkError"); - expect(error.userMessage).toBe("Could not reach Metabase: fetch failed"); - expect(error.developerDetail).toEqual({ - method: "GET", - url: "https://m.example.com/api/user/current", - cause: "fetch failed", - }); - }); - - it("throws ResponseShapeError carrying the request context and the schema's zod issues", async () => { - const body = { id: "not-a-number", email: "a@b.com" }; - const expectedIssues = PingResponse.safeParse(body).error?.issues; - assert(expectedIssues !== undefined, "expected zod failure for fixture body"); - const fakeFetch = captureFetch([jsonResponse(body)]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const error = await client - .requestParsed(PingResponse, "/api/user/current") - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(ResponseShapeError); - assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); - expect(error.userMessage).toBe( - "Metabase returned unexpected response shape:\n" + - " id: Invalid input: expected number, received string", - ); - expect(error.developerDetail).toEqual({ - method: "GET", - url: "https://m.example.com/api/user/current", - status: 200, - zodIssues: expectedIssues, - serverTag: null, - }); - }); - - it("threads getServerTag into ResponseShapeError so the lead names the version", async () => { - const body = { id: "not-a-number", email: "a@b.com" }; - const expectedIssues = PingResponse.safeParse(body).error?.issues; - assert(expectedIssues !== undefined, "expected zod failure for fixture body"); - const fakeFetch = captureFetch([jsonResponse(body)]); - const client = createClient(CONFIG, { - fetchImpl: fakeFetch.fetch, - getServerTag: async () => "v0.58.7", - }); - - const error = await client - .requestParsed(PingResponse, "/api/user/current") - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(ResponseShapeError); - assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); - expect(error.developerDetail).toEqual({ - method: "GET", - url: "https://m.example.com/api/user/current", - status: 200, - zodIssues: expectedIssues, - serverTag: "v0.58.7", - }); - expect(error.userMessage).toBe( - "On Metabase v0.58.7 the response shape was unexpected:\n" + - " id: Invalid input: expected number, received string", - ); - }); - - it("threads getServerTag into HttpError so route-missing names the version", async () => { - const fakeFetch = captureFetch([ - new Response("API endpoint does not exist.", { - status: 404, - headers: { "content-type": "text/plain" }, - }), - ]); - const client = createClient(CONFIG, { - fetchImpl: fakeFetch.fetch, - getServerTag: async () => "v0.58.7", - }); - - const error = await client - .requestParsed(PingResponse, "/api/this-does-not-exist", { retries: 0 }) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(HttpError); - assert(error instanceof HttpError, "expected HttpError"); - expect(error.kind).toBe("route-missing"); - expect(error.userMessage).toBe( - "This endpoint is not available on Metabase v0.58.7: GET /api/this-does-not-exist. " + - "The command may require a newer Metabase major version. " + - "Run 'mb auth list' to see this server's version.", - ); - }); - - it("throws HttpError on content-type mismatch with no silent downgrade", async () => { - const fakeFetch = captureFetch([ - new Response("oops", { status: 200, headers: { "content-type": "text/html" } }), - ]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const error = await client - .requestParsed(PingResponse, "/api/user/current") - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(HttpError); - assert(error instanceof HttpError, "expected HttpError"); - expect(error.developerDetail.body).toBeNull(); - expect(error.userMessage).toBe("Expected json response but got text/html"); - }); - - it("throws HttpError when the response has no content-type header", async () => { - const fakeFetch = captureFetch([ - () => { - const response = new Response('{"id":1,"email":"a@b"}', { status: 200 }); - response.headers.delete("content-type"); - return response; - }, - ]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const error = await client - .requestParsed(PingResponse, "/api/user/current") - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(HttpError); - assert(error instanceof HttpError, "expected HttpError"); - expect(error.userMessage).toBe("Expected json response but got no content-type"); - }); - - it("times out a hung request and throws TimeoutError", async () => { - const client = createClient(CONFIG, { fetchImpl: HANGING_FETCH }); - - const error = await client - .requestParsed(PingResponse, "/api/user/current", { timeoutMs: 25, retries: 0 }) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(TimeoutError); - assert(error instanceof TimeoutError, "expected TimeoutError"); - expect(error.developerDetail.timeoutMs).toBe(25); - }); -}); - -describe("createClient idempotency-aware retry", () => { - it("does not retry POST on 5xx and surfaces the first HttpError", async () => { - const fakeFetch = captureFetch([ - new Response('{"message":"server boom"}', { - status: 500, - headers: { "content-type": "application/json" }, - }), - ]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const error = await client - .requestParsed(PingResponse, "/api/transform/run", { method: "POST", retries: 3 }) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(HttpError); - assert(error instanceof HttpError, "expected HttpError"); - expect(error.status).toBe(500); - expect(error.userMessage).toBe("server boom"); - expect(fakeFetch.calls.length).toBe(1); - }); - - it("does not retry POST on 429 either — non-idempotent never retries on status", async () => { - const fakeFetch = captureFetch([ - new Response('{"message":"slow down"}', { - status: 429, - headers: { "content-type": "application/json", "retry-after": "0" }, - }), - ]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const error = await client - .requestParsed(PingResponse, "/api/card", { method: "POST", retries: 3 }) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(HttpError); - assert(error instanceof HttpError, "expected HttpError"); - expect(error.status).toBe(429); - expect(error.userMessage).toBe("slow down"); - expect(fakeFetch.calls.length).toBe(1); - }); - - it("retries POST on network failure", async () => { - const fakeFetch = captureFetch([ - new TypeError("fetch failed"), - jsonResponse({ id: 9, email: "ok@m.com" }), - ]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const result = await client.requestParsed(PingResponse, "/api/card", { - method: "POST", - retries: 2, - }); - - expect(result).toEqual({ id: 9, email: "ok@m.com" }); - expect(fakeFetch.calls.length).toBe(2); - }); - - it("retries non-idempotent calls when the caller explicitly opts in via idempotent: true", async () => { - const fakeFetch = captureFetch([ - new Response("oops", { status: 503 }), - jsonResponse({ id: 4, email: "p@u.com" }), - ]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const result = await client.requestParsed(PingResponse, "/api/card", { - method: "POST", - idempotent: true, - retries: 2, - }); - - expect(result).toEqual({ id: 4, email: "p@u.com" }); - expect(fakeFetch.calls.length).toBe(2); - }); - - it("does not retry GET on 5xx when the caller forces idempotent: false", async () => { - const fakeFetch = captureFetch([ - new Response('{"message":"down"}', { - status: 503, - headers: { "content-type": "application/json" }, - }), - ]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const error = await client - .requestParsed(PingResponse, "/api/user/current", { idempotent: false, retries: 3 }) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(HttpError); - assert(error instanceof HttpError, "expected HttpError"); - expect(error.status).toBe(503); - expect(error.userMessage).toBe("down"); - expect(fakeFetch.calls.length).toBe(1); - }); -}); - -describe("createClient sanitization", () => { - it("strips the configured apiKey from error bodies", async () => { - const apiKey = "configured_api_key_value"; - const fakeFetch = captureFetch([ - new Response(`{"message":"forbidden","echo":"${apiKey}"}`, { - status: 403, - headers: { "content-type": "application/json" }, - }), - ]); - const client = createClient( - { url: "https://m.example.com", credential: { kind: "apiKey", apiKey } }, - { fetchImpl: fakeFetch.fetch }, - ); - - const error = await client - .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(HttpError); - assert(error instanceof HttpError, "expected HttpError"); - expect(error.developerDetail.body).toBe('{"message":"forbidden","echo":"[REDACTED]"}'); - }); - - it("redacts secret response headers in HttpError detail", async () => { - const fakeFetch = captureFetch([ - new Response('{"message":"forbidden"}', { - status: 403, - headers: { - "content-type": "application/json", - "x-metabase-session": "session-abc", - "set-cookie": "session=foo", - }, - }), - ]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - const error = await client - .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) - .catch((caught: unknown) => caught); - - expect(error).toBeInstanceOf(HttpError); - assert(error instanceof HttpError, "expected HttpError"); - expect(error.developerDetail.responseHeaders).toEqual({ - "content-type": "application/json", - "x-metabase-session": "[REDACTED]", - "set-cookie": "[REDACTED]", - }); - }); -}); - -describe("createClient query encoding", () => { - it("encodes array query values as repeated keys and skips undefined entries", async () => { - const fakeFetch = captureFetch([jsonResponse({ id: 1, email: "a@b.com" })]); - const client = createClient(CONFIG, { fetchImpl: fakeFetch.fetch }); - - await client.requestParsed(PingResponse, "/api/search", { - query: { - models: ["card", "dashboard"], - q: "x", - archived: undefined, - }, - }); - - expect(fakeFetch.calls[0]?.url).toBe( - "https://m.example.com/api/search?models=card&models=dashboard&q=x", - ); - }); -}); - -const OAUTH: OAuthCredential = { - kind: "oauth", - accessToken: "acc-1", - refreshToken: "ref-1", - expiresAt: "2026-06-08T13:00:00.000Z", - clientId: "c1", -}; - -function unauthorizedResponse(): Response { - return new Response('{"error":"unauthorized"}', { - status: 401, - headers: { "content-type": "application/json" }, - }); -} - -describe("createClient OAuth bearer auth", () => { - it("sends an Authorization Bearer header and no x-api-key", async () => { - const fakeFetch = captureFetch([jsonResponse({ id: 1, email: "a@b.c" })]); - const client = createClient( - { url: "https://m.example.com", credential: OAUTH }, - { fetchImpl: fakeFetch.fetch }, - ); - await client.requestParsed(PingResponse, "/api/user/current"); - expect(fakeFetch.calls[0]?.headers["authorization"]).toBe("Bearer acc-1"); - expect(fakeFetch.calls[0]?.headers["x-api-key"]).toBeUndefined(); - }); - - it("refreshes on 401 and replays the request with the new access token", async () => { - const fakeFetch = captureFetch([ - unauthorizedResponse(), - jsonResponse({ id: 1, email: "a@b.c" }), - ]); - const refreshed: OAuthCredential = { ...OAUTH, accessToken: "acc-2", refreshToken: "ref-2" }; - let refreshCalls = 0; - const client = createClient( - { url: "https://m.example.com", credential: OAUTH }, - { - fetchImpl: fakeFetch.fetch, - refreshCredential: async () => { - refreshCalls += 1; - return refreshed; - }, - }, - ); - const result = await client.requestParsed(PingResponse, "/api/user/current", { retries: 0 }); - expect(result).toEqual({ id: 1, email: "a@b.c" }); - expect(refreshCalls).toBe(1); - expect(fakeFetch.calls[0]?.headers["authorization"]).toBe("Bearer acc-1"); - expect(fakeFetch.calls[1]?.headers["authorization"]).toBe("Bearer acc-2"); - }); - - it("gives up after a single refresh when the replay still 401s", async () => { - const fakeFetch = captureFetch([unauthorizedResponse(), unauthorizedResponse()]); - let refreshCalls = 0; - const client = createClient( - { url: "https://m.example.com", credential: OAUTH }, - { - fetchImpl: fakeFetch.fetch, - refreshCredential: async () => { - refreshCalls += 1; - return { ...OAUTH, accessToken: "acc-2" }; - }, - }, - ); - const error = await client - .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) - .catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(HttpError); - assert(error instanceof HttpError, "expected HttpError"); - expect(error.status).toBe(401); - expect(refreshCalls).toBe(1); - expect(fakeFetch.calls).toHaveLength(2); - }); - - it("shares a single refresh across concurrent 401s", async () => { - const fakeFetch = captureFetch([ - unauthorizedResponse(), - unauthorizedResponse(), - jsonResponse({ id: 1, email: "a@b.c" }), - jsonResponse({ id: 1, email: "a@b.c" }), - ]); - let refreshCalls = 0; - const client = createClient( - { url: "https://m.example.com", credential: OAUTH }, - { - fetchImpl: fakeFetch.fetch, - refreshCredential: async () => { - refreshCalls += 1; - // Yield a macrotask so both 401 handlers (pure microtask chains) join this refresh - // before it settles — the deterministic worst case for a duplicate-refresh bug. - await delay(0); - return { ...OAUTH, accessToken: "acc-2" }; - }, - }, - ); - const results = await Promise.all([ - client.requestParsed(PingResponse, "/api/user/current", { retries: 0 }), - client.requestParsed(PingResponse, "/api/user/current", { retries: 0 }), - ]); - expect(results).toEqual([ - { id: 1, email: "a@b.c" }, - { id: 1, email: "a@b.c" }, - ]); - expect(refreshCalls).toBe(1); - expect(fakeFetch.calls[2]?.headers["authorization"]).toBe("Bearer acc-2"); - expect(fakeFetch.calls[3]?.headers["authorization"]).toBe("Bearer acc-2"); - }); - - it("refreshes again on a later request when the token expires a second time", async () => { - const fakeFetch = captureFetch([ - unauthorizedResponse(), - jsonResponse({ id: 1, email: "a@b.c" }), - unauthorizedResponse(), - jsonResponse({ id: 1, email: "a@b.c" }), - ]); - let refreshCalls = 0; - const client = createClient( - { url: "https://m.example.com", credential: OAUTH }, - { - fetchImpl: fakeFetch.fetch, - refreshCredential: async () => { - refreshCalls += 1; - return { ...OAUTH, accessToken: `acc-${refreshCalls + 1}` }; - }, - }, - ); - await client.requestParsed(PingResponse, "/api/user/current", { retries: 0 }); - await client.requestParsed(PingResponse, "/api/user/current", { retries: 0 }); - // Each expiry event gets its own refresh — no per-client "refresh only once" latch. - expect(refreshCalls).toBe(2); - }); - - it("does not refresh an API key credential on 401", async () => { - const fakeFetch = captureFetch([unauthorizedResponse()]); - let refreshCalls = 0; - const client = createClient( - { url: "https://m.example.com", credential: { kind: "apiKey", apiKey: "k" } }, - { - fetchImpl: fakeFetch.fetch, - refreshCredential: async () => { - refreshCalls += 1; - return null; - }, - }, - ); - await client - .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) - .catch(() => undefined); - expect(refreshCalls).toBe(0); - expect(fakeFetch.calls).toHaveLength(1); - }); - - it("redacts both OAuth tokens from error bodies", async () => { - const fakeFetch = captureFetch([ - new Response('{"echo":"acc-1 and ref-1"}', { - status: 403, - headers: { "content-type": "application/json" }, - }), - ]); - const client = createClient( - { url: "https://m.example.com", credential: OAUTH }, - { fetchImpl: fakeFetch.fetch }, - ); - const error = await client - .requestParsed(PingResponse, "/api/user/current", { retries: 0 }) - .catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(HttpError); - assert(error instanceof HttpError, "expected HttpError"); - expect(error.developerDetail.body).toBe('{"echo":"[REDACTED] and [REDACTED]"}'); - }); -}); - -describe("createClient subpath base URLs", () => { - it("joins request paths under a base URL that carries a subpath", async () => { - const fakeFetch = captureFetch([jsonResponse({ id: 1, email: "a@b.c" })]); - const client = createClient( - { url: "https://my.org.com/metabase", credential: { kind: "apiKey", apiKey: "k" } }, - { fetchImpl: fakeFetch.fetch }, - ); - await client.requestParsed(PingResponse, "/api/user/current"); - expect(fakeFetch.calls[0]?.url).toBe("https://my.org.com/metabase/api/user/current"); - }); -}); diff --git a/src/core/http/fake-client.ts b/src/core/http/fake-client.ts deleted file mode 100644 index 49a621e..0000000 --- a/src/core/http/fake-client.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { ZodType } from "zod"; - -import type { Client, RequestOptions } from "./client"; - -export interface FakeClientPlan { - readonly responses?: ReadonlyMap; - readonly errors?: ReadonlyMap; -} - -export interface FakeClientCall { - readonly path: string; - readonly options: RequestOptions | undefined; -} - -export interface FakeClient { - readonly client: Client; - readonly calls: ReadonlyArray; -} - -export function createFakeClient(plan: FakeClientPlan = {}): FakeClient { - const calls: FakeClientCall[] = []; - const client: Client = { - async requestParsed(schema: ZodType, path: string, options?: RequestOptions): Promise { - calls.push({ path, options }); - const failure = plan.errors?.get(path); - if (failure !== undefined) { - throw failure; - } - const response = plan.responses?.get(path); - if (response === undefined) { - throw new Error(`unexpected path: ${path}`); - } - return schema.parse(response); - }, - async requestRaw() { - throw new Error("requestRaw not implemented in fake client"); - }, - async requestStream() { - throw new Error("requestStream not implemented in fake client"); - }, - }; - return { client, calls }; -} diff --git a/src/core/http/probe.ts b/src/core/http/probe.ts deleted file mode 100644 index b15bf5f..0000000 --- a/src/core/http/probe.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface ProbeResult { - ready: boolean; - status: number | null; -} - -export async function probeHealth(url: string, timeoutMs: number): Promise { - try { - const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); - return { ready: response.ok, status: response.status }; - } catch (error) { - // Connection refused, DNS, TLS, AbortError from the timeout — all valid - // "not ready yet" signals during boot. Re-throw anything that isn't a - // standard Error so genuine bugs (URL constructor failures, non-Error - // throws) don't get silently treated as "still booting forever". - if (error instanceof Error) { - return { ready: false, status: null }; - } - throw error; - } -} diff --git a/src/core/version/tag.ts b/src/core/version/tag.ts deleted file mode 100644 index 8d8e4b5..0000000 --- a/src/core/version/tag.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { parse as parseSemver } from "semver"; -import { z } from "zod"; - -export const ParsedVersionSchema = z.object({ - tag: z.string(), - major: z.number().int().nonnegative(), - patch: z.number().int().nonnegative(), -}); -export type ParsedVersion = z.infer; - -export function tryParseTag(tag: string): ParsedVersion | null { - const parsed = parseSemver(tag); - if (parsed === null || (parsed.major !== 0 && parsed.major !== 1)) { - return null; - } - return { - tag, - major: parsed.minor, - patch: parsed.patch, - }; -} diff --git a/src/domain/git-sync.ts b/src/domain/git-sync.ts deleted file mode 100644 index 79f39f9..0000000 --- a/src/domain/git-sync.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { z } from "zod"; - -import type { ResourceView } from "./view"; - -export const SyncTaskStatus = z.enum([ - "running", - "successful", - "errored", - "cancelled", - "timed-out", - "conflict", -]); -export type SyncTaskStatus = z.infer; - -export const SyncTaskType = z.enum(["import", "export"]); -export type SyncTaskType = z.infer; - -export const SyncTask = z - .object({ - id: z.number().int().positive(), - sync_task_type: SyncTaskType, - status: SyncTaskStatus, - progress: z.number().min(0).max(1).nullable(), - started_at: z.string(), - ended_at: z.string().nullable().optional(), - last_progress_report_at: z.string().nullable().optional(), - version: z.string().nullable().optional(), - initiated_by: z.number().int().positive().nullable().optional(), - cancelled: z.boolean().nullable().optional(), - error_message: z.string().nullable().optional(), - conflicts: z.array(z.string()).nullable().optional(), - }) - .loose(); -export type SyncTask = z.infer; - -export const SyncTaskCompact = SyncTask.pick({ - id: true, - sync_task_type: true, - status: true, - progress: true, - version: true, - error_message: true, -}).strip(); -export type SyncTaskCompact = z.infer; - -export const syncTaskView: ResourceView = { - compactPick: SyncTaskCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "sync_task_type", label: "Type" }, - { key: "status", label: "Status" }, - { key: "progress", label: "Progress" }, - { key: "version", label: "Version" }, - { key: "error_message", label: "Error" }, - ], -}; - -export const SyncDirtyItem = z - .object({ - id: z.number().int(), - name: z.string().nullable(), - model: z.string(), - sync_status: z.string(), - collection_id: z.number().int().positive().nullable().optional(), - description: z.string().nullable().optional(), - display: z.string().nullable().optional(), - query_type: z.string().nullable().optional(), - table_id: z.number().int().positive().nullable().optional(), - table_name: z.string().nullable().optional(), - }) - .loose(); -export type SyncDirtyItem = z.infer; - -export const SyncDirtyItemCompact = SyncDirtyItem.pick({ - id: true, - name: true, - model: true, - sync_status: true, - collection_id: true, -}).strip(); -export type SyncDirtyItemCompact = z.infer; - -export const syncDirtyItemView: ResourceView = { - compactPick: SyncDirtyItemCompact, - tableColumns: [ - { key: "id", label: "ID" }, - { key: "model", label: "Model" }, - { key: "name", label: "Name" }, - { key: "sync_status", label: "Status" }, - { key: "collection_id", label: "Collection" }, - ], -}; diff --git a/src/output/cap.test.ts b/src/output/cap.test.ts deleted file mode 100644 index def5f7d..0000000 --- a/src/output/cap.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import * as fc from "fast-check"; -import { describe, expect, it } from "vitest"; - -import { capListEnvelope } from "./cap"; -import type { ListEnvelope } from "./types"; - -interface Item { - id: number; - name: string; -} - -function envelope(items: Item[]): ListEnvelope { - return { data: items, returned: items.length, total: items.length }; -} - -function byteLength(value: unknown): number { - return Buffer.byteLength(JSON.stringify(value), "utf8"); -} - -describe("capListEnvelope", () => { - it("returns input unchanged when maxBytes is 0", () => { - const input = envelope([ - { id: 1, name: "a" }, - { id: 2, name: "b" }, - ]); - expect(capListEnvelope(input, 0)).toEqual(input); - }); - - it("returns input unchanged when fully under cap", () => { - const input = envelope([{ id: 1, name: "a" }]); - expect(capListEnvelope(input, 10_000)).toEqual(input); - }); - - it("drops tail items when over cap, never partial", () => { - const input = envelope([ - { id: 1, name: "a".repeat(100) }, - { id: 2, name: "b".repeat(100) }, - { id: 3, name: "c".repeat(100) }, - ]); - const result = capListEnvelope(input, 200); - expect( - result.data.every((item) => typeof item.name === "string" && item.name.length === 100), - ).toBe(true); - expect(result.returned).toBe(result.data.length); - expect(result.truncated).toEqual({ reason: "max_bytes", bytes: byteLength(input) }); - expect(byteLength(result)).toBeLessThanOrEqual(200); - }); - - it("can truncate to zero items when even one would not fit", () => { - const input = envelope([{ id: 1, name: "x".repeat(500) }]); - const result = capListEnvelope(input, 50); - expect(result.data).toEqual([]); - expect(result.returned).toBe(0); - expect(result.truncated).toEqual({ reason: "max_bytes", bytes: byteLength(input) }); - }); - - it("preserves total/limit fields after truncation", () => { - const input: ListEnvelope = { - data: [ - { id: 1, name: "a".repeat(50) }, - { id: 2, name: "b".repeat(50) }, - { id: 3, name: "c".repeat(50) }, - ], - returned: 3, - total: 17, - limit: 50, - }; - const result = capListEnvelope(input, 100); - expect(result.total).toBe(17); - expect(result.limit).toBe(50); - }); - - it("property: a generously-sized cap fits and returns input unchanged when input fits", () => { - fc.assert( - fc.property( - fc.array(fc.record({ id: fc.integer(), name: fc.string() }), { - minLength: 1, - maxLength: 30, - }), - fc.integer({ min: 200, max: 20_000 }), - (items, maxBytes) => { - const input = envelope(items); - const result = capListEnvelope(input, maxBytes); - if (byteLength(input) <= maxBytes) { - expect(result).toEqual(input); - return; - } - expect(result.returned).toBe(result.data.length); - expect(result.truncated?.bytes).toBe(byteLength(input)); - if (result.data.length > 0) { - expect(byteLength(result)).toBeLessThanOrEqual(maxBytes); - } - }, - ), - ); - }); - - it("property: truncated data is always a prefix of input data", () => { - fc.assert( - fc.property( - fc.array(fc.record({ id: fc.integer(), name: fc.string() }), { - minLength: 1, - maxLength: 20, - }), - fc.integer({ min: 30, max: 1_000 }), - (items, maxBytes) => { - const result = capListEnvelope(envelope(items), maxBytes); - for (let index = 0; index < result.data.length; index += 1) { - expect(result.data[index]).toEqual(items[index]); - } - }, - ), - ); - }); -}); diff --git a/src/output/cap.ts b/src/output/cap.ts deleted file mode 100644 index e9a3f64..0000000 --- a/src/output/cap.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ListEnvelope } from "./types"; - -export function capListEnvelope(envelope: ListEnvelope, maxBytes: number): ListEnvelope { - if (maxBytes <= 0) { - return envelope; - } - const fullBytes = jsonByteLength(envelope); - if (fullBytes <= maxBytes) { - return envelope; - } - - let lo = 0; - let hi = envelope.data.length; - while (lo < hi) { - const mid = Math.ceil((lo + hi) / 2); - if (jsonByteLength(truncate(envelope, mid, fullBytes)) <= maxBytes) { - lo = mid; - } else { - hi = mid - 1; - } - } - return truncate(envelope, lo, fullBytes); -} - -function truncate( - envelope: ListEnvelope, - count: number, - originalBytes: number, -): ListEnvelope { - return { - ...envelope, - data: envelope.data.slice(0, count), - returned: count, - truncated: { reason: "max_bytes", bytes: originalBytes }, - }; -} - -function jsonByteLength(value: unknown): number { - return Buffer.byteLength(JSON.stringify(value), "utf8"); -} diff --git a/src/output/error.test.ts b/src/output/error.test.ts deleted file mode 100644 index 5b797ae..0000000 --- a/src/output/error.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { afterEach, assert, beforeEach, describe, expect, it, vi } from "vitest"; -import { z } from "zod"; - -import { - AbortError, - ConfigError, - ResponseShapeError, - UnknownError, - ValidationError, -} from "../core/errors"; -import { reportError } from "./error"; - -interface CapturedStreams { - stderr: string; -} - -let streams: CapturedStreams; -const originalExitCode = process.exitCode; -const originalVerbose = process.env["MB_VERBOSE"]; - -beforeEach(() => { - streams = { stderr: "" }; - process.stderr.isTTY = false; - vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { - streams.stderr += String(chunk); - return true; - }); - process.exitCode = 0; - delete process.env["MB_VERBOSE"]; - delete process.env["METABASE_VERBOSE"]; -}); - -afterEach(() => { - vi.restoreAllMocks(); - process.exitCode = originalExitCode; - if (originalVerbose === undefined) { - delete process.env["MB_VERBOSE"]; - } else { - process.env["MB_VERBOSE"] = originalVerbose; - } -}); - -describe("reportError", () => { - it("writes the user message to stderr and sets exit code 2 for ConfigError", () => { - reportError(new ConfigError("missing TTY")); - expect(streams.stderr).toBe("missing TTY\n"); - expect(process.exitCode).toBe(2); - }); - - it("sets exit code 130 for AbortError", () => { - reportError(new AbortError()); - expect(streams.stderr).toBe("aborted\n"); - expect(process.exitCode).toBe(130); - }); - - it("sets exit code 1 for UnknownError wrapping a generic Error", () => { - reportError(new Error("kaboom")); - expect(streams.stderr).toBe("kaboom\n(rerun with MB_VERBOSE=1 for details)\n"); - expect(process.exitCode).toBe(1); - }); - - it("appends the verbose breadcrumb (not the detail) when MB_VERBOSE is unset and detail exists", () => { - reportError(new UnknownError({ originalMessage: "boom", stack: "trace" })); - expect(streams.stderr).toBe("boom\n(rerun with MB_VERBOSE=1 for details)\n"); - }); - - it("appends developerDetail JSON when MB_VERBOSE=1 and the error carries detail", () => { - process.env["MB_VERBOSE"] = "1"; - reportError(new UnknownError({ originalMessage: "boom", stack: "trace" })); - expect(streams.stderr).toBe( - "boom\n" + JSON.stringify({ originalMessage: "boom", stack: "trace" }) + "\n", - ); - }); - - it("does not append developerDetail JSON when MB_VERBOSE=1 but detail is null", () => { - process.env["MB_VERBOSE"] = "1"; - reportError(new ConfigError("nope")); - expect(streams.stderr).toBe("nope\n"); - expect(process.exitCode).toBe(2); - }); - - it("normalizes a non-MetabaseError value (string) into an UnknownError envelope", () => { - reportError("plain string"); - expect(streams.stderr).toBe("plain string\n(rerun with MB_VERBOSE=1 for details)\n"); - expect(process.exitCode).toBe(1); - }); - - it("prints the JSON-pointer issue path on the stderr line beneath the ValidationError header", () => { - const schema = z.object({ total: z.number() }); - const result = schema.safeParse({ total: null }); - assert(!result.success, "expected zod failure"); - reportError( - new ValidationError( - "https://m.example.com/api/collection/8/items: value did not match expected schema", - { - source: "https://m.example.com/api/collection/8/items", - zodIssues: result.error.issues, - }, - ), - ); - expect(streams.stderr).toBe( - "https://m.example.com/api/collection/8/items: value did not match expected schema\n" + - " /total: Invalid input: expected number, received null\n" + - "(rerun with MB_VERBOSE=1 for details)\n", - ); - expect(process.exitCode).toBe(1); - }); - - it("prints the ResponseShapeError lead and field paths and exits 1", () => { - const schema = z.object({ version: z.object({ tag: z.string() }) }); - const result = schema.safeParse({ version: {} }); - assert(!result.success, "expected zod failure"); - const error = new ResponseShapeError({ - method: "GET", - url: "https://m.example.com/api/session/properties", - status: 200, - zodIssues: result.error.issues, - serverTag: null, - }); - - reportError(error); - - expect(streams.stderr).toBe( - "Metabase returned unexpected response shape:\n" + - " version.tag: Invalid input: expected string, received undefined\n" + - "(rerun with MB_VERBOSE=1 for details)\n", - ); - expect(process.exitCode).toBe(1); - }); - - it("emits a JSON error envelope to stderr (no detail) when format is json", () => { - reportError(new ConfigError("missing TTY"), "json"); - const expected = - JSON.stringify({ - ok: false, - error: { category: "config", message: "missing TTY", exitCode: 2 }, - }) + "\n"; - expect(streams.stderr).toBe(expected); - expect(process.exitCode).toBe(2); - }); - - it("omits detail from the JSON error envelope when MB_VERBOSE is unset", () => { - reportError(new UnknownError({ originalMessage: "boom", stack: "trace" }), "json"); - const expected = - JSON.stringify({ - ok: false, - error: { category: "unknown", message: "boom", exitCode: 1 }, - }) + "\n"; - expect(streams.stderr).toBe(expected); - expect(process.exitCode).toBe(1); - }); - - it("includes detail in the JSON error envelope when MB_VERBOSE=1", () => { - process.env["MB_VERBOSE"] = "1"; - reportError(new UnknownError({ originalMessage: "boom", stack: "trace" }), "json"); - const expected = - JSON.stringify({ - ok: false, - error: { - category: "unknown", - message: "boom", - exitCode: 1, - detail: { originalMessage: "boom", stack: "trace" }, - }, - }) + "\n"; - expect(streams.stderr).toBe(expected); - }); -}); diff --git a/src/output/error.ts b/src/output/error.ts deleted file mode 100644 index 561c30e..0000000 --- a/src/output/error.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { consumeLegacyEnvWarnings, ENV_VERBOSE, readEnv } from "../core/env"; -import { toMetabaseError } from "../core/errors"; -import type { ErrorCategory, MetabaseError } from "../core/errors"; - -import { warn } from "./notice"; -import { serializeJson } from "./render"; -import type { Format } from "./types"; - -const VERBOSE_BREADCRUMB = "(rerun with MB_VERBOSE=1 for details)"; - -interface JsonErrorPayload { - category: ErrorCategory; - message: string; - exitCode: number; - detail?: unknown; -} - -interface JsonErrorEnvelope { - ok: false; - error: JsonErrorPayload; -} - -export function reportError(error: unknown, format?: Format): void { - const handled = toMetabaseError(error); - const verbose = readEnv(ENV_VERBOSE) === "1"; - if (format === "json") { - writeJsonError(handled, verbose); - } else { - writeTextError(handled, verbose); - } - for (const message of consumeLegacyEnvWarnings()) { - warn(message); - } - process.exitCode = handled.exitCode; -} - -function writeTextError(handled: MetabaseError, verbose: boolean): void { - process.stderr.write(handled.userMessage + "\n"); - if (handled.developerDetail === null) { - return; - } - if (verbose) { - process.stderr.write(serializeJson(handled.developerDetail, stderrPretty()) + "\n"); - } else { - process.stderr.write(VERBOSE_BREADCRUMB + "\n"); - } -} - -function stderrPretty(): boolean { - return process.stderr.isTTY === true; -} - -function writeJsonError(handled: MetabaseError, verbose: boolean): void { - const payload: JsonErrorPayload = { - category: handled.category, - message: handled.userMessage, - exitCode: handled.exitCode, - }; - if (verbose && handled.developerDetail !== null) { - payload.detail = handled.developerDetail; - } - const envelope: JsonErrorEnvelope = { ok: false, error: payload }; - process.stderr.write(serializeJson(envelope, stderrPretty()) + "\n"); -} diff --git a/src/output/notice.ts b/src/output/notice.ts deleted file mode 100644 index 7504ae6..0000000 --- a/src/output/notice.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function warn(message: string): void { - process.stderr.write(message + "\n"); -} - -export function listTruncationNotice(bytes: number): string { - return `… cut at ${bytes} bytes; narrow the selection or raise --max-bytes`; -} - -const ITEM_OVERSIZE_REMEDY = "narrow with --fields or raise the cap with --max-bytes "; - -export function itemOversizeMessage(bytes: number, maxBytes: number, hint?: string): string { - return `output is ${bytes} bytes, over the ${maxBytes}-byte --max-bytes cap; ${hint ?? ITEM_OVERSIZE_REMEDY}`; -} diff --git a/src/output/projection.ts b/src/output/projection.ts deleted file mode 100644 index 1a726d6..0000000 --- a/src/output/projection.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { ConfigError } from "../core/errors"; -import type { ResourceView } from "../domain/view"; -import { isPlainObject } from "../runtime/predicates"; - -export function applyProjection( - value: T, - view: ResourceView, - full: boolean, - fields: string[] | undefined, -): unknown { - if (fields !== undefined) { - if (fields.length === 0) { - throw new ConfigError("--fields requires at least one path"); - } - return projectFields(value, fields); - } - if (full) { - return value; - } - const parsed = view.compactPick.safeParse(value); - if (parsed.success) { - return parsed.data; - } - throw new ConfigError(`compact projection failed: ${parsed.error.message}`); -} - -function projectFields(value: unknown, fields: string[]): Record { - const out: Record = {}; - for (const path of fields) { - if (path.length === 0) { - throw new ConfigError(`empty field path`); - } - const parts = path.split("."); - if (parts.some((part) => part.length === 0)) { - throw new ConfigError(`invalid field path: "${path}"`); - } - setPath(out, parts, pickPath(value, parts)); - } - return out; -} - -export function pickPath(value: unknown, parts: string[]): unknown { - let cursor: unknown = value; - for (const part of parts) { - if (!isPlainObject(cursor) || !Object.hasOwn(cursor, part)) { - throw new ConfigError(`unknown field path: "${parts.join(".")}"`); - } - cursor = Reflect.get(cursor, part); - } - return cursor; -} - -function setPath(target: Record, parts: string[], value: unknown): void { - let cursor = target; - const lastIndex = parts.length - 1; - for (const [index, part] of parts.entries()) { - if (index === lastIndex) { - cursor[part] = value; - return; - } - const existing = cursor[part]; - if (isPlainObject(existing)) { - cursor = existing; - } else { - const next: Record = {}; - cursor[part] = next; - cursor = next; - } - } -} - -export { isPlainObject }; diff --git a/src/output/types.test.ts b/src/output/types.test.ts deleted file mode 100644 index 3821403..0000000 --- a/src/output/types.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { z } from "zod"; - -import { listEnvelopeSchema, wrapList, type ListEnvelope } from "./types"; - -const Person = z.object({ id: z.number().int(), name: z.string() }).strict(); - -describe("listEnvelopeSchema", () => { - it("accepts a minimal envelope with only data and returned", () => { - const schema = listEnvelopeSchema(Person); - const envelope = { data: [{ id: 1, name: "x" }], returned: 1 }; - expect(schema.parse(envelope)).toEqual(envelope); - }); - - it("accepts an envelope with all optional fields populated", () => { - const schema = listEnvelopeSchema(Person); - const envelope: ListEnvelope<{ id: number; name: string }> = { - data: [{ id: 1, name: "x" }], - returned: 1, - total: 50, - limit: 25, - truncated: { reason: "max_bytes", bytes: 4096 }, - }; - expect(schema.parse(envelope)).toEqual(envelope); - }); - - it("rejects an envelope whose items fail the item schema", () => { - const schema = listEnvelopeSchema(Person); - const result = schema.safeParse({ - data: [{ id: "not-a-number", name: "x" }], - returned: 1, - }); - expect(result.success).toBe(false); - }); - - it("rejects a negative returned count", () => { - const schema = listEnvelopeSchema(Person); - const result = schema.safeParse({ data: [], returned: -1 }); - expect(result.success).toBe(false); - }); - - it("rejects a non-integer total", () => { - const schema = listEnvelopeSchema(Person); - const result = schema.safeParse({ data: [], returned: 0, total: 1.5 }); - expect(result.success).toBe(false); - }); - - it("rejects truncated.reason that is not the literal 'max_bytes'", () => { - const schema = listEnvelopeSchema(Person); - const result = schema.safeParse({ - data: [], - returned: 0, - truncated: { reason: "wrong-reason", bytes: 100 }, - }); - expect(result.success).toBe(false); - }); -}); - -describe("wrapList", () => { - it("returns an envelope whose returned and total reflect the array length", () => { - expect( - wrapList([ - { id: 1, name: "a" }, - { id: 2, name: "b" }, - ]), - ).toEqual({ - data: [ - { id: 1, name: "a" }, - { id: 2, name: "b" }, - ], - returned: 2, - total: 2, - }); - }); - - it("returns a zero-length envelope for an empty input", () => { - expect(wrapList([])).toEqual({ data: [], returned: 0, total: 0 }); - }); - - it("composes with listEnvelopeSchema — wrapList output round-trips through the schema", () => { - const schema = listEnvelopeSchema(Person); - const envelope = wrapList([{ id: 1, name: "x" }]); - expect(schema.parse(envelope)).toEqual(envelope); - }); -}); diff --git a/src/output/types.ts b/src/output/types.ts deleted file mode 100644 index 2932d31..0000000 --- a/src/output/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z, type ZodType } from "zod"; - -export type Format = "json" | "text"; - -// Sized to stay under agent-harness tool-output truncation (~30k chars in Claude Code's Bash -// tool): the CLI's own cap must fire, with its teaching error, before the transport silently -// cuts JSON mid-object. -export const DEFAULT_MAX_BYTES = 24576; - -export interface TruncationInfo { - reason: "max_bytes"; - bytes: number; -} - -export interface ListEnvelope { - data: T[]; - returned: number; - total?: number | null | undefined; - limit?: number | undefined; - truncated?: TruncationInfo | undefined; -} - -export function listEnvelopeSchema(item: ZodType): ZodType> { - return z.object({ - data: z.array(item), - returned: z.number().int().nonnegative(), - total: z.number().int().nonnegative().nullable().optional(), - limit: z.number().int().nonnegative().optional(), - truncated: z - .object({ - reason: z.literal("max_bytes"), - bytes: z.number().int().nonnegative(), - }) - .optional(), - }); -} - -export function wrapList(items: T[]): ListEnvelope { - return { data: items, returned: items.length, total: items.length }; -} - -export interface RenderOptions { - format: Format; - full: boolean; - fields: string[] | undefined; - maxBytes: number; - oversizeHint?: string | undefined; -} diff --git a/src/runtime/capabilities.ts b/src/runtime/capabilities.ts deleted file mode 100644 index 9a3027c..0000000 --- a/src/runtime/capabilities.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from "zod"; - -export const Capabilities = z.object({ - minVersion: z.number(), - tokenFeature: z.string().optional(), -}); -export type Capabilities = z.infer; - -export const BASELINE_CAPABILITIES: Capabilities = Object.freeze({ - minVersion: 58, -}); diff --git a/src/runtime/paginate.test.ts b/src/runtime/paginate.test.ts deleted file mode 100644 index 8b0737a..0000000 --- a/src/runtime/paginate.test.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { assert, describe, expect, it } from "vitest"; -import { z } from "zod"; - -import { type ClientCredentials, createClient } from "../core/http/client"; -import { collectPaginated, paginate } from "./paginate"; - -const CONFIG: ClientCredentials = { - url: "https://m.example.com", - credential: { kind: "apiKey", apiKey: "mb_test_key" }, -}; - -const Card = z.object({ id: z.number().int(), name: z.string() }); - -interface FetchCallRecord { - url: string; - method: string; -} - -interface FakeFetchHandle { - fetch: typeof fetch; - calls: FetchCallRecord[]; -} - -interface FetchScriptResponse { - body: unknown; -} - -function makeFakeFetch(script: FetchScriptResponse[]): FakeFetchHandle { - const queue = [...script]; - const calls: FetchCallRecord[] = []; - const fetchImpl: typeof fetch = async (input, init) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - calls.push({ url, method: init?.method ?? "GET" }); - const next = queue.shift(); - assert(next !== undefined, "fakeFetch: no more responses queued"); - return new Response(JSON.stringify(next.body), { - headers: { "content-type": "application/json" }, - }); - }; - return { fetch: fetchImpl, calls }; -} - -function urlOf(call: FetchCallRecord): URL { - return new URL(call.url); -} - -function makeItems(count: number): Array> { - return Array.from({ length: count }, (_, index) => ({ id: index, name: `card-${index}` })); -} - -function pageOf(call: FetchCallRecord): { limit: string | null; offset: string | null } { - const params = urlOf(call).searchParams; - return { limit: params.get("limit"), offset: params.get("offset") }; -} - -describe("paginate", () => { - it("yields items from a single full page and stops when the page comes back smaller", async () => { - const handle = makeFakeFetch([ - { - body: { - data: [ - { id: 1, name: "a" }, - { id: 2, name: "b" }, - ], - total: 2, - }, - }, - ]); - const client = createClient(CONFIG, { fetchImpl: handle.fetch }); - - const items = await collectPaginated(client, "/api/card", Card, { pageSize: 50 }); - - expect(items).toEqual([ - { id: 1, name: "a" }, - { id: 2, name: "b" }, - ]); - expect(handle.calls.map(pageOf)).toEqual([{ limit: "50", offset: "0" }]); - }); - - it("walks multiple pages until total is reached", async () => { - const handle = makeFakeFetch([ - { - body: { - data: [ - { id: 1, name: "a" }, - { id: 2, name: "b" }, - ], - total: 5, - }, - }, - { - body: { - data: [ - { id: 3, name: "c" }, - { id: 4, name: "d" }, - ], - total: 5, - }, - }, - { body: { data: [{ id: 5, name: "e" }], total: 5 } }, - ]); - const client = createClient(CONFIG, { fetchImpl: handle.fetch }); - - const items = await collectPaginated(client, "/api/card", Card, { pageSize: 2 }); - - expect(items).toEqual([ - { id: 1, name: "a" }, - { id: 2, name: "b" }, - { id: 3, name: "c" }, - { id: 4, name: "d" }, - { id: 5, name: "e" }, - ]); - expect(handle.calls.map(pageOf)).toEqual([ - { limit: "2", offset: "0" }, - { limit: "2", offset: "2" }, - { limit: "2", offset: "4" }, - ]); - }); - - it("stops before fetching another page when max cap is reached mid-page", async () => { - const handle = makeFakeFetch([ - { - body: { - data: [ - { id: 1, name: "a" }, - { id: 2, name: "b" }, - ], - total: 100, - }, - }, - ]); - const client = createClient(CONFIG, { fetchImpl: handle.fetch }); - - const items: Array> = []; - for await (const item of paginate(client, "/api/card", Card, { pageSize: 2, max: 1 })) { - items.push(item); - } - - expect(items).toEqual([{ id: 1, name: "a" }]); - expect(handle.calls).toHaveLength(1); - }); - - it("requests at most `max` items in the final page", async () => { - const handle = makeFakeFetch([ - { - body: { - data: [ - { id: 1, name: "a" }, - { id: 2, name: "b" }, - ], - total: 100, - }, - }, - { body: { data: [{ id: 3, name: "c" }], total: 100 } }, - ]); - const client = createClient(CONFIG, { fetchImpl: handle.fetch }); - - await collectPaginated(client, "/api/card", Card, { pageSize: 2, max: 3 }); - - expect(handle.calls.map(pageOf)).toEqual([ - { limit: "2", offset: "0" }, - { limit: "1", offset: "2" }, - ]); - }); - - it("forwards extra query params to every page request", async () => { - const handle = makeFakeFetch([ - { body: { data: [{ id: 1, name: "a" }], total: 2 } }, - { body: { data: [{ id: 2, name: "b" }], total: 2 } }, - ]); - const client = createClient(CONFIG, { fetchImpl: handle.fetch }); - - await collectPaginated(client, "/api/search", Card, { - pageSize: 1, - query: { q: "hello", archived: false }, - }); - - for (const call of handle.calls) { - const params = urlOf(call).searchParams; - expect(params.get("q")).toBe("hello"); - expect(params.get("archived")).toBe("false"); - } - }); - - it("stops when the server returns a short page even without total", async () => { - const handle = makeFakeFetch([{ body: { data: [{ id: 1, name: "a" }] } }]); - const client = createClient(CONFIG, { fetchImpl: handle.fetch }); - - const items = await collectPaginated(client, "/api/card", Card, { pageSize: 50 }); - - expect(items).toEqual([{ id: 1, name: "a" }]); - expect(handle.calls).toHaveLength(1); - }); - - it("rejects pages whose items fail schema validation", async () => { - const handle = makeFakeFetch([{ body: { data: [{ id: "not-a-number", name: "x" }] } }]); - const client = createClient(CONFIG, { fetchImpl: handle.fetch }); - - const error = await collectPaginated(client, "/api/card", Card).catch( - (caught: unknown) => caught, - ); - expect(error).toBeInstanceOf(Error); - }); - - it("preserves passthrough fields in the envelope without affecting yielded items", async () => { - const handle = makeFakeFetch([ - { body: { data: [{ id: 1, name: "a" }], total: 1, models: ["card"] } }, - ]); - const client = createClient(CONFIG, { fetchImpl: handle.fetch }); - - const items = await collectPaginated(client, "/api/search", Card); - expect(items).toEqual([{ id: 1, name: "a" }]); - }); - - it("accepts total: null on an empty page without falling over (collection-items shape)", async () => { - const handle = makeFakeFetch([{ body: { data: [], total: null, models: ["card"] } }]); - const client = createClient(CONFIG, { fetchImpl: handle.fetch }); - - const items = await collectPaginated(client, "/api/collection/8/items", Card, { pageSize: 50 }); - - expect(items).toEqual([]); - expect(handle.calls).toHaveLength(1); - }); - - it("treats total: null as unknown total and continues paginating until a short page", async () => { - const handle = makeFakeFetch([ - { body: { data: [{ id: 1, name: "a" }], total: null } }, - { body: { data: [{ id: 2, name: "b" }], total: null } }, - { body: { data: [], total: null } }, - ]); - const client = createClient(CONFIG, { fetchImpl: handle.fetch }); - - const items = await collectPaginated(client, "/api/card", Card, { pageSize: 1 }); - - expect(items).toEqual([ - { id: 1, name: "a" }, - { id: 2, name: "b" }, - ]); - expect(handle.calls).toHaveLength(3); - }); -}); - -describe("paginate edge-case grid", () => { - function buildPagedFetch(items: Array>): FakeFetchHandle { - const calls: FetchCallRecord[] = []; - const fetchImpl: typeof fetch = async (input, init) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - calls.push({ url, method: init?.method ?? "GET" }); - const params = new URL(url).searchParams; - const limitParam = params.get("limit"); - const offsetParam = params.get("offset"); - assert( - limitParam !== null && offsetParam !== null, - "paginate must always send both limit and offset", - ); - const slice = items.slice(Number(offsetParam), Number(offsetParam) + Number(limitParam)); - return new Response(JSON.stringify({ data: slice, total: items.length }), { - headers: { "content-type": "application/json" }, - }); - }; - return { fetch: fetchImpl, calls }; - } - - interface GridCase { - label: string; - itemsCount: number; - pageSize: number; - max: number | undefined; - expectedCollected: Array>; - expectedRequests: Array<{ limit: string; offset: string }>; - } - - const grid: GridCase[] = [ - { - label: "empty source, no max", - itemsCount: 0, - pageSize: 5, - max: undefined, - expectedCollected: [], - expectedRequests: [{ limit: "5", offset: "0" }], - }, - { - label: "single page exactly fills and stops (total == offset+data)", - itemsCount: 5, - pageSize: 5, - max: undefined, - expectedCollected: makeItems(5), - expectedRequests: [{ limit: "5", offset: "0" }], - }, - { - label: "two full pages then short tail, no max", - itemsCount: 7, - pageSize: 3, - max: undefined, - expectedCollected: makeItems(7), - expectedRequests: [ - { limit: "3", offset: "0" }, - { limit: "3", offset: "3" }, - { limit: "3", offset: "6" }, - ], - }, - { - label: "max=0 returns nothing without any fetch", - itemsCount: 10, - pageSize: 3, - max: 0, - expectedCollected: [], - expectedRequests: [], - }, - { - label: "max=1 with pageSize=1 issues a single request", - itemsCount: 10, - pageSize: 1, - max: 1, - expectedCollected: makeItems(1), - expectedRequests: [{ limit: "1", offset: "0" }], - }, - { - label: "max mid-page caps the final request to the remaining slots", - itemsCount: 10, - pageSize: 4, - max: 6, - expectedCollected: makeItems(6), - expectedRequests: [ - { limit: "4", offset: "0" }, - { limit: "2", offset: "4" }, - ], - }, - { - label: "max larger than items still stops when source is exhausted", - itemsCount: 3, - pageSize: 2, - max: 100, - expectedCollected: makeItems(3), - expectedRequests: [ - { limit: "2", offset: "0" }, - { limit: "2", offset: "2" }, - ], - }, - { - label: "max equal to items.length matches without overshoot", - itemsCount: 5, - pageSize: 5, - max: 5, - expectedCollected: makeItems(5), - expectedRequests: [{ limit: "5", offset: "0" }], - }, - { - label: "pageSize=1 walks one item per call until total reached", - itemsCount: 3, - pageSize: 1, - max: undefined, - expectedCollected: makeItems(3), - expectedRequests: [ - { limit: "1", offset: "0" }, - { limit: "1", offset: "1" }, - { limit: "1", offset: "2" }, - ], - }, - { - label: "pageSize larger than items short-circuits after first response", - itemsCount: 2, - pageSize: 50, - max: undefined, - expectedCollected: makeItems(2), - expectedRequests: [{ limit: "50", offset: "0" }], - }, - ]; - - it.each(grid)( - "$label", - async ({ itemsCount, pageSize, max, expectedCollected, expectedRequests }) => { - const items = makeItems(itemsCount); - const handle = buildPagedFetch(items); - const client = createClient(CONFIG, { fetchImpl: handle.fetch }); - - const opts = max === undefined ? { pageSize } : { pageSize, max }; - const collected = await collectPaginated(client, "/api/card", Card, opts); - - expect(collected).toEqual(expectedCollected); - expect(handle.calls.map(pageOf)).toEqual(expectedRequests); - }, - ); -}); diff --git a/src/runtime/paginate.ts b/src/runtime/paginate.ts deleted file mode 100644 index 2dcb9b3..0000000 --- a/src/runtime/paginate.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { z, type ZodType } from "zod"; - -import type { Client, QueryValue } from "../core/http/client"; - -export const DEFAULT_PAGE_SIZE = 50; - -export interface PaginateOptions { - query?: Record; - pageSize?: number; - max?: number; - signal?: AbortSignal; -} - -export interface PaginatedEnvelope { - data: T[]; - total?: number | null | undefined; - limit?: number | undefined; - offset?: number | undefined; -} - -export async function* paginate( - client: Client, - path: string, - itemSchema: ZodType, - opts: PaginateOptions = {}, -): AsyncIterable { - const envelopeSchema = paginatedEnvelopeSchema(itemSchema); - const pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE; - const baseQuery = opts.query ?? {}; - const cap = opts.max; - - let offset = 0; - let yielded = 0; - - while (true) { - const remaining = cap === undefined ? Number.POSITIVE_INFINITY : cap - yielded; - if (remaining <= 0) { - return; - } - const requested = Math.min(pageSize, remaining); - const envelope = await client.requestParsed(envelopeSchema, path, { - query: { ...baseQuery, limit: requested, offset }, - ...(opts.signal !== undefined && { signal: opts.signal }), - }); - - for (const item of envelope.data) { - yield item; - yielded += 1; - if (cap !== undefined && yielded >= cap) { - return; - } - } - - if (envelope.data.length < requested) { - return; - } - if ( - envelope.total !== undefined && - envelope.total !== null && - offset + envelope.data.length >= envelope.total - ) { - return; - } - - offset += envelope.data.length; - } -} - -export async function collectPaginated( - client: Client, - path: string, - itemSchema: ZodType, - opts: PaginateOptions = {}, -): Promise { - const items: T[] = []; - for await (const item of paginate(client, path, itemSchema, opts)) { - items.push(item); - } - return items; -} - -function paginatedEnvelopeSchema(itemSchema: ZodType): ZodType> { - return z - .object({ - data: z.array(itemSchema), - total: z.number().int().nonnegative().nullable().optional(), - limit: z.number().int().nonnegative().optional(), - offset: z.number().int().nonnegative().optional(), - }) - .passthrough(); -} diff --git a/src/runtime/signal.test.ts b/src/runtime/signal.test.ts deleted file mode 100644 index 01dd7a6..0000000 --- a/src/runtime/signal.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { afterEach, assert, describe, expect, it } from "vitest"; - -import { AbortError } from "../core/errors"; -import { abortReason, createProcessAbortHandler, type ProcessAbortHandler } from "./signal"; - -describe("createProcessAbortHandler", () => { - const installed: ProcessAbortHandler[] = []; - - afterEach(() => { - while (installed.length > 0) { - const handle = installed.pop(); - handle?.uninstall(); - } - }); - - it("aborts the signal with AbortError when SIGINT fires", () => { - const handle = createProcessAbortHandler(); - installed.push(handle); - - expect(handle.signal.aborted).toBe(false); - process.emit("SIGINT"); - - expect(handle.signal.aborted).toBe(true); - const reason: unknown = handle.signal.reason; - expect(reason).toBeInstanceOf(AbortError); - assert(reason instanceof AbortError, "expected AbortError"); - expect(reason.exitCode).toBe(130); - }); - - it("only aborts on the first SIGINT — subsequent SIGINTs reach a fresh handler", () => { - const first = createProcessAbortHandler(); - installed.push(first); - - process.emit("SIGINT"); - expect(first.signal.aborted).toBe(true); - const initialReason: unknown = first.signal.reason; - - const second = createProcessAbortHandler(); - installed.push(second); - - process.emit("SIGINT"); - expect(first.signal.reason).toBe(initialReason); - expect(second.signal.aborted).toBe(true); - }); - - it("uninstall() detaches the SIGINT handler so the signal stays open", () => { - const handle = createProcessAbortHandler(); - handle.uninstall(); - - process.emit("SIGINT"); - expect(handle.signal.aborted).toBe(false); - }); -}); - -describe("abortReason", () => { - it("returns an existing MetabaseError reason unchanged", () => { - const controller = new AbortController(); - const original = new AbortError("interrupted"); - controller.abort(original); - - expect(abortReason(controller.signal)).toBe(original); - }); - - it("wraps a string reason in an AbortError preserving the message", () => { - const controller = new AbortController(); - controller.abort("custom-reason"); - - const wrapped = abortReason(controller.signal); - expect(wrapped).toBeInstanceOf(AbortError); - expect(wrapped.message).toBe("custom-reason"); - }); - - it("converts a DOMException-style reason to an AbortError keeping its exit code", () => { - const controller = new AbortController(); - controller.abort(); - - const wrapped = abortReason(controller.signal); - expect(wrapped).toBeInstanceOf(AbortError); - expect(wrapped.exitCode).toBe(130); - }); -}); diff --git a/src/runtime/signal.ts b/src/runtime/signal.ts deleted file mode 100644 index e7e8e61..0000000 --- a/src/runtime/signal.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { AbortError, errorMessage, MetabaseError } from "../core/errors"; - -export interface ProcessAbortHandler { - signal: AbortSignal; - uninstall: () => void; -} - -export interface CombinedAborts { - combined: AbortSignal; - processSignal: AbortSignal; -} - -export function createProcessAbortHandler(): ProcessAbortHandler { - const controller = new AbortController(); - const handler = (): void => { - if (!controller.signal.aborted) { - controller.abort(new AbortError("interrupted")); - } - process.off("SIGINT", handler); - }; - process.on("SIGINT", handler); - return { - signal: controller.signal, - uninstall: () => process.off("SIGINT", handler), - }; -} - -let singleton: AbortSignal | null = null; - -export function getProcessAbortSignal(): AbortSignal { - if (singleton === null) { - singleton = createProcessAbortHandler().signal; - } - return singleton; -} - -export function combineAborts( - timeoutSignal: AbortSignal, - callerSignal?: AbortSignal, -): CombinedAborts { - const processSignal = getProcessAbortSignal(); - const signals: AbortSignal[] = [timeoutSignal, processSignal]; - if (callerSignal) { - signals.push(callerSignal); - } - return { combined: AbortSignal.any(signals), processSignal }; -} - -export function throwIfAborted(...signals: Array): void { - for (const signal of signals) { - if (signal?.aborted) { - throw abortReason(signal); - } - } -} - -export function abortReason(signal: AbortSignal): MetabaseError { - const reason: unknown = signal.reason; - if (reason instanceof MetabaseError) { - return reason; - } - if (reason instanceof Error || typeof reason === "string") { - return new AbortError(errorMessage(reason) || "aborted"); - } - return new AbortError("aborted"); -} diff --git a/tests/e2e/alert.e2e.test.ts b/tests/e2e/alert.e2e.test.ts index bf81e8f..8143f68 100644 --- a/tests/e2e/alert.e2e.test.ts +++ b/tests/e2e/alert.e2e.test.ts @@ -1,10 +1,13 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { AlertListEnvelope } from "../../src/commands/alert/list"; -import { AlertSendResult } from "../../src/commands/alert/send"; -import { NotificationCompact, type NotificationCreateInput } from "../../src/domain/notification"; -import { parseJson } from "../../src/runtime/json"; - +import { + NotificationCompact, + type NotificationCreateInput, +} from "@metabase/client/domain/notification"; +import { parseJson } from "@metabase/client/json"; + +import { AlertListEnvelope } from "../../packages/cli/src/commands/alert/list"; +import { AlertSendResult } from "../../packages/cli/src/commands/alert/send"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; @@ -103,7 +106,10 @@ describe("alert e2e", () => { expect(parseJson(result.stdout, AlertListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -149,7 +155,10 @@ describe("alert e2e", () => { expect(parseJson(result.stdout, AlertListEnvelope)).toEqual({ data: [expectedCompact(created.id, created.creator_id)], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -165,7 +174,10 @@ describe("alert e2e", () => { expect(parseJson(matching.stdout, AlertListEnvelope)).toEqual({ data: [expectedCompact(created.id, created.creator_id)], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); const other = await runCli({ @@ -177,7 +189,10 @@ describe("alert e2e", () => { expect(parseJson(other.stdout, AlertListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -194,7 +209,10 @@ describe("alert e2e", () => { expect(parseJson(result.stdout, AlertListEnvelope)).toEqual({ data: [expectedCompact(created.id, created.creator_id)], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -209,7 +227,10 @@ describe("alert e2e", () => { expect(parseJson(result.stdout, AlertListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -339,7 +360,10 @@ describe("alert e2e", () => { expect(parseJson(activeList.stdout, AlertListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); const inactiveList = await runCli({ @@ -351,7 +375,10 @@ describe("alert e2e", () => { expect(parseJson(inactiveList.stdout, AlertListEnvelope)).toEqual({ data: [inactive], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); const reactivate = await runCli({ diff --git a/tests/e2e/auth.e2e.test.ts b/tests/e2e/auth.e2e.test.ts index 39b1cc3..60257a7 100644 --- a/tests/e2e/auth.e2e.test.ts +++ b/tests/e2e/auth.e2e.test.ts @@ -1,15 +1,14 @@ -import { join } from "node:path"; import { promises as fs } from "node:fs"; - +import { join } from "node:path"; import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { LoginResult } from "../../src/commands/auth/login"; -import { AuthProfileListEnvelope } from "../../src/commands/auth/list"; -import { LogoutResult } from "../../src/commands/auth/logout"; -import { AuthStatus } from "../../src/commands/auth/status"; -import { ProfilesFile } from "../../src/core/auth/profile-record"; -import { parseJson } from "../../src/runtime/json"; +import { parseJson } from "@metabase/client/json"; +import { LoginResult } from "../../packages/cli/src/commands/auth/login"; +import { AuthProfileListEnvelope } from "../../packages/cli/src/commands/auth/list"; +import { LogoutResult } from "../../packages/cli/src/commands/auth/logout"; +import { AuthStatus } from "../../packages/cli/src/commands/auth/status"; +import { ProfilesFile } from "../../packages/cli/src/core/auth/profile-record"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; diff --git a/tests/e2e/bootstrap-data.ts b/tests/e2e/bootstrap-data.ts index 9ce03ac..2c3702e 100644 --- a/tests/e2e/bootstrap-data.ts +++ b/tests/e2e/bootstrap-data.ts @@ -4,10 +4,10 @@ import { fileURLToPath } from "node:url"; import { z } from "zod"; -import { isNotFoundError } from "../../src/core/errors"; -import { ParsedVersionSchema } from "../../src/core/version/tag"; -import { TokenFeatures } from "../../src/domain/session-properties"; -import { parseJson } from "../../src/runtime/json"; +import { isFileNotFoundError } from "@metabase/client/errors"; +import { ParsedVersion } from "@metabase/client/version/tag"; +import { TokenFeatures } from "@metabase/client/domain/session-properties"; +import { parseJson } from "@metabase/client/json"; import { resolveStackId } from "./defaults"; @@ -41,7 +41,7 @@ export const SeededIds = z.object({ export type SeededIds = z.infer; export const ServerIdentity = z.object({ - version: ParsedVersionSchema.nullable(), + version: ParsedVersion.nullable(), tokenFeatures: TokenFeatures.nullable(), // Whether the server supports full-API OAuth login (Metabase v63+, full-access scope advertised // in discovery); probed live during bootstrap. Defaults false for bootstrap files written before @@ -78,7 +78,7 @@ export async function readBootstrap(): Promise { try { raw = await fs.readFile(BOOTSTRAP_FILE_PATH, "utf8"); } catch (error) { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { throw missingBootstrapError(error); } throw error; @@ -91,7 +91,7 @@ export function readBootstrapSync(): E2EBootstrap { try { raw = readFileSync(BOOTSTRAP_FILE_PATH, "utf8"); } catch (error) { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { throw missingBootstrapError(error); } throw error; diff --git a/tests/e2e/card-query.ts b/tests/e2e/card-query.ts index 6f258b8..49ed7dc 100644 --- a/tests/e2e/card-query.ts +++ b/tests/e2e/card-query.ts @@ -1,4 +1,4 @@ -import { CardQueryResult } from "../../src/domain/card"; +import { CardQueryResult } from "@metabase/client/domain/card"; type CompletedCardQueryResult = CardQueryResult & { data: NonNullable }; diff --git a/tests/e2e/card.e2e.test.ts b/tests/e2e/card.e2e.test.ts index 794c1d8..14f7042 100644 --- a/tests/e2e/card.e2e.test.ts +++ b/tests/e2e/card.e2e.test.ts @@ -1,20 +1,20 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { CardListEnvelope } from "../../src/commands/card/list"; -import { ValidationOutcome } from "../../src/core/schema/validate"; import { Card, CardCompact, CardCreateInput, CardQueryResult, CardQueryResultCompact, -} from "../../src/domain/card"; -import { parseJson } from "../../src/runtime/json"; +} from "@metabase/client/domain/card"; +import { parseJson } from "@metabase/client/json"; +import { CardListEnvelope } from "../../packages/cli/src/commands/card/list"; +import { ValidationOutcome } from "../../packages/cli/src/core/schema/validate"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { assertCompactColumns, assertCompletedQuery } from "./card-query"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { cliErrorMessage } from "./cli-error"; +import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; const NEW_CARD_NAME = "e2e_card_new"; @@ -117,7 +117,10 @@ describe("card e2e", () => { expect(parseJson(result.stdout, CardListEnvelope)).toEqual({ data: [{ ...ORDERS_BY_STATUS_COMPACT, archived: true }], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -156,6 +159,19 @@ describe("card e2e", () => { }); }); + it("list with a rejected --limit fails with a ConfigError envelope and an empty stdout", async () => { + const result = await runCli({ + args: ["card", "list", "--limit", "0", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorCategory(result.stderr)).toBe("config"); + expect(cliErrorMessage(result.stderr)).toBe("invalid --limit: 0 (must be ≥ 1)"); + expect(result.stdout).toBe(""); + }); + it("get with a non-integer id fails fast with ConfigError", async () => { const result = await runCli({ args: ["card", "get", "abc", "--json"], @@ -435,7 +451,10 @@ describe("card e2e", () => { expect(parseJson(archivedListResult.stdout, CardListEnvelope)).toEqual({ data: [{ ...ORDERS_BY_STATUS_COMPACT, archived: true }], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); diff --git a/tests/e2e/cli-error.ts b/tests/e2e/cli-error.ts index 7e4fbff..77a9bb3 100644 --- a/tests/e2e/cli-error.ts +++ b/tests/e2e/cli-error.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { parseJson } from "../../src/runtime/json"; +import { parseJson } from "@metabase/client/json"; const CliErrorEnvelope = z .object({ diff --git a/tests/e2e/collection.e2e.test.ts b/tests/e2e/collection.e2e.test.ts index 3fe4ffb..d486d27 100644 --- a/tests/e2e/collection.e2e.test.ts +++ b/tests/e2e/collection.e2e.test.ts @@ -1,15 +1,16 @@ import { afterEach, assert, beforeAll, describe, expect, it } from "vitest"; -import { CollectionItemListEnvelope } from "../../src/commands/collection/items"; -import { CollectionListEnvelope } from "../../src/commands/collection/list"; -import { CollectionTreeResponse } from "../../src/commands/collection/tree"; -import { Collection, CollectionCompact } from "../../src/domain/collection"; -import { parseJson } from "../../src/runtime/json"; +import { Collection, CollectionCompact } from "@metabase/client/domain/collection"; +import { parseJson } from "@metabase/client/json"; +import { CollectionItemListEnvelope } from "../../packages/cli/src/commands/collection/items"; +import { CollectionListEnvelope } from "../../packages/cli/src/commands/collection/list"; +import { CollectionTreeResponse } from "../../packages/cli/src/commands/collection/tree"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; + const DEFAULT_COLLECTION_NAME = "E2E Default"; const DEFAULT_COMPACT = { @@ -98,7 +99,10 @@ describe("collection e2e", () => { expect(parseJson(result.stdout, CollectionListEnvelope)).toEqual({ data: [TRASH_COMPACT], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -127,7 +131,10 @@ describe("collection e2e", () => { expect(parseJson(result.stdout, CollectionListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -316,11 +323,14 @@ describe("collection e2e", () => { }, ], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); - it("items --limit caps the returned page", async () => { + it("items --limit caps the returned page and points at the rest", async () => { const result = await runCli({ args: ["collection", "items", String(SEEDED.defaultCollectionId), "--limit", "1", "--json"], configHome: await makeIsolatedConfigHome(), @@ -331,7 +341,44 @@ describe("collection e2e", () => { const envelope = parseJson(result.stdout, CollectionItemListEnvelope); const { data, ...meta } = envelope; expect(data).toHaveLength(1); - expect(meta).toEqual({ returned: 1, limit: 1 }); + expect(meta).toEqual({ + returned: 1, + offset: 0, + limit: 1, + total: 2, + has_more: true, + next_offset: 1, + }); + }); + + it("items --offset resumes at next_offset and reports the end of the collection", async () => { + const result = await runCli({ + args: [ + "collection", + "items", + String(SEEDED.defaultCollectionId), + "--limit", + "1", + "--offset", + "1", + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, CollectionItemListEnvelope); + const { data, ...meta } = envelope; + expect(data).toHaveLength(1); + expect(meta).toEqual({ + returned: 1, + offset: 1, + limit: 1, + total: 2, + has_more: false, + next_offset: null, + }); }); it("items --models rejects an unknown model with ConfigError", async () => { @@ -410,7 +457,10 @@ describe("collection e2e", () => { expect(parseJson(itemsResult.stdout, CollectionItemListEnvelope)).toEqual({ data: [], returned: 0, - total: 0, + offset: 0, + total: null, + has_more: false, + next_offset: null, }); }); diff --git a/tests/e2e/dashboard-parameter-values.e2e.test.ts b/tests/e2e/dashboard-parameter-values.e2e.test.ts index 8653190..5656b53 100644 --- a/tests/e2e/dashboard-parameter-values.e2e.test.ts +++ b/tests/e2e/dashboard-parameter-values.e2e.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { DashboardCompact } from "../../src/domain/dashboard"; -import { ParameterValues } from "../../src/domain/parameter"; -import { parseJson } from "../../src/runtime/json"; +import { DashboardCompact } from "@metabase/client/domain/dashboard"; +import { ParameterValues } from "@metabase/client/domain/parameter"; +import { parseJson } from "@metabase/client/json"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; diff --git a/tests/e2e/dashboard.e2e.test.ts b/tests/e2e/dashboard.e2e.test.ts index 09dacbc..f33de80 100644 --- a/tests/e2e/dashboard.e2e.test.ts +++ b/tests/e2e/dashboard.e2e.test.ts @@ -1,20 +1,20 @@ import { afterEach, assert, beforeAll, describe, expect, it } from "vitest"; import { z } from "zod"; -import { DashcardListEnvelope } from "../../src/commands/dashboard/cards"; -import { DashboardListEnvelope } from "../../src/commands/dashboard/list"; -import { ValidationOutcome } from "../../src/core/schema/validate"; -import { CardCompact } from "../../src/domain/card"; +import { CardCompact } from "@metabase/client/domain/card"; import { Dashboard, DashboardCompact, DashboardDetail, Dashcard, DashcardCompact, -} from "../../src/domain/dashboard"; -import { listEnvelopeSchema } from "../../src/output/types"; -import { parseJson } from "../../src/runtime/json"; +} from "@metabase/client/domain/dashboard"; +import { parseJson } from "@metabase/client/json"; +import { DashcardListEnvelope } from "../../packages/cli/src/commands/dashboard/cards"; +import { DashboardListEnvelope } from "../../packages/cli/src/commands/dashboard/list"; +import { ValidationOutcome } from "../../packages/cli/src/core/schema/validate"; +import { listEnvelopeSchema } from "../../packages/cli/src/output/types"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; @@ -150,7 +150,10 @@ describe("dashboard e2e", () => { expect(parseJson(result.stdout, DashboardListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -335,7 +338,10 @@ describe("dashboard e2e", () => { }, ], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -849,7 +855,10 @@ describe("dashboard e2e", () => { expect(parseJson(archivedListResult.stdout, DashboardListEnvelope)).toEqual({ data: [{ ...ORDERS_OVERVIEW_COMPACT, archived: true }], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -874,7 +883,10 @@ describe("dashboard e2e", () => { expect(parseJson(archivedListResult.stdout, DashboardListEnvelope)).toEqual({ data: [{ ...ORDERS_OVERVIEW_COMPACT, archived: true }], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); }); diff --git a/tests/e2e/db.e2e.test.ts b/tests/e2e/db.e2e.test.ts index d9b965d..411b2dc 100644 --- a/tests/e2e/db.e2e.test.ts +++ b/tests/e2e/db.e2e.test.ts @@ -1,18 +1,22 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { fullRollupOversizeHint, tableMapOversizeHint } from "../../src/commands/db/hints"; -import { DatabaseListEnvelope } from "../../src/commands/db/list"; -import { DatabaseSchemaListEnvelope } from "../../src/commands/db/schemas"; -import { DatabaseSchemaTablesEnvelope } from "../../src/commands/db/schema-tables"; -import { Database, DatabaseCompact, DatabaseSyncResult } from "../../src/domain/database"; -import { TableCompact } from "../../src/domain/table"; -import { DEFAULT_MAX_BYTES, listEnvelopeSchema } from "../../src/output/types"; -import { parseJson } from "../../src/runtime/json"; - +import { Database, DatabaseCompact, DatabaseSyncResult } from "@metabase/client/domain/database"; +import { TableCompact } from "@metabase/client/domain/table"; +import { parseJson } from "@metabase/client/json"; + +import { + fullRollupOversizeHint, + tableMapOversizeHint, +} from "../../packages/cli/src/commands/db/hints"; +import { DatabaseListEnvelope } from "../../packages/cli/src/commands/db/list"; +import { DatabaseSchemaListEnvelope } from "../../packages/cli/src/commands/db/schemas"; +import { DatabaseSchemaTablesEnvelope } from "../../packages/cli/src/commands/db/schema-tables"; +import { DEFAULT_MAX_BYTES, listEnvelopeSchema } from "../../packages/cli/src/output/types"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; + const SAVED_QUESTIONS_VIRTUAL_DB_ID = -1337; const PUBLIC_TABLES_SORTED_BY_DISPLAY_NAME: TableCompact[] = [ @@ -132,7 +136,10 @@ describe("db e2e", () => { expect(parseJson(result.stdout, DatabaseListEnvelope)).toEqual({ data: [{ id: SEEDED.warehouseDbId, name: "Warehouse", engine: "postgres" }], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -175,7 +182,10 @@ describe("db e2e", () => { }, ], returned: 2, + offset: 0, total: 2, + has_more: false, + next_offset: null, }); }); @@ -320,7 +330,10 @@ describe("db e2e", () => { expect(parseJson(result.stdout, DatabaseSchemaListEnvelope)).toEqual({ data: [{ name: "analytics" }, { name: "public" }], returned: 2, + offset: 0, total: 2, + has_more: false, + next_offset: null, }); }); @@ -346,7 +359,10 @@ describe("db e2e", () => { expect(parseJson(result.stdout, DatabaseSchemaTablesEnvelope)).toEqual({ data: PUBLIC_TABLES_SORTED_BY_DISPLAY_NAME, returned: PUBLIC_TABLES_SORTED_BY_DISPLAY_NAME.length, + offset: 0, total: PUBLIC_TABLES_SORTED_BY_DISPLAY_NAME.length, + has_more: false, + next_offset: null, }); }); @@ -361,7 +377,10 @@ describe("db e2e", () => { expect(parseJson(result.stdout, DatabaseSchemaTablesEnvelope)).toEqual({ data: ANALYTICS_TABLES_SORTED_BY_DISPLAY_NAME, returned: ANALYTICS_TABLES_SORTED_BY_DISPLAY_NAME.length, + offset: 0, total: ANALYTICS_TABLES_SORTED_BY_DISPLAY_NAME.length, + has_more: false, + next_offset: null, }); }); diff --git a/tests/e2e/document.e2e.test.ts b/tests/e2e/document.e2e.test.ts index 0340da4..f43188c 100644 --- a/tests/e2e/document.e2e.test.ts +++ b/tests/e2e/document.e2e.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { DocumentListEnvelope } from "../../src/commands/document/list"; -import { DocumentCompact } from "../../src/domain/document"; -import { parseJson } from "../../src/runtime/json"; +import { DocumentCompact } from "@metabase/client/domain/document"; +import { parseJson } from "@metabase/client/json"; +import { DocumentListEnvelope } from "../../packages/cli/src/commands/document/list"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; diff --git a/tests/e2e/eid-translation.e2e.test.ts b/tests/e2e/eid-translation.e2e.test.ts index 3ba8c35..874fc8c 100644 --- a/tests/e2e/eid-translation.e2e.test.ts +++ b/tests/e2e/eid-translation.e2e.test.ts @@ -1,8 +1,8 @@ import { afterEach, assert, beforeAll, describe, expect, it } from "vitest"; -import { Card } from "../../src/domain/card"; -import { EidTranslateResult } from "../../src/domain/eid-translation"; -import { parseJson } from "../../src/runtime/json"; +import { Card } from "@metabase/client/domain/card"; +import { EidTranslateResult } from "@metabase/client/domain/eid-translation"; +import { parseJson } from "@metabase/client/json"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; @@ -10,7 +10,10 @@ import { cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; import { requireServer } from "./server-gate"; -const transformsSkip = requireServer({ minVersion: 59 }); +const transformsSkip = requireServer( + "eid-translation › accepts the previously-missing transform model in the closed enum", + { minVersion: 59 }, +); describe("eid e2e", () => { let bootstrap: E2EBootstrap; diff --git a/tests/e2e/field.e2e.test.ts b/tests/e2e/field.e2e.test.ts index 9f85430..277970f 100644 --- a/tests/e2e/field.e2e.test.ts +++ b/tests/e2e/field.e2e.test.ts @@ -1,14 +1,16 @@ import { afterEach, assert, beforeAll, describe, expect, it } from "vitest"; -import { createClient } from "../../src/core/http/client"; -import { Field, FieldCompact, FieldSummary, FieldValues } from "../../src/domain/field"; -import { TableQueryMetadata } from "../../src/domain/table"; -import { parseJson } from "../../src/runtime/json"; +import { Field, FieldCompact, FieldSummary, FieldValues } from "@metabase/client/domain/field"; +import { TableQueryMetadata } from "@metabase/client/domain/table"; +import { createTransport } from "@metabase/client/http/transport"; +import { parseJson } from "@metabase/client/json"; +import { USER_AGENT } from "../../packages/cli/src/core/user-agent"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; + describe("field e2e", () => { let bootstrap: E2EBootstrap; let customersEmailFieldId: number; @@ -20,10 +22,10 @@ describe("field e2e", () => { }); async function resolveFieldId(tableId: number, fieldName: string): Promise { - const client = createClient({ - url: bootstrap.baseUrl, - credential: { kind: "apiKey", apiKey: bootstrap.adminApiKey }, - }); + const client = createTransport( + { url: bootstrap.baseUrl, credential: { kind: "apiKey", apiKey: bootstrap.adminApiKey } }, + { userAgent: USER_AGENT }, + ); const metadata = await client.requestParsed( TableQueryMetadata, `/api/table/${tableId}/query_metadata`, diff --git a/tests/e2e/git-sync.e2e.test.ts b/tests/e2e/git-sync.e2e.test.ts index 87d5909..3c4f8a9 100644 --- a/tests/e2e/git-sync.e2e.test.ts +++ b/tests/e2e/git-sync.e2e.test.ts @@ -1,13 +1,13 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { SyncSettingsUpdateResult } from "../../src/commands/git-sync/add-collection"; -import { CurrentTaskResult } from "../../src/commands/git-sync/current-task"; -import { SyncDirtyListEnvelope } from "../../src/commands/git-sync/dirty"; -import { IsDirtyResult } from "../../src/commands/git-sync/is-dirty"; -import { SyncStatus } from "../../src/commands/git-sync/status"; -import { WaitResult } from "../../src/commands/git-sync/wait"; -import { parseJson } from "../../src/runtime/json"; - +import { parseJson } from "@metabase/client/json"; + +import { SyncSettingsUpdateResult } from "../../packages/cli/src/commands/git-sync/add-collection"; +import { CurrentTaskResult } from "../../packages/cli/src/commands/git-sync/current-task"; +import { SyncDirtyListEnvelope } from "../../packages/cli/src/commands/git-sync/dirty"; +import { IsDirtyResult } from "../../packages/cli/src/commands/git-sync/is-dirty"; +import { SyncStatus } from "../../packages/cli/src/commands/git-sync/status"; +import { WaitResult } from "../../packages/cli/src/commands/git-sync/wait"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; @@ -16,7 +16,10 @@ import { requireServer } from "./server-gate"; // The remote-sync API has breaking server-side differences through v59 (the git source layer // was reworked and v59 NPEs on the idempotent `remove-collection` no-op path); it settles at // v60, which is the minVersion every git-sync command declares. -const skipReason = requireServer({ minVersion: 60, tokenFeature: "remote_sync" }); +const skipReason = requireServer("git-sync › git-sync e2e against EE git-sync endpoints", { + minVersion: 60, + tokenFeature: "remote_sync", +}); describe("git-sync arg validation e2e (no Metabase contact required)", () => { const tempDirs: string[] = []; @@ -178,7 +181,10 @@ describe.skipIf(skipReason !== null)("git-sync e2e against EE git-sync endpoints expect(parseJson(result.stdout, SyncDirtyListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); diff --git a/tests/e2e/help-json.e2e.test.ts b/tests/e2e/help-json.e2e.test.ts index 73bd78b..81636e5 100644 --- a/tests/e2e/help-json.e2e.test.ts +++ b/tests/e2e/help-json.e2e.test.ts @@ -1,15 +1,15 @@ import { afterEach, describe, expect, it } from "vitest"; -import main from "../../src/main"; +import { parseJson } from "@metabase/client/json"; + +import main from "../../packages/cli/src/main"; import { buildHelpEntry, buildHelpIndex, CommandHelpEntry, CommandHelpIndex, resolveCommandPath, -} from "../../src/runtime/command-help"; -import { parseJson } from "../../src/runtime/json"; - +} from "../../packages/cli/src/runtime/command-help"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; describe("--help --json e2e", () => { diff --git a/tests/e2e/interrupt.e2e.test.ts b/tests/e2e/interrupt.e2e.test.ts new file mode 100644 index 0000000..a250be2 --- /dev/null +++ b/tests/e2e/interrupt.e2e.test.ts @@ -0,0 +1,111 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { cliErrorCategory, cliErrorMessage } from "./cli-error"; +import { cleanupConfigHome, mkTempConfigHome, runCliInterrupt } from "./run-cli"; + +// Ctrl-C exits 130 only while something is still in flight, and a real Metabase answers a local +// request in milliseconds — far too fast to interrupt without a race. These stubs stand in for a +// slow peer, not for Metabase: the CLI under test is the built binary, unmodified, and every +// assertion is about its own signal handling. +const INTERRUPT_EXIT_CODE = 130; +const INTERRUPT_AFTER_MS = 1_500; +const POLL_INTERVAL_MS = 200; + +type StubHandler = (request: IncomingMessage, response: ServerResponse) => void; + +interface StubServer { + url: string; + server: Server; +} + +async function startStub(handler: StubHandler): Promise { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error(`stub server did not bind a TCP port: ${String(address)}`); + } + return { url: `http://127.0.0.1:${address.port}`, server }; +} + +async function stopStub(stub: StubServer): Promise { + stub.server.closeAllConnections(); + await new Promise((resolve, reject) => { + stub.server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +function sendJson(response: ServerResponse, body: unknown): void { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); +} + +describe("SIGINT handling (end-to-end)", () => { + const tempDirs: string[] = []; + const stubs: StubServer[] = []; + + afterEach(async () => { + await Promise.all(stubs.splice(0).map(stopStub)); + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + async function stubServer(handler: StubHandler): Promise { + const stub = await startStub(handler); + stubs.push(stub); + return stub; + } + + it("aborts an in-flight request and exits 130", async () => { + const stub = await stubServer(() => { + // Accept the request and never answer it, so the CLI is still waiting when SIGINT lands. + }); + + const result = await runCliInterrupt({ + args: ["card", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: { MB_URL: stub.url, MB_API_KEY: "mb_e2e_interrupt_key" }, + interruptAfterMs: INTERRUPT_AFTER_MS, + }); + + expect(result.exitCode).toBe(INTERRUPT_EXIT_CODE); + expect(cliErrorCategory(result.stderr)).toBe("abort"); + expect(cliErrorMessage(result.stderr)).toBe("interrupted"); + }); + + it("aborts a --wait poll loop and exits 130", async () => { + const stub = await stubServer((request, response) => { + if (request.method === "POST") { + sendJson(response, { status: "ok" }); + return; + } + sendJson(response, { id: 1, name: "stub", initial_sync_status: "incomplete" }); + }); + + const result = await runCliInterrupt({ + args: ["db", "sync-schema", "1", "--wait", "--interval", String(POLL_INTERVAL_MS), "--json"], + configHome: await makeIsolatedConfigHome(), + env: { MB_URL: stub.url, MB_API_KEY: "mb_e2e_interrupt_key" }, + interruptAfterMs: INTERRUPT_AFTER_MS, + }); + + expect(result.exitCode).toBe(INTERRUPT_EXIT_CODE); + expect(cliErrorCategory(result.stderr)).toBe("abort"); + expect(cliErrorMessage(result.stderr)).toBe("interrupted"); + }); +}); diff --git a/tests/e2e/library.e2e.test.ts b/tests/e2e/library.e2e.test.ts index 9fb481f..81a848c 100644 --- a/tests/e2e/library.e2e.test.ts +++ b/tests/e2e/library.e2e.test.ts @@ -1,19 +1,25 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { LibraryPublishResult } from "../../src/commands/library/publish"; -import { LibraryUnpublishResult } from "../../src/commands/library/unpublish"; -import { Library } from "../../src/domain/library"; -import { TableCompact } from "../../src/domain/table"; -import { parseJson } from "../../src/runtime/json"; +import { Library } from "@metabase/client/domain/library"; +import { TableCompact } from "@metabase/client/domain/table"; +import { parseJson } from "@metabase/client/json"; +import { LibraryPublishResult } from "../../packages/cli/src/commands/library/publish"; +import { LibraryUnpublishResult } from "../../packages/cli/src/commands/library/unpublish"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { SEEDED } from "./seed/seeded"; import { requireServer } from "./server-gate"; -const LIBRARY_UNAVAILABLE = requireServer({ minVersion: 59, tokenFeature: "library" }); -const REMOTE_SYNC_UNAVAILABLE = requireServer({ minVersion: 60, tokenFeature: "remote_sync" }); +const LIBRARY_UNAVAILABLE = requireServer("library › with the library feature", { + minVersion: 59, + tokenFeature: "library", +}); +const REMOTE_SYNC_UNAVAILABLE = requireServer( + "library › with the library and remote_sync features", + { minVersion: 60, tokenFeature: "remote_sync" }, +); const SYNC_SCOPE_HINT_MARKER = "is not marked for git-sync"; diff --git a/tests/e2e/measure.e2e.test.ts b/tests/e2e/measure.e2e.test.ts index 51fb9ad..a6a45d2 100644 --- a/tests/e2e/measure.e2e.test.ts +++ b/tests/e2e/measure.e2e.test.ts @@ -1,15 +1,16 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { MeasureListEnvelope } from "../../src/commands/measure/list"; -import { ValidationOutcome } from "../../src/core/schema/validate"; -import { MeasureCompact, type MeasureCreateInput } from "../../src/domain/measure"; -import { parseJson } from "../../src/runtime/json"; +import { MeasureCompact, type MeasureCreateInput } from "@metabase/client/domain/measure"; +import { parseJson } from "@metabase/client/json"; +import { MeasureListEnvelope } from "../../packages/cli/src/commands/measure/list"; +import { ValidationOutcome } from "../../packages/cli/src/core/schema/validate"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; -import { requireServer } from "./server-gate"; +import { requireServer, serverRejectedMessage } from "./server-gate"; + const FIRST_NEW_MEASURE_ID = 1; const MEASURE_NAME = "OrderCount"; const MEASURE_DESCRIPTION = "Count of orders rows."; @@ -32,7 +33,7 @@ const NEW_MEASURE_BODY: MeasureCreateInput = { }, }; -const skipReason = requireServer({ minVersion: 59 }); +const skipReason = requireServer("measure › measure e2e", { minVersion: 59 }); describe.skipIf(skipReason !== null)("measure e2e", () => { let bootstrap: E2EBootstrap; @@ -80,7 +81,10 @@ describe.skipIf(skipReason !== null)("measure e2e", () => { expect(parseJson(result.stdout, MeasureListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -109,7 +113,10 @@ describe.skipIf(skipReason !== null)("measure e2e", () => { expect(parseJson(listResult.stdout, MeasureListEnvelope)).toEqual({ data: [NEW_MEASURE_COMPACT], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -156,7 +163,7 @@ describe.skipIf(skipReason !== null)("measure e2e", () => { }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Metabase returned 500"); + expect(cliErrorMessage(result.stderr)).toBe(serverRejectedMessage()); expect(result.stdout).toBe(""); }); @@ -304,7 +311,10 @@ describe.skipIf(skipReason !== null)("measure e2e", () => { expect(parseJson(listResult.stdout, MeasureListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); diff --git a/tests/e2e/oauth.e2e.test.ts b/tests/e2e/oauth.e2e.test.ts index 29d2ba7..11fd03b 100644 --- a/tests/e2e/oauth.e2e.test.ts +++ b/tests/e2e/oauth.e2e.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import type { OAuthCredential } from "../../src/core/auth/credential"; -import { oauthLogin } from "../../src/core/auth/oauth-login"; -import { refreshOAuthCredential, revokeOAuthCredential } from "../../src/core/auth/oauth-session"; -import { AuthProfileListEnvelope } from "../../src/commands/auth/list"; -import { parseJson } from "../../src/runtime/json"; +import type { OAuthCredential } from "@metabase/client/auth/credential"; +import { oauthLogin } from "@metabase/client/auth/oauth-login"; +import { refreshOAuthCredential, revokeOAuthCredential } from "@metabase/client/auth/oauth-session"; +import { parseJson } from "@metabase/client/json"; +import { OAUTH_CLIENT_NAME, USER_AGENT } from "../../packages/cli/src/core/user-agent"; +import { AuthProfileListEnvelope } from "../../packages/cli/src/commands/auth/list"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { requireOAuthServer } from "./server-gate"; @@ -27,7 +28,7 @@ import { // completes the consent decision against the live server, then assert the real bearer/refresh/revoke // lifecycle — the parts unit tests can only mock. -describe.skipIf(requireOAuthServer() !== null)("oauth e2e", () => { +describe.skipIf(requireOAuthServer("oauth › oauth e2e") !== null)("oauth e2e", () => { let bootstrap: E2EBootstrap; const tempDirs: string[] = []; @@ -41,7 +42,7 @@ describe.skipIf(requireOAuthServer() !== null)("oauth e2e", () => { async function login(): Promise { return oauthLogin( - { baseUrl: bootstrap.baseUrl }, + { baseUrl: bootstrap.baseUrl, userAgent: USER_AGENT, clientName: OAUTH_CLIENT_NAME }, { openBrowser: consentingBrowser(bootstrap.baseUrl, bootstrap.admin), onAuthorizeUrl: () => undefined, @@ -64,7 +65,12 @@ describe.skipIf(requireOAuthServer() !== null)("oauth e2e", () => { it("refreshes the access token against the real token endpoint and the new token works", async () => { const credential = await login(); - const refreshed = await refreshOAuthCredential(bootstrap.baseUrl, credential, Date.now()); + const refreshed = await refreshOAuthCredential( + bootstrap.baseUrl, + credential, + Date.now(), + USER_AGENT, + ); expect(refreshed.accessToken).not.toBe(credential.accessToken); expect(refreshed.refreshToken).not.toBe(""); // rotation-aware (new or retained) @@ -76,7 +82,7 @@ describe.skipIf(requireOAuthServer() !== null)("oauth e2e", () => { it("revokes both tokens server-side so neither survives logout", async () => { const credential = await login(); - const revoked = await revokeOAuthCredential(bootstrap.baseUrl, credential); + const revoked = await revokeOAuthCredential(bootstrap.baseUrl, credential, USER_AGENT); expect(revoked).toBe(true); // The revoked grant is rejected outright: 400 invalid_grant. diff --git a/tests/e2e/profiles.e2e.test.ts b/tests/e2e/profiles.e2e.test.ts index ca9c678..25b197a 100644 --- a/tests/e2e/profiles.e2e.test.ts +++ b/tests/e2e/profiles.e2e.test.ts @@ -1,17 +1,18 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { AuthProfileListEnvelope } from "../../src/commands/auth/list"; -import { LoginResult } from "../../src/commands/auth/login"; -import { LogoutResult } from "../../src/commands/auth/logout"; -import { AuthStatus } from "../../src/commands/auth/status"; -import { DatabaseListEnvelope } from "../../src/commands/db/list"; -import { CardQueryResult } from "../../src/domain/card"; -import { parseJson } from "../../src/runtime/json"; - +import { CardQueryResult } from "@metabase/client/domain/card"; +import { parseJson } from "@metabase/client/json"; + +import { AuthProfileListEnvelope } from "../../packages/cli/src/commands/auth/list"; +import { LoginResult } from "../../packages/cli/src/commands/auth/login"; +import { LogoutResult } from "../../packages/cli/src/commands/auth/logout"; +import { AuthStatus } from "../../packages/cli/src/commands/auth/status"; +import { DatabaseListEnvelope } from "../../packages/cli/src/commands/db/list"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; + describe("profiles e2e", () => { let bootstrap: E2EBootstrap; const tempDirs: string[] = []; @@ -173,7 +174,10 @@ describe("profiles e2e", () => { expect(parseJson(result.stdout, DatabaseListEnvelope)).toEqual({ data: [{ id: SEEDED.warehouseDbId, name: "Warehouse", engine: "postgres" }], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -210,7 +214,10 @@ describe("profiles e2e", () => { expect(parseJson(result.stdout, AuthProfileListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); diff --git a/tests/e2e/query.e2e.test.ts b/tests/e2e/query.e2e.test.ts index ce556d0..a3a82cd 100644 --- a/tests/e2e/query.e2e.test.ts +++ b/tests/e2e/query.e2e.test.ts @@ -1,18 +1,19 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { CardQueryResult, CardQueryResultCompact } from "@metabase/client/domain/card"; +import { parseJson } from "@metabase/client/json"; + import { getQuerySchemaBundle, QuerySchemaBundle, ValidationOutcome, -} from "../../src/core/schema/validate"; -import { CardQueryResult, CardQueryResultCompact } from "../../src/domain/card"; -import { parseJson } from "../../src/runtime/json"; - +} from "../../packages/cli/src/core/schema/validate"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { assertCompactColumns, assertCompletedQuery } from "./card-query"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; + const VALID_QUERY = { "lib/type": "mbql/query", database: 1, diff --git a/tests/e2e/run-cli.e2e.test.ts b/tests/e2e/run-cli.e2e.test.ts new file mode 100644 index 0000000..cc5c5c9 --- /dev/null +++ b/tests/e2e/run-cli.e2e.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { parseJson } from "@metabase/client/json"; + +import { AuthStatus } from "../../packages/cli/src/commands/auth/status"; +import { DEFAULT_PROFILE } from "../../packages/cli/src/core/auth/storage"; +import { cliErrorMessage } from "./cli-error"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; + +const SENTINEL_PROFILE = "e2e_env_leak_sentinel"; + +const HOSTILE_ENV: Record = { + MB_URL: "http://127.0.0.1:9", + MB_API_KEY: "mb_e2e_env_leak_sentinel_key", + MB_PROFILE: SENTINEL_PROFILE, + METABASE_URL: "http://127.0.0.1:9", + METABASE_API_KEY: "mb_e2e_env_leak_sentinel_key", + METABASE_PROFILE: SENTINEL_PROFILE, +}; + +describe("runCli env isolation e2e", () => { + const tempDirs: string[] = []; + const savedEnv = new Map(); + + beforeEach(() => { + for (const [key, value] of Object.entries(HOSTILE_ENV)) { + savedEnv.set(key, process.env[key]); + process.env[key] = value; + } + }); + + afterEach(async () => { + for (const [key, value] of savedEnv) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + savedEnv.clear(); + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + it("routes to the default profile even when the parent shell exports MB_PROFILE", async () => { + const configHome = await makeIsolatedConfigHome(); + + const result = await runCli({ args: ["auth", "status", "--json"], configHome }); + + expect(result.exitCode).toBe(0); + expect(parseJson(result.stdout, AuthStatus)).toEqual({ + profile: DEFAULT_PROFILE, + present: false, + url: null, + method: null, + user: null, + version: null, + tokenFeatures: null, + lastProbedAt: null, + lastFailure: null, + }); + }); + + it("reports no credentials even when the parent shell exports MB_URL and MB_API_KEY", async () => { + const configHome = await makeIsolatedConfigHome(); + + const result = await runCli({ args: ["db", "list", "--json"], configHome }); + + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain( + `Not authenticated for profile "${DEFAULT_PROFILE}".`, + ); + }); +}); diff --git a/tests/e2e/run-cli.ts b/tests/e2e/run-cli.ts index c35075f..0bd71e2 100644 --- a/tests/e2e/run-cli.ts +++ b/tests/e2e/run-cli.ts @@ -5,7 +5,7 @@ import { join, resolve } from "node:path"; import { execa } from "execa"; const REPO_ROOT = resolve(import.meta.dirname, "..", ".."); -const CLI_ENTRY = resolve(REPO_ROOT, "dist", "cli.mjs"); +const CLI_ENTRY = resolve(REPO_ROOT, "packages", "cli", "dist", "cli.mjs"); export interface RunCliOptions { args: ReadonlyArray; @@ -24,11 +24,26 @@ export interface RunCliResult { const DEFAULT_TIMEOUT_MS = 30_000; +// The spawned CLI runs on an allowlist, so no `MB_*`/`METABASE_*` from the developer's shell can +// point a test at their own instance. `PATH` resolves the `node` binary; `HOME` and `TMPDIR` back +// the platform's config and temp directories. +const INHERITED_ENV_KEYS = ["PATH", "HOME", "TMPDIR"] as const; + +function inheritedEnv(): NodeJS.ProcessEnv { + const inherited: NodeJS.ProcessEnv = {}; + for (const key of INHERITED_ENV_KEYS) { + const value = process.env[key]; + if (value !== undefined) { + inherited[key] = value; + } + } + return inherited; +} + export async function runCli(opts: RunCliOptions): Promise { const configHome = opts.configHome ?? (await mkTempConfigHome()); const env: NodeJS.ProcessEnv = { - PATH: process.env["PATH"], - HOME: process.env["HOME"], + ...inheritedEnv(), XDG_CONFIG_HOME: configHome, MB_CLI_DISABLE_KEYRING: "1", ...opts.env, @@ -36,6 +51,7 @@ export async function runCli(opts: RunCliOptions): Promise { const result = await execa("node", [CLI_ENTRY, ...opts.args], { env, + extendEnv: false, reject: false, timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, encoding: "utf8", @@ -59,6 +75,54 @@ export async function runCli(opts: RunCliOptions): Promise { }; } +export interface RunCliInterruptOptions extends RunCliOptions { + interruptAfterMs: number; +} + +// Ctrl-C reaches the CLI as SIGINT delivered to its pid, which is what a terminal's line +// discipline does with 0x03 and what `execa`'s `kill` reproduces without a pty. A process killed +// *by* the signal reports no exit code at all, so an exact code here is also proof the CLI handled +// it rather than dying of Node's default disposition. +export async function runCliInterrupt(opts: RunCliInterruptOptions): Promise { + const configHome = opts.configHome ?? (await mkTempConfigHome()); + const env: NodeJS.ProcessEnv = { + ...inheritedEnv(), + XDG_CONFIG_HOME: configHome, + MB_CLI_DISABLE_KEYRING: "1", + ...opts.env, + }; + + const subprocess = execa("node", [CLI_ENTRY, ...opts.args], { + env, + extendEnv: false, + reject: false, + timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + encoding: "utf8", + stdout: "pipe", + stderr: "pipe", + input: opts.stdin ?? "", + }); + + const interrupt = setTimeout(() => subprocess.kill("SIGINT"), opts.interruptAfterMs); + const result = await subprocess.finally(() => { + clearTimeout(interrupt); + }); + + if (typeof result.exitCode !== "number") { + const cause = result.signal ? `signal ${result.signal}` : "no exit code"; + throw new Error( + `mb CLI process did not exit normally (${cause}); stderr:\n${asString(result.stderr)}`, + ); + } + + return { + stdout: asString(result.stdout), + stderr: asString(result.stderr), + exitCode: result.exitCode, + configHome, + }; +} + // execa's stdout/stderr are typed as a union covering all encoding modes, // so we narrow at runtime even though `encoding: "utf8"` guarantees a string. function asString(stream: unknown): string { diff --git a/tests/e2e/search.e2e.test.ts b/tests/e2e/search.e2e.test.ts index ea2649d..1b521ff 100644 --- a/tests/e2e/search.e2e.test.ts +++ b/tests/e2e/search.e2e.test.ts @@ -1,13 +1,14 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { SearchListEnvelope } from "../../src/commands/search"; -import { SEARCH_MODELS } from "../../src/domain/search"; -import { parseJson } from "../../src/runtime/json"; +import { SEARCH_MODELS } from "@metabase/client/domain/search"; +import { parseJson } from "@metabase/client/json"; +import { SearchListEnvelope } from "../../packages/cli/src/commands/search"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { cliErrorMessage } from "./cli-error"; +import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; + const ORDERS_BY_STATUS_COMPACT = { id: SEEDED.ordersCardId, name: "Orders by status", @@ -52,7 +53,10 @@ describe("search e2e", () => { expect(parseJson(result.stdout, SearchListEnvelope)).toEqual({ data: [ORDERS_BY_STATUS_COMPACT], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, limit: 10, }); }); @@ -69,7 +73,10 @@ describe("search e2e", () => { expect(parseJson(result.stdout, SearchListEnvelope)).toEqual({ data: [ORDERS_BY_STATUS_COMPACT], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, limit: 20, }); }); @@ -89,7 +96,7 @@ describe("search e2e", () => { expect(result.stdout).toBe(""); }); - it("--limit with a non-positive integer rejects with ConfigError", async () => { + it("--limit with a non-positive integer rejects with a ConfigError envelope", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["search", "--limit", "0", "--json"], @@ -98,7 +105,8 @@ describe("search e2e", () => { }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("invalid --limit: 0 (must be ≥ 1)"); + expect(cliErrorCategory(result.stderr)).toBe("config"); + expect(cliErrorMessage(result.stderr)).toBe("invalid --limit: 0 (must be ≥ 1)"); expect(result.stdout).toBe(""); }); diff --git a/tests/e2e/segment.e2e.test.ts b/tests/e2e/segment.e2e.test.ts index c9fce1f..bd480a5 100644 --- a/tests/e2e/segment.e2e.test.ts +++ b/tests/e2e/segment.e2e.test.ts @@ -1,14 +1,16 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { SegmentListEnvelope } from "../../src/commands/segment/list"; -import { ValidationOutcome } from "../../src/core/schema/validate"; -import { SegmentCompact, type SegmentCreateInput } from "../../src/domain/segment"; -import { parseJson } from "../../src/runtime/json"; +import { SegmentCompact, type SegmentCreateInput } from "@metabase/client/domain/segment"; +import { parseJson } from "@metabase/client/json"; +import { SegmentListEnvelope } from "../../packages/cli/src/commands/segment/list"; +import { ValidationOutcome } from "../../packages/cli/src/core/schema/validate"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; +import { serverRejectedMessage } from "./server-gate"; + const FIRST_NEW_SEGMENT_ID = 1; const SEGMENT_NAME = "PositiveIdOrders"; const SEGMENT_DESCRIPTION = "Orders with a positive id."; @@ -77,7 +79,10 @@ describe("segment e2e", () => { expect(parseJson(result.stdout, SegmentListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -106,7 +111,10 @@ describe("segment e2e", () => { expect(parseJson(listResult.stdout, SegmentListEnvelope)).toEqual({ data: [NEW_SEGMENT_COMPACT], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -153,7 +161,7 @@ describe("segment e2e", () => { }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Metabase returned 500"); + expect(cliErrorMessage(result.stderr)).toBe(serverRejectedMessage()); expect(result.stdout).toBe(""); }); @@ -301,7 +309,10 @@ describe("segment e2e", () => { expect(parseJson(listResult.stdout, SegmentListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); diff --git a/tests/e2e/server-gate.ts b/tests/e2e/server-gate.ts index d807f52..03500ac 100644 --- a/tests/e2e/server-gate.ts +++ b/tests/e2e/server-gate.ts @@ -1,19 +1,75 @@ +import { readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { z } from "zod"; + +import { isFileNotFoundError } from "@metabase/client/errors"; +import { parseJson } from "@metabase/client/json"; import { checkCapabilities, mergeCapabilities, type Capabilities, -} from "../../src/core/version/capabilities"; -import type { ServerInfo } from "../../src/core/version/probe"; +} from "@metabase/client/version/capabilities"; +import type { ServerInfo } from "@metabase/client/version/probe"; import { readBootstrapSync } from "./bootstrap-data"; +import { resolveStackId } from "./defaults"; -// An unparsable version tag ("vUNKNOWN", "vLOCAL", any non-semver dev/head build) probes to +// A dev/head build ("vUNKNOWN", "vLOCAL_DEV", or any "-SNAPSHOT" tag) probes to // version: null. Treat that as the latest version so every version-gated suite runs — head and // local dev builds carry the newest features, and skipping them would hide regressions there. // The premium token-feature is still checked against the live probe, so this only relaxes the // version that genuinely can't be parsed; a suite whose token-feature the server lacks still skips. const HEAD_ASSUMED_MAJOR = 9999; +const HERE = dirname(fileURLToPath(import.meta.url)); + +// Gates run at module top level in the test worker, where `isolate: true` resets the module +// registry per file — a module-level array could not accumulate across suites. The stack id scopes +// the file so `e2e:matrix --parallel` stacks in one checkout do not overwrite each other's log. +export const GATE_SKIP_FILE_PATH = resolve(HERE, `.gate-skips.${resolveStackId()}.json`); + +const GateSkip = z.object({ + lane: z.string(), + reason: z.string(), +}); +export type GateSkip = z.infer; + +const GateSkipLog = z.array(GateSkip); + +export function readGateSkips(): GateSkip[] { + let raw: string; + try { + raw = readFileSync(GATE_SKIP_FILE_PATH, "utf8"); + } catch (error) { + if (isFileNotFoundError(error)) { + return []; + } + throw error; + } + return parseJson(raw, GateSkipLog, { source: GATE_SKIP_FILE_PATH }); +} + +export function clearGateSkips(): void { + try { + unlinkSync(GATE_SKIP_FILE_PATH); + } catch (error) { + if (isFileNotFoundError(error)) { + return; + } + throw error; + } +} + +function recordGateSkip(lane: string, reason: string): void { + const logged = readGateSkips(); + if (logged.some((entry) => entry.lane === lane)) { + return; + } + writeFileSync(GATE_SKIP_FILE_PATH, `${JSON.stringify([...logged, { lane, reason }], null, 2)}\n`); +} + function resolveServerInfo(): ServerInfo { const { server } = readBootstrapSync(); if (server.version !== null) { @@ -25,27 +81,50 @@ function resolveServerInfo(): ServerInfo { }; } -export function requireServer(required: Partial): string | null { +// `lane` names the describe or test the caller guards with the returned reason. It is what the +// closing report prints, so an unmet gate says which coverage went dark rather than adding one +// more anonymous digit to vitest's skip count. +export function requireServer(lane: string, required: Partial): string | null { const failure = checkCapabilities(resolveServerInfo(), mergeCapabilities(required)); - return failure === null ? null : failure.detail; + if (failure === null) { + return null; + } + recordGateSkip(lane, failure.detail); + return failure.detail; } +const OAUTH_UNSUPPORTED_REASON = + "server does not support full-API OAuth login (Metabase v63+) — re-run e2e:bootstrap if the image changed"; + // Gate for the OAuth login suite: a version check would be wrong here (head images without the // OAuth backend would run and fail), so bootstrap probes the discovery endpoint live and the // suite keys off that. The probe also rejects the agent-API-only OAuth server v60–62 ship // (no full-access scope advertised). Re-run `bun run e2e:bootstrap` after switching images. -export function requireOAuthServer(): string | null { +export function requireOAuthServer(lane: string): string | null { if (readBootstrapSync().server.oauthSupported) { return null; } - return "server does not support full-API OAuth login (Metabase v63+) — re-run e2e:bootstrap if the image changed"; + recordGateSkip(lane, OAUTH_UNSUPPORTED_REASON); + return OAUTH_UNSUPPORTED_REASON; } // True only when the server version is known AND below `minVersion` — the exact condition under // which a non-baseline command's preflight raises a CapabilityError (exit 2) rather than warning // and proceeding on an unknown version. Lets a suite assert the gate fires on the sub-version -// stacks the matrix boots, inverse to the `requireServer` skip the happy-path suite uses. +// stacks the matrix boots, inverse to the `requireServer` skip the happy-path suite uses. It logs +// nothing: the branch it selects against is the complementary one, not coverage that went missing. export function serverVersionBelow(minVersion: number): boolean { const { version } = resolveServerInfo(); return version !== null && version.major < minVersion; } + +// Metabase answers a structurally invalid MBQL 5 definition with a 500 through v61 and a 400 from +// v62 on. A suite asserting "the server, not the client-side validator, rejected this" pins the one +// status its own stack sends, so a 500 where a 400 belongs is still a failure. +const MBQL_REJECTION_STATUS_VERSION = 62; + +export function serverRejectedMessage(): string { + return serverVersionBelow(MBQL_REJECTION_STATUS_VERSION) + ? "Metabase returned 500." + : "Metabase returned 400."; +} diff --git a/tests/e2e/setting.e2e.test.ts b/tests/e2e/setting.e2e.test.ts index 542cb1b..36e80ed 100644 --- a/tests/e2e/setting.e2e.test.ts +++ b/tests/e2e/setting.e2e.test.ts @@ -1,10 +1,12 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { z } from "zod"; -import { SettingListEnvelope } from "../../src/commands/setting/list"; -import { createClient, type Client } from "../../src/core/http/client"; -import { SettingValue } from "../../src/domain/setting"; -import { parseJson } from "../../src/runtime/json"; +import { SettingValue } from "@metabase/client/domain/setting"; +import { createTransport, type Transport } from "@metabase/client/http/transport"; +import { parseJson } from "@metabase/client/json"; + +import { SettingListEnvelope } from "../../packages/cli/src/commands/setting/list"; +import { USER_AGENT } from "../../packages/cli/src/core/user-agent"; const IntegerSettingValue = SettingValue.extend({ value: z.number().int() }); const NumberSettingValue = SettingValue.extend({ value: z.number() }); @@ -15,22 +17,25 @@ import { cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { requireServer } from "./server-gate"; -const remoteSyncSkip = requireServer({ minVersion: 60, tokenFeature: "remote_sync" }); +const remoteSyncSkip = requireServer( + "setting › get --json on a string-valued setting wraps the bare server response", + { minVersion: 60, tokenFeature: "remote_sync" }, +); const MUTABLE_KEY = "enable-public-sharing"; const MUTABLE_KEY_ENV_NAME = "MB_ENABLE_PUBLIC_SHARING"; describe("setting e2e", () => { let bootstrap: E2EBootstrap; - let adminClient: Client; + let adminClient: Transport; const tempDirs: string[] = []; beforeAll(async () => { bootstrap = await readBootstrap(); - adminClient = createClient({ - url: bootstrap.baseUrl, - credential: { kind: "apiKey", apiKey: bootstrap.adminApiKey }, - }); + adminClient = createTransport( + { url: bootstrap.baseUrl, credential: { kind: "apiKey", apiKey: bootstrap.adminApiKey } }, + { userAgent: USER_AGENT }, + ); }); afterEach(async () => { diff --git a/tests/e2e/setup/bootstrap.ts b/tests/e2e/setup/bootstrap.ts index 0933a47..0640e7e 100644 --- a/tests/e2e/setup/bootstrap.ts +++ b/tests/e2e/setup/bootstrap.ts @@ -1,26 +1,32 @@ import { promises as fs } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { z } from "zod"; -import { errorMessage, isNotFoundError } from "../../../src/core/errors"; -import { createClient, type Client } from "../../../src/core/http/client"; -import { HttpError } from "../../../src/core/http/errors"; -import { backoffDelay, DEFAULT_MAX_RETRIES, runWithRetries } from "../../../src/core/http/retry"; -import { tryDiscoverMetadata } from "../../../src/core/http/oauth"; -import { probeServer, type ServerInfo } from "../../../src/core/version/probe"; -import { CardQueryResult } from "../../../src/domain/card"; -import { CurrentUser } from "../../../src/domain/user"; -import { parseJsonResult } from "../../../src/runtime/json"; -import { pollUntil } from "../../../src/runtime/poll"; +import { CardQueryResult } from "@metabase/client/domain/card"; +import { CurrentUser } from "@metabase/client/domain/user"; +import { errorMessage, isFileNotFoundError, MetabaseError } from "@metabase/client/errors"; +import { createTransport, type Transport } from "@metabase/client/http/transport"; +import { HttpError } from "@metabase/client/http/errors"; +import { tryDiscoverMetadata } from "@metabase/client/http/oauth"; +import { backoffDelay, DEFAULT_MAX_RETRIES, runWithRetries } from "@metabase/client/http/retry"; +import { parseJsonResult } from "@metabase/client/json"; +import { pollUntil } from "@metabase/client/poll"; +import { probeServer, type ServerInfo } from "@metabase/client/version/probe"; + +import { USER_AGENT } from "../../../packages/cli/src/core/user-agent"; import { Bootstrap, BOOTSTRAP_FILE_PATH, type E2EBootstrap, type SeededIds, } from "../bootstrap-data"; -import { resolveE2EBaseUrl, resolveSnapshotName } from "../defaults"; +import { + DEFAULT_E2E_STACK, + resolveE2EBaseUrl, + resolveSnapshotName, + resolveStackId, +} from "../defaults"; import { E2E_GROUPS } from "../seed/ids"; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -59,9 +65,15 @@ const ORDERS_OVERVIEW_DASHBOARD_DESCRIPTION = "E2E seeded dashboard with one ord const LIMITED_GROUP_NAME = "E2E Limited"; const LIBRARY_FEATURE = "library"; const LIBRARY_MIN_VERSION = 59; +const TRANSFORMS_ENABLED_SETTING = "transforms-enabled"; +const TRANSFORMS_MIN_VERSION = 59; +const TRANSFORMS_LOCKED_STATUSES: ReadonlySet = new Set([402, 403]); const BASE_URL = resolveE2EBaseUrl(); +const SESSION_HEADER = "x-metabase-session"; +const JSON_CONTENT_TYPE = "application/json"; + const SessionPropertiesResponse = z.object({ "setup-token": z.string().nullish() }).loose(); const SessionResponse = z.object({ id: z.string() }); const ApiKeyResponse = z.object({ unmasked_key: z.string() }).loose(); @@ -72,6 +84,9 @@ const TableMeta = z .loose(); const DatabaseMetadataResponse = z.object({ tables: z.array(TableMeta) }).loose(); const PermissionsGroupResponse = z.object({ id: z.number().int().positive() }).loose(); +const NamedEntityResponse = z.object({ name: z.string() }).loose(); +const PermissionsGroupListResponse = z.array(NamedEntityResponse); +const DatabaseListResponse = z.object({ data: z.array(NamedEntityResponse) }).loose(); const CollectionGraphResponse = z .object({ revision: z.number().int(), groups: z.record(z.string(), z.unknown()) }) .loose(); @@ -82,15 +97,28 @@ const DashboardWithDashcardsResponse = z }) .loose(); +// Bun pools connections, and a Metabase still warming up drops an idle one often enough to matter: +// the close surfaces on the next request, and a POST that may already have landed is one no retry +// policy can safely replay. A connection per request costs nothing at this volume. +const seedFetch: typeof fetch = (input, init) => fetch(input, { ...init, keepalive: false }); + +function apiKeyClient(apiKey: string): Transport { + return createTransport( + { url: BASE_URL, credential: { kind: "apiKey", apiKey } }, + { userAgent: USER_AGENT, fetchImpl: seedFetch }, + ); +} + async function main(): Promise { await waitForReady(BASE_URL, HEALTH_TIMEOUT_MS); const existing = await readStoredBootstrap(); if (existing && (await canReuseExisting(existing.adminApiKey))) { assertSnapshotMatchesSeed(existing); + await reportSnapshotTransforms(apiKeyClient(existing.adminApiKey), existing.server); // OAuth support depends on the booted image, not on the reused credentials — re-probe it so a // stale bootstrap file (or an image swap on the same stack) can't pin the wrong answer. - const oauthSupported = (await tryDiscoverMetadata(BASE_URL)) !== null; + const oauthSupported = (await tryDiscoverMetadata(BASE_URL, USER_AGENT)) !== null; if (existing.server.oauthSupported !== oauthSupported) { await writeStoredBootstrap({ ...existing, server: { ...existing.server, oauthSupported } }); } @@ -99,25 +127,24 @@ async function main(): Promise { } const sessionId = await ensureAdminSessionId(); + await assertServerNotSeeded(sessionId); const adminApiKey = await mintApiKey(sessionId, "e2e-admin-key", E2E_GROUPS.ADMIN); - const client = createClient({ - url: BASE_URL, - credential: { kind: "apiKey", apiKey: adminApiKey }, - }); + const client = apiKeyClient(adminApiKey); const apiKeyUser = await client.requestParsed(CurrentUser, "/api/user/current"); const probed = await probeServer(client, { retries: DEFAULT_MAX_RETRIES }); + if (transformsReady(probed)) { + await enableTransforms(client); + await reportTransformsUsable(client); + } const seeded = await seedContent(client, libraryReady(probed)); - const oauthSupported = (await tryDiscoverMetadata(BASE_URL)) !== null; + const oauthSupported = (await tryDiscoverMetadata(BASE_URL, USER_AGENT)) !== null; const server = { ...probed, oauthSupported }; const limitedGroupId = await createLimitedGroup(client); await revokeDefaultCollectionAccess(client, limitedGroupId, seeded.defaultCollectionId); const limitedApiKey = await mintApiKey(sessionId, "e2e-limited-key", limitedGroupId); - const limitedClient = createClient({ - url: BASE_URL, - credential: { kind: "apiKey", apiKey: limitedApiKey }, - }); + const limitedClient = apiKeyClient(limitedApiKey); const limitedKeyUser = await limitedClient.requestParsed(CurrentUser, "/api/user/current"); await assertLimitedKeyCannotQueryOrdersCard(limitedClient, seeded.ordersCardId); @@ -149,10 +176,10 @@ async function ensureAdminSessionId(): Promise { return direct.id; } - const sessionProps = await postOrGetPreAuthJson( + const sessionProps = await fetchJson( `${BASE_URL}/api/session/properties`, SessionPropertiesResponse, - "GET", + { method: "GET" }, ); const setupToken = sessionProps["setup-token"]; if (typeof setupToken !== "string" || setupToken.length === 0) { @@ -162,17 +189,21 @@ async function ensureAdminSessionId(): Promise { ); } - await postOrGetPreAuthJson(`${BASE_URL}/api/setup`, z.unknown(), "POST", { - token: setupToken, - user: { - first_name: ADMIN.first_name, - last_name: ADMIN.last_name, - email: ADMIN.email, - password: ADMIN.password, - site_name: ADMIN.site_name, - }, - prefs: { site_name: ADMIN.site_name, allow_tracking: false }, - database: null, + await fetchJson(`${BASE_URL}/api/setup`, z.unknown(), { + method: "POST", + headers: { "content-type": JSON_CONTENT_TYPE }, + body: JSON.stringify({ + token: setupToken, + user: { + first_name: ADMIN.first_name, + last_name: ADMIN.last_name, + email: ADMIN.email, + password: ADMIN.password, + site_name: ADMIN.site_name, + }, + prefs: { site_name: ADMIN.site_name, allow_tracking: false }, + database: null, + }), }); const after = await tryLogin(); @@ -182,13 +213,13 @@ async function ensureAdminSessionId(): Promise { return after.id; } -// These requests can't use the api-key `Client`: setup is pre-auth (no key yet) and -// `mintApiKey` authenticates with an `x-metabase-session` header the client doesn't model. -// They still share the client's retry loop via `runWithRetries`. +// These requests can't use the api-key `Transport`: setup is pre-auth (no key yet), and everything +// that runs before the admin key exists authenticates with an `x-metabase-session` header the +// client doesn't model. They still share the client's retry loop via `runWithRetries`. async function fetchWithRetry(url: string, init: RequestInit): Promise { return runWithRetries(async (attempt) => { try { - return { kind: "success", response: await fetch(url, init) }; + return { kind: "success", response: await seedFetch(url, init) }; } catch (error) { if (attempt >= DEFAULT_MAX_RETRIES) { const method = init.method ?? "GET"; @@ -205,7 +236,7 @@ async function fetchWithRetry(url: string, init: RequestInit): Promise async function mintApiKey(sessionId: string, namePrefix: string, groupId: number): Promise { const response = await fetchWithRetry(`${BASE_URL}/api/api-key`, { method: "POST", - headers: { "content-type": "application/json", "x-metabase-session": sessionId }, + headers: { "content-type": JSON_CONTENT_TYPE, [SESSION_HEADER]: sessionId }, body: JSON.stringify({ name: `${namePrefix}-${Date.now()}`, group_id: groupId, @@ -217,7 +248,7 @@ async function mintApiKey(sessionId: string, namePrefix: string, groupId: number return ApiKeyResponse.parse(await response.json()).unmasked_key; } -async function createLimitedGroup(client: Client): Promise { +async function createLimitedGroup(client: Transport): Promise { const created = await client.requestParsed(PermissionsGroupResponse, "/api/permissions/group", { method: "POST", body: { name: LIMITED_GROUP_NAME }, @@ -226,7 +257,7 @@ async function createLimitedGroup(client: Client): Promise { } async function revokeDefaultCollectionAccess( - client: Client, + client: Transport, limitedGroupId: number, defaultCollectionId: number, ): Promise { @@ -250,7 +281,7 @@ async function revokeDefaultCollectionAccess( } async function assertLimitedKeyCannotQueryOrdersCard( - client: Client, + client: Transport, ordersCardId: number, ): Promise { try { @@ -269,6 +300,65 @@ async function assertLimitedKeyCannotQueryOrdersCard( ); } +// The bootstrap artifact and the snapshot are working-tree state while the app-db is a docker +// volume under a compose project that no stack id namespaces, so nothing keeps the two in step: a +// fresh worktree, a `git clean -x`, or a METABASE_CLI_E2E_STACK that renames both artifacts without +// renaming the volume all leave a seeded server whose bootstrap file is gone. The seed path assumes +// an empty server. `/api/database`, `/api/collection`, `/api/card` and `/api/dashboard` all accept +// duplicate names, so re-seeding would create a second copy of each and `captureSnapshot` would +// freeze that into the fixture every suite restores; only `createLimitedGroup` fails loudly, and it +// runs last. Refusing up front is the one outcome that neither corrupts the fixture nor adopts an +// app-db of unknown provenance. It reads on the admin session rather than an api key so that it +// precedes the first thing the seed path creates: a refused bootstrap must leave the server as it +// found it, and refusals come in runs because retrying is what a developer reaches for. +async function assertServerNotSeeded(sessionId: string): Promise { + const residue = await findSeedResidue(sessionId); + if (residue.length === 0) { + return; + } + const sentences = [ + `bootstrap: ${BASE_URL} already holds seed content (${residue.join(", ")}), but ${BOOTSTRAP_FILE_PATH} and snapshot ${SNAPSHOT_NAME} are not both present, so seeding again would duplicate every entity it creates.`, + "Rebuild from empty with `bun run e2e:down && bun run e2e:up && bun run e2e:bootstrap`.", + ]; + const stackHint = stackScopeHint(); + if (stackHint !== null) { + sentences.push(stackHint); + } + throw new Error(sentences.join(" ")); +} + +// The stack id scopes the bootstrap file and the snapshot name but not the compose project, so +// `bun run e2e:up` under a non-default stack id boots — and seeds — the default stack's containers. +function stackScopeHint(): string | null { + const stack = resolveStackId(); + if (stack === DEFAULT_E2E_STACK) { + return null; + } + return `METABASE_CLI_E2E_STACK=${stack} names those two paths but not the compose project, so \`bun run e2e:up\` reached the ${DEFAULT_E2E_STACK} stack's containers; run a non-default stack through \`bun run e2e:matrix --stack=${stack}\`.`; +} + +// The warehouse database is the first entity `seedContent` creates and the limited group is the last +// entity the seed creates at all, so between them they witness a seed run that got anywhere: the +// collection, card and dashboard are only ever created after the database, and the limited api key +// only after the group. +async function findSeedResidue(sessionId: string): Promise { + const asAdmin: RequestInit = { headers: { [SESSION_HEADER]: sessionId } }; + const [databases, groups] = await Promise.all([ + fetchJson(`${BASE_URL}/api/database`, DatabaseListResponse, asAdmin), + fetchJson(`${BASE_URL}/api/permissions/group`, PermissionsGroupListResponse, asAdmin), + ]); + const residue: string[] = []; + if (databases.data.some((database) => database.name === WAREHOUSE_DB_NAME)) { + residue.push(`database "${WAREHOUSE_DB_NAME}"`); + } + // Metabase rejects a duplicate group name case-insensitively, so detect it the way the server does. + const limitedGroupName = LIMITED_GROUP_NAME.toLowerCase(); + if (groups.some((group) => group.name.toLowerCase() === limitedGroupName)) { + residue.push(`permissions group "${LIMITED_GROUP_NAME}"`); + } + return residue; +} + // Mirrors tests/e2e/server-gate.ts: a null (unparseable head/dev) version counts as the latest, so // the library round-trip seeds and runs on head images; real sub-59 images parse to a major below // the floor and skip seeding, since their `/api/ee/library` endpoints don't exist yet. @@ -291,7 +381,86 @@ function libraryReady(server: ServerInfo): boolean { return server.version === null || server.version.major >= LIBRARY_MIN_VERSION; } -async function seedContent(client: Client, libraryEnabled: boolean): Promise { +function transformsReady(server: ServerInfo): boolean { + return server.version === null || server.version.major >= TRANSFORMS_MIN_VERSION; +} + +// Only the transform suites turn on the opt-in, so a stack that will not take it costs those suites +// and nothing else. Bootstrap runs inside globalSetup, where a throw ends the run for every suite in +// the stack — a warning names the cause without taking the other forty suites down with it. +function warnTransformsUnavailable(detail: string): void { + process.stderr.write(`bootstrap: transform suites will fail — ${detail}\n`); +} + +// The opt-in lives in the app DB, so the snapshot every suite restores is what decides whether +// transforms work. Nothing has run yet at bootstrap time, so the live server still reflects what the +// snapshot holds — ask it rather than reading the setting back, because releases through v61 serve +// transforms whether or not it is set and their snapshots need nothing. +async function reportSnapshotTransforms(client: Transport, server: ServerInfo): Promise { + if (!transformsReady(server)) { + return; + } + const blocked = await transformsBlockedStatus(client); + if (blocked !== null) { + warnTransformsUnavailable( + `snapshot ${SNAPSHOT_NAME} leaves transforms locked (HTTP ${blocked}) — rebuild it with \`bun run e2e:down && bun run e2e:up && bun run e2e:bootstrap\``, + ); + } +} + +// Enabling the setting is the whole opt-in on a self-hosted stack; anything else that keeps +// transforms locked (a hosted instance missing the add-on, a caller the server won't treat as a +// data analyst) is a stack we cannot run the transform suites against, and saying so here beats +// discovering it one 402 at a time. +async function reportTransformsUsable(client: Transport): Promise { + const blocked = await transformsBlockedStatus(client); + if (blocked !== null) { + warnTransformsUnavailable( + `${BASE_URL} still refuses transforms (HTTP ${blocked}) after enabling ${TRANSFORMS_ENABLED_SETTING}`, + ); + } +} + +// The statuses a server that will not serve transforms answers to a plain transform read: 402 for +// the feature itself, 403 when the disabled setting leaves no enabled transform source types. +async function transformsBlockedStatus(client: Transport): Promise { + try { + await client.requestRaw("/api/transform", { expectContentType: "binary" }); + return null; + } catch (error) { + if (error instanceof HttpError && TRANSFORMS_LOCKED_STATUSES.has(error.status)) { + return error.status; + } + throw error; + } +} + +// Query transforms need no license — but a self-hosted instance must opt in via this setting, and +// the server answers every transform endpoint with 402 until it does. Setting it before the snapshot +// is captured is what makes the transform suites exercise transforms instead of skipping them. A +// server that will not take the write (an unregistered key on a dev jar older than the probed +// version suggests, a read-only value pinned by env) is left to `reportTransformsUsable`, which says +// what the server actually does with transforms afterwards. +async function enableTransforms(client: Transport): Promise { + try { + await client.requestRaw(`/api/setting/${TRANSFORMS_ENABLED_SETTING}`, { + method: "PUT", + body: { value: true }, + idempotent: true, + expectContentType: "binary", + }); + } catch (error) { + if (error instanceof HttpError) { + warnTransformsUnavailable( + `${BASE_URL} refused to set ${TRANSFORMS_ENABLED_SETTING} (HTTP ${error.status})`, + ); + return; + } + throw error; + } +} + +async function seedContent(client: Transport, libraryEnabled: boolean): Promise { const warehouseDbId = await createEntityId(client, "/api/database", { name: WAREHOUSE_DB_NAME, engine: "postgres", @@ -381,7 +550,7 @@ const LibraryCollectionListResponse = z.array(LibraryCollectionResponse); // The Library's Data/Metrics collections don't exist until the Library is created. POST // /api/ee/library/ is the one-time initializer (it 4xxs if already created), so check for the // `library-data` collection first and only create when absent. -async function ensureLibraryDataCollection(client: Client): Promise { +async function ensureLibraryDataCollection(client: Transport): Promise { const existing = await findLibraryDataCollectionId(client); if (existing !== null) { return existing; @@ -394,7 +563,7 @@ async function ensureLibraryDataCollection(client: Client): Promise { return created; } -async function findLibraryDataCollectionId(client: Client): Promise { +async function findLibraryDataCollectionId(client: Transport): Promise { const collections = await client.requestParsed(LibraryCollectionListResponse, "/api/collection", { query: { "include-library": true }, }); @@ -410,12 +579,12 @@ async function findLibraryDataCollectionId(client: Client): Promise { +async function createEntityId(client: Transport, path: string, body: unknown): Promise { const created = await client.requestParsed(EntityWithIdResponse, path, { method: "POST", body }); return created.id; } -async function waitForDatabaseSync(client: Client, databaseId: number): Promise { +async function waitForDatabaseSync(client: Transport, databaseId: number): Promise { await pollUntil( async () => client.requestParsed(DatabaseMetadataResponse, `/api/database/${databaseId}/metadata`), @@ -438,7 +607,7 @@ const ORDERS_ID_FIELD_NAME = "id"; type WarehouseSchema = Pick; async function discoverWarehouseSchema( - client: Client, + client: Transport, databaseId: number, ): Promise { const metadata = await client.requestParsed( @@ -478,7 +647,7 @@ function requireTableId(tableIdByName: Map, name: string): numbe return id; } -async function captureSnapshot(client: Client): Promise { +async function captureSnapshot(client: Transport): Promise { await client.requestRaw(`/api/testing/snapshot/${SNAPSHOT_NAME}`, { method: "POST", idempotent: true, @@ -490,7 +659,7 @@ async function snapshotFileExists(): Promise { await fs.stat(SNAPSHOT_FILE_PATH); return true; } catch (error) { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { return false; } throw error; @@ -501,14 +670,14 @@ async function waitForReady(baseUrl: string, timeoutMs: number): Promise { await pollUntil( async (signal) => { try { - const health = await fetch(`${baseUrl}/api/health`, { signal }); + const health = await seedFetch(`${baseUrl}/api/health`, { signal }); if (!health.ok) { return false; } // /api/health goes green before the app reliably serves real endpoints; gating on // the settings endpoint the probe + setup depend on keeps a slow (especially EE) // startup from resetting connections mid-bootstrap. - const properties = await fetch(`${baseUrl}/api/session/properties`, { signal }); + const properties = await seedFetch(`${baseUrl}/api/session/properties`, { signal }); return properties.ok; } catch { return false; @@ -519,19 +688,10 @@ async function waitForReady(baseUrl: string, timeoutMs: number): Promise { ); } -async function postOrGetPreAuthJson( - url: string, - schema: z.ZodType, - method: "GET" | "POST", - body?: unknown, -): Promise { - const init: RequestInit = { method }; - if (body !== undefined) { - init.headers = { "content-type": "application/json" }; - init.body = JSON.stringify(body); - } +async function fetchJson(url: string, schema: z.ZodType, init: RequestInit): Promise { const response = await fetchWithRetry(url, init); if (!response.ok) { + const method = init.method ?? "GET"; throw new Error(`${method} ${url} -> ${response.status}: ${await response.text()}`); } return schema.parse(await response.json()); @@ -540,7 +700,7 @@ async function postOrGetPreAuthJson( async function tryLogin(): Promise | null> { const response = await fetchWithRetry(`${BASE_URL}/api/session`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": JSON_CONTENT_TYPE }, body: JSON.stringify({ username: ADMIN.email, password: ADMIN.password }), }); if (!response.ok) { @@ -551,7 +711,7 @@ async function tryLogin(): Promise | null> { } async function keyStillWorks(apiKey: string): Promise { - const client = createClient({ url: BASE_URL, credential: { kind: "apiKey", apiKey } }); + const client = apiKeyClient(apiKey); try { await client.requestParsed(CurrentUser, "/api/user/current"); return true; @@ -568,7 +728,7 @@ async function readStoredBootstrap(): Promise { try { raw = await fs.readFile(BOOTSTRAP_FILE_PATH, "utf8"); } catch (error) { - if (isNotFoundError(error)) { + if (isFileNotFoundError(error)) { return null; } throw error; @@ -587,8 +747,16 @@ async function writeStoredBootstrap(data: E2EBootstrap): Promise { await fs.writeFile(BOOTSTRAP_FILE_PATH, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 }); } +// The developer detail names the request that failed; without it a transport failure reads as a +// bare "Could not reach Metabase" with no way to tell which step of the seed it came from. +function failureDetail(error: unknown): string { + if (!(error instanceof MetabaseError)) { + return ""; + } + return ` (${JSON.stringify(error.developerDetail)})`; +} + main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`bootstrap failed: ${message}\n`); + process.stderr.write(`bootstrap failed: ${errorMessage(error)}${failureDetail(error)}\n`); process.exit(1); }); diff --git a/tests/e2e/setup/global-setup.ts b/tests/e2e/setup/global-setup.ts index 2df044a..b9e8e69 100644 --- a/tests/e2e/setup/global-setup.ts +++ b/tests/e2e/setup/global-setup.ts @@ -5,23 +5,55 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { resolveE2EBaseUrl } from "../defaults"; +import { clearGateSkips, readGateSkips, type GateSkip } from "../server-gate"; const execFileAsync = promisify(execFile); const HERE = dirname(fileURLToPath(import.meta.url)); const E2E_ROOT = resolve(HERE, ".."); const REPO_ROOT = resolve(E2E_ROOT, "..", ".."); -const DIST_CLI = resolve(REPO_ROOT, "dist", "cli.mjs"); +const DIST_CLI = resolve(REPO_ROOT, "packages", "cli", "dist", "cli.mjs"); const BOOTSTRAP_SCRIPT = resolve(E2E_ROOT, "setup", "bootstrap.ts"); -export default async function setup(): Promise { +const RULE = "─".repeat(78); + +// Named `setup`/`teardown` rather than a default export: vitest reads `m.default` as the setup hook +// and drops `m.teardown` entirely when both are present, so a default export makes the report below +// silently never run. +export async function setup(): Promise { if (!existsSync(DIST_CLI)) { throw new Error( `Built CLI missing at ${DIST_CLI} — run \`bun run build\` before \`bun run test:e2e\`.`, ); } + clearGateSkips(); + await execFileAsync("bun", [BOOTSTRAP_SCRIPT], { env: { ...process.env, METABASE_CLI_E2E_URL: resolveE2EBaseUrl() }, }); } + +export function teardown(): void { + const skipped = readGateSkips(); + if (skipped.length === 0) { + return; + } + process.stdout.write(formatGateReport(skipped)); +} + +function formatGateReport(skipped: GateSkip[]): string { + const lanes = skipped.map(({ lane, reason }) => ` ${lane}\n ${reason}`).join("\n"); + const count = skipped.length === 1 ? "1 lane" : `${skipped.length} lanes`; + return [ + "", + RULE, + ` e2e coverage gaps — ${count} did not run against this server`, + RULE, + lanes, + RULE, + " The counts above cover everything else. They do not cover these.", + "", + "", + ].join("\n"); +} diff --git a/tests/e2e/setup/oauth-harness.ts b/tests/e2e/setup/oauth-harness.ts index 6c7f9ae..5bb691e 100644 --- a/tests/e2e/setup/oauth-harness.ts +++ b/tests/e2e/setup/oauth-harness.ts @@ -1,9 +1,10 @@ import { z } from "zod"; -import type { OAuthCredential } from "../../../src/core/auth/credential"; -import { writeOAuthProfile } from "../../../src/core/auth/storage"; -import { CurrentUser } from "../../../src/domain/user"; -import { parseJson } from "../../../src/runtime/json"; +import type { OAuthCredential } from "@metabase/client/auth/credential"; +import { CurrentUser } from "@metabase/client/domain/user"; +import { parseJson } from "@metabase/client/json"; + +import { writeOAuthProfile } from "../../../packages/cli/src/core/auth/storage"; import type { E2EBootstrap } from "../bootstrap-data"; // Browser-simulation and raw-protocol helpers for the OAuth e2e suite. They live under setup/ diff --git a/tests/e2e/setup/reset.ts b/tests/e2e/setup/reset.ts index d8e72fa..358675a 100644 --- a/tests/e2e/setup/reset.ts +++ b/tests/e2e/setup/reset.ts @@ -1,18 +1,20 @@ -import { createClient, type Client } from "../../../src/core/http/client"; +import { createTransport, type Transport } from "@metabase/client/http/transport"; + +import { USER_AGENT } from "../../../packages/cli/src/core/user-agent"; import { readBootstrap } from "../bootstrap-data"; import { resolveSnapshotName } from "../defaults"; -let cachedClient: Client | null = null; +let cachedClient: Transport | null = null; -async function adminClient(): Promise { +async function adminClient(): Promise { if (cachedClient !== null) { return cachedClient; } const bootstrap = await readBootstrap(); - cachedClient = createClient({ - url: bootstrap.baseUrl, - credential: { kind: "apiKey", apiKey: bootstrap.adminApiKey }, - }); + cachedClient = createTransport( + { url: bootstrap.baseUrl, credential: { kind: "apiKey", apiKey: bootstrap.adminApiKey } }, + { userAgent: USER_AGENT }, + ); return cachedClient; } diff --git a/tests/e2e/skills.e2e.test.ts b/tests/e2e/skills.e2e.test.ts index d9fb76e..d19537a 100644 --- a/tests/e2e/skills.e2e.test.ts +++ b/tests/e2e/skills.e2e.test.ts @@ -1,10 +1,10 @@ import { afterEach, assert, describe, expect, it } from "vitest"; -import { SkillGetEnvelope } from "../../src/commands/skills/get"; -import { SkillListEnvelope } from "../../src/commands/skills/list"; -import { SkillPathListEnvelope } from "../../src/commands/skills/path"; -import { parseJson } from "../../src/runtime/json"; +import { parseJson } from "@metabase/client/json"; +import { SkillGetEnvelope } from "../../packages/cli/src/commands/skills/get"; +import { SkillListEnvelope } from "../../packages/cli/src/commands/skills/list"; +import { SkillPathListEnvelope } from "../../packages/cli/src/commands/skills/path"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; const BUNDLED_VISIBLE_NAMES = [ @@ -106,6 +106,62 @@ describe("skills e2e", () => { expect(result.stderr).toContain("cut at"); }); + it("get answers a cap too small for even one skill with an empty window and no resumption point", async () => { + const result = await runCli({ + args: ["skills", "get", "core", "--json", "--max-bytes", "200"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + // What the answer would have measured is the answer itself, uncapped: the notice quotes it so + // a caller can size the cap that would carry it. + const uncapped = await runCli({ + args: ["skills", "get", "core", "--json", "--max-bytes", "0"], + configHome: await makeIsolatedConfigHome(), + }); + const fullBytes = Buffer.byteLength(uncapped.stdout.trimEnd(), "utf8"); + + expect(parseJson(result.stdout, SkillGetEnvelope)).toEqual({ + data: [], + returned: 0, + offset: 0, + total: 1, + has_more: true, + next_offset: null, + truncated: { reason: "max_bytes", bytes: fullBytes }, + }); + expect(result.stderr).toBe( + `… cut at ${fullBytes} bytes; a skill body is indivisible — pass --max-bytes 0 to print it whole, or \`mb skills path \` to read it from disk`, + ); + }); + + it("get --all walking next_offset under the default cap terminates and yields every skill once", async () => { + const configHome = await makeIsolatedConfigHome(); + const names: string[] = []; + let offset = 0; + + for (let iteration = 0; iteration < BUNDLED_VISIBLE_NAMES.length; iteration += 1) { + const result = await runCli({ + args: ["skills", "get", "--all", "--json", "--offset", String(offset)], + configHome, + }); + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, SkillGetEnvelope); + expect(envelope.returned).toBeGreaterThan(0); + names.push(...envelope.data.map((skill) => skill.name)); + if (!envelope.has_more) { + expect(names).toEqual([...BUNDLED_VISIBLE_NAMES]); + return; + } + const next = envelope.next_offset; + assert(next !== null && next !== undefined, "has_more must come with a next_offset"); + expect(next).toBe(offset + envelope.returned); + offset = next; + } + + throw new Error(`walk did not terminate; collected ${names.length} skills`); + }); + it("get accepts comma-separated names", async () => { const result = await runCli({ args: ["skills", "get", "git-sync,transform", "--json", "--max-bytes", "0"], diff --git a/tests/e2e/snippet.e2e.test.ts b/tests/e2e/snippet.e2e.test.ts index a068395..afab9d0 100644 --- a/tests/e2e/snippet.e2e.test.ts +++ b/tests/e2e/snippet.e2e.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { SnippetListEnvelope } from "../../src/commands/snippet/list"; -import { Snippet, SnippetCompact, type SnippetCreateInput } from "../../src/domain/snippet"; -import { parseJson } from "../../src/runtime/json"; +import { Snippet, SnippetCompact, type SnippetCreateInput } from "@metabase/client/domain/snippet"; +import { parseJson } from "@metabase/client/json"; +import { SnippetListEnvelope } from "../../packages/cli/src/commands/snippet/list"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; @@ -73,7 +73,10 @@ describe("snippet e2e", () => { expect(parseJson(result.stdout, SnippetListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -102,7 +105,10 @@ describe("snippet e2e", () => { expect(parseJson(listResult.stdout, SnippetListEnvelope)).toEqual({ data: [NEW_SNIPPET_COMPACT], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -221,7 +227,10 @@ describe("snippet e2e", () => { expect(parseJson(listResult.stdout, SnippetListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -244,7 +253,10 @@ describe("snippet e2e", () => { expect(parseJson(listResult.stdout, SnippetListEnvelope)).toEqual({ data: [{ ...NEW_SNIPPET_COMPACT, archived: true }], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); diff --git a/tests/e2e/subscription.e2e.test.ts b/tests/e2e/subscription.e2e.test.ts index c714a74..7733fa7 100644 --- a/tests/e2e/subscription.e2e.test.ts +++ b/tests/e2e/subscription.e2e.test.ts @@ -1,13 +1,13 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { SubscriptionListEnvelope } from "../../src/commands/subscription/list"; import { PulseCompact, type PulseChannelCompact, type PulseCreateInput, -} from "../../src/domain/pulse"; -import { parseJson } from "../../src/runtime/json"; +} from "@metabase/client/domain/pulse"; +import { parseJson } from "@metabase/client/json"; +import { SubscriptionListEnvelope } from "../../packages/cli/src/commands/subscription/list"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; @@ -119,7 +119,10 @@ describe("subscription e2e", () => { expect(parseJson(result.stdout, SubscriptionListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -165,7 +168,10 @@ describe("subscription e2e", () => { expect(parseJson(result.stdout, SubscriptionListEnvelope)).toEqual({ data: [expectedCompact(id)], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -181,7 +187,10 @@ describe("subscription e2e", () => { expect(parseJson(matching.stdout, SubscriptionListEnvelope)).toEqual({ data: [expectedCompact(id)], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); const other = await runCli({ @@ -193,7 +202,10 @@ describe("subscription e2e", () => { expect(parseJson(other.stdout, SubscriptionListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -210,7 +222,10 @@ describe("subscription e2e", () => { expect(parseJson(result.stdout, SubscriptionListEnvelope)).toEqual({ data: [expectedCompact(id)], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -225,7 +240,10 @@ describe("subscription e2e", () => { expect(parseJson(result.stdout, SubscriptionListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -423,7 +441,10 @@ describe("subscription e2e", () => { expect(parseJson(activeList.stdout, SubscriptionListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); const archivedList = await runCli({ @@ -435,7 +456,10 @@ describe("subscription e2e", () => { expect(parseJson(archivedList.stdout, SubscriptionListEnvelope)).toEqual({ data: [archivedCompact], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); }); diff --git a/tests/e2e/table.e2e.test.ts b/tests/e2e/table.e2e.test.ts index 827d57c..bca94f9 100644 --- a/tests/e2e/table.e2e.test.ts +++ b/tests/e2e/table.e2e.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { FieldListEnvelope } from "../../src/commands/table/fields"; -import { tableFieldsOversizeHint } from "../../src/commands/table/hints"; -import { TableListEnvelope } from "../../src/commands/table/list"; -import { Table, TableCompact } from "../../src/domain/table"; -import { parseJson } from "../../src/runtime/json"; +import { Table, TableCompact } from "@metabase/client/domain/table"; +import { parseJson } from "@metabase/client/json"; +import { FieldListEnvelope } from "../../packages/cli/src/commands/table/fields"; +import { tableFieldsOversizeHint } from "../../packages/cli/src/commands/table/hints"; +import { TableListEnvelope } from "../../packages/cli/src/commands/table/list"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; @@ -143,7 +143,66 @@ describe("table e2e", () => { expect(parseJson(result.stdout, TableListEnvelope)).toEqual({ data: SEEDED_WAREHOUSE_TABLES, returned: SEEDED_WAREHOUSE_TABLES.length, + offset: 0, total: SEEDED_WAREHOUSE_TABLES.length, + has_more: false, + next_offset: null, + }); + }); + + it("list --limit with --offset returns the matching slice and points at the rest", async () => { + const result = await runCli({ + args: [ + "table", + "list", + "--db-id", + String(SEEDED.warehouseDbId), + "--json", + "--limit", + "2", + "--offset", + "2", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, TableListEnvelope)).toEqual({ + data: SEEDED_WAREHOUSE_TABLES.slice(2, 4), + returned: 2, + offset: 2, + limit: 2, + total: SEEDED_WAREHOUSE_TABLES.length, + has_more: true, + next_offset: 4, + }); + }); + + it("list --offset onto the last page reports no further items", async () => { + const offset = SEEDED_WAREHOUSE_TABLES.length - 2; + const result = await runCli({ + args: [ + "table", + "list", + "--db-id", + String(SEEDED.warehouseDbId), + "--json", + "--offset", + String(offset), + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, TableListEnvelope)).toEqual({ + data: SEEDED_WAREHOUSE_TABLES.slice(offset), + returned: 2, + offset, + total: SEEDED_WAREHOUSE_TABLES.length, + has_more: false, + next_offset: null, }); }); @@ -263,14 +322,20 @@ describe("table e2e", () => { const fieldNames = envelope.data.map((field) => field.name).toSorted(); expect({ returned: envelope.returned, + offset: envelope.offset, total: envelope.total, + has_more: envelope.has_more, + next_offset: envelope.next_offset, fieldNames, everyFieldHasCustomersTableId: envelope.data.every( (field) => field.table_id === SEEDED.tables.customers, ), }).toEqual({ returned: CUSTOMERS_FIELD_NAMES.length, + offset: 0, total: CUSTOMERS_FIELD_NAMES.length, + has_more: false, + next_offset: null, fieldNames: CUSTOMERS_FIELD_NAMES, everyFieldHasCustomersTableId: true, }); diff --git a/tests/e2e/timeline-event.e2e.test.ts b/tests/e2e/timeline-event.e2e.test.ts index f8aeb5d..e4439ce 100644 --- a/tests/e2e/timeline-event.e2e.test.ts +++ b/tests/e2e/timeline-event.e2e.test.ts @@ -1,13 +1,13 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { DeleteResult } from "../../src/commands/delete-runtime"; import { TimelineEventCompact, type TimelineCreateInput, type TimelineEventCreateInput, -} from "../../src/domain/timeline"; -import { parseJson } from "../../src/runtime/json"; +} from "@metabase/client/domain/timeline"; +import { parseJson } from "@metabase/client/json"; +import { DeleteResult } from "../../packages/cli/src/commands/delete-runtime"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; diff --git a/tests/e2e/timeline.e2e.test.ts b/tests/e2e/timeline.e2e.test.ts index d4811df..4bf1f0f 100644 --- a/tests/e2e/timeline.e2e.test.ts +++ b/tests/e2e/timeline.e2e.test.ts @@ -1,15 +1,15 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { DeleteResult } from "../../src/commands/delete-runtime"; -import { TimelineEventListEnvelope } from "../../src/commands/timeline/events"; -import { TimelineListEnvelope } from "../../src/commands/timeline/list"; import { TimelineCompact, type TimelineCreateInput, type TimelineEventCreateInput, -} from "../../src/domain/timeline"; -import { parseJson } from "../../src/runtime/json"; +} from "@metabase/client/domain/timeline"; +import { parseJson } from "@metabase/client/json"; +import { DeleteResult } from "../../packages/cli/src/commands/delete-runtime"; +import { TimelineEventListEnvelope } from "../../packages/cli/src/commands/timeline/events"; +import { TimelineListEnvelope } from "../../packages/cli/src/commands/timeline/list"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; @@ -101,7 +101,10 @@ describe("timeline e2e", () => { expect(parseJson(result.stdout, TimelineListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -143,7 +146,10 @@ describe("timeline e2e", () => { expect(parseJson(result.stdout, TimelineListEnvelope)).toEqual({ data: [NEW_TIMELINE_COMPACT], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -223,7 +229,10 @@ describe("timeline e2e", () => { expect(parseJson(activeList.stdout, TimelineListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); const archivedList = await runCli({ @@ -235,7 +244,10 @@ describe("timeline e2e", () => { expect(parseJson(archivedList.stdout, TimelineListEnvelope)).toEqual({ data: [{ ...NEW_TIMELINE_COMPACT, archived: true }], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -269,7 +281,10 @@ describe("timeline e2e", () => { }, ], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -286,7 +301,10 @@ describe("timeline e2e", () => { expect(parseJson(result.stdout, TimelineEventListEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -330,7 +348,10 @@ describe("timeline e2e", () => { expect(parseJson(activeResult.stdout, TimelineEventListEnvelope)).toEqual({ data: [activeCompact], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); const allResult = await runCli({ @@ -346,7 +367,10 @@ describe("timeline e2e", () => { }).toEqual({ data: [activeCompact, archivedCompact], returned: 2, + offset: 0, total: 2, + has_more: false, + next_offset: null, }); }); diff --git a/tests/e2e/transform-job.e2e.test.ts b/tests/e2e/transform-job.e2e.test.ts index 2be9b49..fdebb1f 100644 --- a/tests/e2e/transform-job.e2e.test.ts +++ b/tests/e2e/transform-job.e2e.test.ts @@ -1,13 +1,16 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { DeleteResult } from "../../src/commands/delete-runtime"; -import { TransformJobListEnvelope } from "../../src/commands/transform-job/list"; -import { TransformJobRunResult } from "../../src/commands/transform-job/run"; -import { TransformJobActiveResult } from "../../src/commands/transform-job/set-active"; -import { TransformJobTransformsEnvelope } from "../../src/commands/transform-job/transforms"; -import { TransformJobCompact } from "../../src/domain/transform-job"; -import { parseJson } from "../../src/runtime/json"; - +import { TransformJobCompact } from "@metabase/client/domain/transform-job"; +import { parseJson } from "@metabase/client/json"; + +import { DeleteResult } from "../../packages/cli/src/commands/delete-runtime"; +import { TransformJobListEnvelope } from "../../packages/cli/src/commands/transform-job/list"; +import { + TransformJobRunResult, + type TransformJobRunResultJson, +} from "../../packages/cli/src/commands/transform-job/run"; +import { TransformJobActiveResult } from "../../packages/cli/src/commands/transform-job/set-active"; +import { TransformJobTransformsEnvelope } from "../../packages/cli/src/commands/transform-job/transforms"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; @@ -22,6 +25,39 @@ const HOURLY_JOB_ID = 1; const DAILY_JOB_ID = 2; const DAILY_TAG_ID = 2; const JOB_TRANSFORM_NAME = "e2e_job_transform"; +const FIRST_JOB_RUN_ID = 1; +const RUN_STARTED_MESSAGE = "Job run started"; + +// Every shipped release answers POST /run with an opaque `stub--` string; the +// real run id — and the null that reports nothing was started — arrived on the v64 line. +const NUMERIC_RUN_ID_MIN_VERSION = 64; +const numericRunIdSkip = requireServer("transform-job › job_run_id", { + minVersion: NUMERIC_RUN_ID_MIN_VERSION, +}); +const returnsStubRunId = numericRunIdSkip !== null; + +function stubRunIdPrefix(jobId: number): string { + return `stub-${jobId}-`; +} + +// The stub run id carries a wall-clock suffix, so trim it back to its deterministic prefix and let +// the envelope be asserted whole. +function withoutStubTimestamp( + result: TransformJobRunResultJson, + jobId: number, +): TransformJobRunResultJson { + if (typeof result.job_run_id !== "string") { + return result; + } + return { + ...result, + job_run_id: result.job_run_id.slice(0, stubRunIdPrefix(jobId).length), + }; +} + +function expectedRunId(jobId: number): string | null { + return returnsStubRunId ? stubRunIdPrefix(jobId) : null; +} interface JobTransformNativeQuery { type: "native"; @@ -133,7 +169,8 @@ function withoutActive(job: TransformJobCompact): Omit { let bootstrap: E2EBootstrap; @@ -322,7 +359,12 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(result.exitCode, result.stderr).toBe(0); - expect(parseJson(result.stdout, TransformJobRunResult).message).toBe("Job run started"); + expect( + withoutStubTimestamp(parseJson(result.stdout, TransformJobRunResult), HOURLY_JOB_ID), + ).toEqual({ + message: RUN_STARTED_MESSAGE, + job_run_id: expectedRunId(HOURLY_JOB_ID), + }); }); it("run --force-refresh triggers a manual job run", async () => { @@ -332,7 +374,43 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(result.exitCode, result.stderr).toBe(0); - expect(parseJson(result.stdout, TransformJobRunResult).message).toBe("Job run started"); + expect( + withoutStubTimestamp(parseJson(result.stdout, TransformJobRunResult), HOURLY_JOB_ID), + ).toEqual({ + message: RUN_STARTED_MESSAGE, + job_run_id: expectedRunId(HOURLY_JOB_ID), + }); + }); + + // Only the v64 line reports whether anything actually started; every earlier release answers with + // the stub id whether or not the job resolved to a transform, so there is nothing to distinguish. + describe.skipIf(numericRunIdSkip !== null)("job_run_id", () => { + it("run returns the numeric run id when the job resolves to transforms", async () => { + await createJobTransform(); + + const result = await runCli({ + args: ["transform-job", "run", String(DAILY_JOB_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, TransformJobRunResult)).toEqual({ + message: RUN_STARTED_MESSAGE, + job_run_id: FIRST_JOB_RUN_ID, + }); + }); + + it("run says the job was not started when it resolves to no transforms", async () => { + const result = await runCli({ + args: ["transform-job", "run", String(HOURLY_JOB_ID), "--format", "text"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain( + `Transform job ${HOURLY_JOB_ID} was not started (already running, or it resolves to no transforms).`, + ); + }); }); it("run with a non-integer id fails fast with ConfigError", async () => { @@ -368,7 +446,10 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { expect(parseJson(result.stdout, TransformJobTransformsEnvelope)).toEqual({ data: [JOB_TRANSFORM_COMPACT], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -382,7 +463,10 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { expect(parseJson(result.stdout, TransformJobTransformsEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -407,7 +491,7 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { expect(result.stderr).toContain("Not found: GET /api/transform-job/9999999/transforms."); }); - describe.skipIf(requireServer({ minVersion: 61 }) !== null)("set-active", () => { + describe.skipIf(setActiveSkipReason !== null)("set-active", () => { it("set-active false deactivates every job", async () => { const result = await runCli({ args: ["transform-job", "set-active", "false", "--json"], diff --git a/tests/e2e/transform-tag.e2e.test.ts b/tests/e2e/transform-tag.e2e.test.ts index 7d2ab1a..b99cb22 100644 --- a/tests/e2e/transform-tag.e2e.test.ts +++ b/tests/e2e/transform-tag.e2e.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { DeleteResult } from "../../src/commands/delete-runtime"; -import { TransformTagListEnvelope } from "../../src/commands/transform-tag/list"; -import { TransformTagCompact } from "../../src/domain/transform-tag"; -import { parseJson } from "../../src/runtime/json"; +import { TransformTagCompact } from "@metabase/client/domain/transform-tag"; +import { parseJson } from "@metabase/client/json"; +import { DeleteResult } from "../../packages/cli/src/commands/delete-runtime"; +import { TransformTagListEnvelope } from "../../packages/cli/src/commands/transform-tag/list"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; @@ -27,7 +27,7 @@ const USER_TAG_COMPACT = { built_in_type: null, } as const; -const skipReason = requireServer({ minVersion: 59 }); +const skipReason = requireServer("transform-tag › transform-tag e2e", { minVersion: 59 }); describe.skipIf(skipReason !== null)("transform-tag e2e", () => { let bootstrap: E2EBootstrap; diff --git a/tests/e2e/transform.e2e.test.ts b/tests/e2e/transform.e2e.test.ts index d04c014..10ecf91 100644 --- a/tests/e2e/transform.e2e.test.ts +++ b/tests/e2e/transform.e2e.test.ts @@ -1,25 +1,30 @@ import { afterEach, assert, beforeAll, describe, expect, it } from "vitest"; -import { z } from "zod"; - -import { DeleteResult } from "../../src/commands/delete-runtime"; -import { TransformCancelResult } from "../../src/commands/transform/cancel"; -import { TransformDependenciesEnvelope } from "../../src/commands/transform/dependencies"; -import { TransformListEnvelope } from "../../src/commands/transform/list"; -import { RUN_TERMINAL_STATUSES, TransformRunResult } from "../../src/commands/transform/run"; -import { TransformRunListEnvelope } from "../../src/commands/transform/runs"; -import { createClient, type Client } from "../../src/core/http/client"; -import { ValidationOutcome } from "../../src/core/schema/validate"; -import { Collection } from "../../src/domain/collection"; -import { TransformCompact, TransformRun, TransformRunCompact } from "../../src/domain/transform"; -import { parseJson } from "../../src/runtime/json"; -import { pollUntil } from "../../src/runtime/poll"; - +import { Collection } from "@metabase/client/domain/collection"; +import { + isTransformRunTerminal, + TransformCompact, + TransformRun, + TransformRunCompact, + TransformRunResult, +} from "@metabase/client/domain/transform"; +import { createTransport, type Transport } from "@metabase/client/http/transport"; +import { parseJson } from "@metabase/client/json"; +import { pollUntil } from "@metabase/client/poll"; + +import { DeleteResult } from "../../packages/cli/src/commands/delete-runtime"; +import { TransformCancelResult } from "../../packages/cli/src/commands/transform/cancel"; +import { TransformDependenciesEnvelope } from "../../packages/cli/src/commands/transform/dependencies"; +import { TransformListEnvelope } from "../../packages/cli/src/commands/transform/list"; +import { TransformRunListEnvelope } from "../../packages/cli/src/commands/transform/runs"; +import { USER_AGENT } from "../../packages/cli/src/core/user-agent"; +import { ValidationOutcome } from "../../packages/cli/src/core/schema/validate"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; import { requireServer, serverVersionBelow } from "./server-gate"; + const FIRST_TRANSFORM_ID = 1; const TRANSFORM_NAME = "e2e_transform"; const TRANSFORM_TARGET_TABLE = "e2e_transform"; @@ -36,19 +41,17 @@ interface TransformBody { target: { type: "table"; database: number; schema: string; name: string }; } -const RunStatusResponse = z.object({ status: z.string() }).loose(); - -async function waitForRunComplete(client: Client, runId: number): Promise { +async function waitForRunComplete(client: Transport, runId: number): Promise { await pollUntil( - async () => client.requestParsed(RunStatusResponse, `/api/transform/run/${runId}`), - (run) => RUN_TERMINAL_STATUSES.has(run.status), + async () => client.requestParsed(TransformRun, `/api/transform/run/${runId}`), + (run) => isTransformRunTerminal(run.status), { intervalMs: 500, timeoutMs: 30_000 }, ); } -async function waitForRunStarted(client: Client, runId: number): Promise { +async function waitForRunStarted(client: Transport, runId: number): Promise { await pollUntil( - async () => client.requestParsed(RunStatusResponse, `/api/transform/run/${runId}`), + async () => client.requestParsed(TransformRun, `/api/transform/run/${runId}`), (run) => run.status === "started", { intervalMs: 200, timeoutMs: 15_000 }, ); @@ -86,19 +89,19 @@ const TRANSFORM_COMPACT = { target_db_id: SEEDED.warehouseDbId, } as const; -const skipReason = requireServer({ minVersion: 59 }); +const skipReason = requireServer("transform › transform e2e", { minVersion: 59 }); describe.skipIf(skipReason !== null)("transform e2e", () => { let bootstrap: E2EBootstrap; - let adminClient: Client; + let adminClient: Transport; const tempDirs: string[] = []; beforeAll(async () => { bootstrap = await readBootstrap(); - adminClient = createClient({ - url: bootstrap.baseUrl, - credential: { kind: "apiKey", apiKey: bootstrap.adminApiKey }, - }); + adminClient = createTransport( + { url: bootstrap.baseUrl, credential: { kind: "apiKey", apiKey: bootstrap.adminApiKey } }, + { userAgent: USER_AGENT }, + ); }); afterEach(async () => { @@ -144,7 +147,10 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { expect(parseJson(result.stdout, TransformListEnvelope)).toEqual({ data: [TRANSFORM_COMPACT], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -566,7 +572,10 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { }, ], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); @@ -618,19 +627,26 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { }, ], returned: 1, + offset: 0, total: 1, + has_more: false, + next_offset: null, }); }); - it("runs --limit caps the result count to the requested page", async () => { + it("runs --limit caps the result count and points at the rest", async () => { await createSeedTransform(); - const runResult = await runCli({ - args: ["transform", "run", String(FIRST_TRANSFORM_ID), "--wait", "--json"], - configHome: await makeIsolatedConfigHome(), - env: authEnv(), - }); - expect(runResult.exitCode, runResult.stderr).toBe(0); + const runOnce = async (): Promise => { + const runResult = await runCli({ + args: ["transform", "run", String(FIRST_TRANSFORM_ID), "--wait", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(runResult.exitCode, runResult.stderr).toBe(0); + }; + await runOnce(); + await runOnce(); const result = await runCli({ args: ["transform", "runs", "--limit", "1", "--json"], @@ -651,7 +667,50 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { }, ], returned: 1, + offset: 0, limit: 1, + total: 2, + has_more: true, + next_offset: 1, + }); + }); + + it("runs --offset resumes at the row the unwindowed listing holds there", async () => { + await createSeedTransform(); + + const runOnce = async (): Promise => { + const runResult = await runCli({ + args: ["transform", "run", String(FIRST_TRANSFORM_ID), "--wait", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(runResult.exitCode, runResult.stderr).toBe(0); + }; + await runOnce(); + await runOnce(); + + const unwindowed = await runCli({ + args: ["transform", "runs", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(unwindowed.exitCode, unwindowed.stderr).toBe(0); + const listed = parseJson(unwindowed.stdout, TransformRunListEnvelope); + expect(listed.data).toHaveLength(2); + + const result = await runCli({ + args: ["transform", "runs", "--offset", "1", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, TransformRunListEnvelope)).toEqual({ + data: [listed.data[1]], + returned: 1, + offset: 1, + total: 2, + has_more: false, + next_offset: null, }); }); @@ -834,7 +893,10 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { expect(parseJson(result.stdout, TransformDependenciesEnvelope)).toEqual({ data: [], returned: 0, + offset: 0, total: 0, + has_more: false, + next_offset: null, }); }); @@ -908,8 +970,9 @@ describe.skipIf(!serverVersionBelow(GATE_MIN_VERSION))( const result = await runCli({ args: ["transform", "list", "--json"], configHome }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( - `This command requires Metabase v${GATE_MIN_VERSION}+ (this server is ${serverTag}). Upgrade Metabase or pin mb-cli to an older release.`, + expect(cliErrorMessage(result.stderr)).toBe( + `This operation requires Metabase v${GATE_MIN_VERSION}+ (this server is ${serverTag}). Upgrade Metabase to use it.\n` + + "Or install an `@metabase/cli` release that targets this server.", ); expect(result.stdout).toBe(""); }); @@ -925,10 +988,10 @@ describe.skipIf(!serverVersionBelow(GATE_MIN_VERSION))( }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( + expect(cliErrorMessage(result.stderr)).toBe( `This endpoint is not available on Metabase ${serverTag}: GET /api/transform. ` + - `The command may require a newer Metabase major version. ` + - `Run 'mb auth list' to see this server's version.`, + "It may require a newer Metabase major version.\n" + + "Run `mb auth list` to see this server's version.", ); expect(result.stdout).toBe(""); }); diff --git a/tests/e2e/upgrade.e2e.test.ts b/tests/e2e/upgrade.e2e.test.ts index 6f87632..877fb3e 100644 --- a/tests/e2e/upgrade.e2e.test.ts +++ b/tests/e2e/upgrade.e2e.test.ts @@ -2,10 +2,10 @@ import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import packageJson from "../../package.json" with { type: "json" }; -import { UpgradeStatus } from "../../src/commands/upgrade"; -import { parseJson } from "../../src/runtime/json"; +import { parseJson } from "@metabase/client/json"; +import packageJson from "../../packages/cli/package.json" with { type: "json" }; +import { UpgradeStatus } from "../../packages/cli/src/commands/upgrade"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; interface RegistryFixture { diff --git a/tests/e2e/uuid.e2e.test.ts b/tests/e2e/uuid.e2e.test.ts index aaf95d1..1c7497f 100644 --- a/tests/e2e/uuid.e2e.test.ts +++ b/tests/e2e/uuid.e2e.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it } from "vitest"; -import { parseJson } from "../../src/runtime/json"; -import { UuidList } from "../../src/commands/uuid"; +import { parseJson } from "@metabase/client/json"; +import { UuidList } from "../../packages/cli/src/commands/uuid"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; describe("uuid e2e", () => { diff --git a/tests/e2e/version.e2e.test.ts b/tests/e2e/version.e2e.test.ts index 58e1fa0..86e7a06 100644 --- a/tests/e2e/version.e2e.test.ts +++ b/tests/e2e/version.e2e.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import { writeProbeResult, writeProfile } from "../../src/core/auth/storage"; +import { writeProbeResult, writeProfile } from "../../packages/cli/src/core/auth/storage"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; @@ -67,7 +67,7 @@ describe("version preflight enforcement e2e", () => { expect(result.exitCode).toBe(2); expect(result.stderr).toContain( - "This command requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase or pin mb-cli to an older release.", + "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", ); }); @@ -78,7 +78,7 @@ describe("version preflight enforcement e2e", () => { const result = await runCli({ args: ["measure", "list", "--skip-preflight"], configHome }); expect(result.exitCode).toBe(1); - expect(result.stderr).not.toContain("This command requires Metabase"); + expect(result.stderr).not.toContain("This operation requires Metabase"); expect(result.stderr).toContain("Could not reach Metabase"); }); @@ -101,7 +101,7 @@ describe("version preflight enforcement e2e", () => { expect(result.exitCode).toBe(2); expect(result.stderr).toContain( - "This command requires the 'remote_sync' premium feature (not enabled on this server).", + "This operation requires the 'remote_sync' premium feature (not enabled on this server).", ); }); @@ -116,7 +116,7 @@ describe("version preflight enforcement e2e", () => { }); expect(result.exitCode).toBe(1); - expect(result.stderr).not.toContain("This command requires Metabase"); + expect(result.stderr).not.toContain("This operation requires Metabase"); expect(result.stderr).toContain("Could not reach Metabase"); }); }); diff --git a/tsconfig.json b/tsconfig.json index 47abe60..8c63c4e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,20 @@ "isolatedModules": true, "verbatimModuleSyntax": true, "skipLibCheck": true, - "noEmit": true + "noEmit": true, + // `@metabase/client`'s export map names its built output, which is what a consumer installs and + // nothing this workspace should compile against. Every in-repo importer reads the source. + "paths": { + "@metabase/client": ["./packages/client/src/index"], + "@metabase/client/*": ["./packages/client/src/*"] + } }, - "include": ["src/**/*", "tests/**/*", "scripts/**/*", "tsdown.config.ts", "eslint.config.js"] + "include": [ + "packages/cli/src/**/*", + "packages/client/src/**/*", + "packages/cli/tsdown.config.ts", + "packages/client/tsdown.config.ts", + "tests/**/*", + "scripts/**/*" + ] } diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 30fbc1e..ce7a9f1 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -1,13 +1,28 @@ +import { fileURLToPath } from "node:url"; + import { defineWorkspace } from "vitest/config"; +const CLIENT_SRC = fileURLToPath(new URL("./packages/client/src", import.meta.url)); + +// `@metabase/client`'s export map names its built output, which is what a consumer installs. In this +// workspace every importer reads the source, matching the `paths` mapping in the root tsconfig. +const clientSource = { + alias: [ + { find: /^@metabase\/client$/, replacement: `${CLIENT_SRC}/index.ts` }, + { find: /^@metabase\/client\//, replacement: `${CLIENT_SRC}/` }, + ], +}; + export default defineWorkspace([ { + resolve: clientSource, test: { name: "unit", - include: ["src/**/*.test.ts", "tests/structure.test.ts"], + include: ["packages/cli/src/**/*.test.ts", "packages/client/src/**/*.test.ts"], }, }, { + resolve: clientSource, test: { name: "e2e", include: ["tests/e2e/**/*.e2e.test.ts"],