diff --git a/.agents/rules/schema-types.md b/.agents/rules/schema-types.md index 7b70dba199..588d2a841a 100644 --- a/.agents/rules/schema-types.md +++ b/.agents/rules/schema-types.md @@ -27,7 +27,7 @@ modules** — files named `types.ts` (or `*.types.ts` under `configure/`): | `parser/service/tailordb/types.ts` | Parsed data structures (`TailorDBType`, `ParsedField`, permissions, ...) | | `parser/service/idp/types.ts` | Normalized IdP permission types shared by parser and CLI | | `plugin/types.ts` | Plugin authoring types, generation hook contexts, generator config | -| `runtime/types.ts` | Runtime principal/env types (`TailorUser`, `TailorActor`, `TailorEnv`) | +| `runtime/types.ts` | Runtime principal/env types (`TailorPrincipal`, `TailorEnv`) | Rules for pure type modules (enforced by oxlint): diff --git a/.agents/rules/sdk-internals.md b/.agents/rules/sdk-internals.md index 7502d60d19..9001db8d07 100644 --- a/.agents/rules/sdk-internals.md +++ b/.agents/rules/sdk-internals.md @@ -1,5 +1,20 @@ # SDK Internals +## API Naming: `define*` vs `create*` + +Public functions in `src/configure/` follow a strict naming convention. + +| Prefix | Meaning | Output | Where used | +| --------- | --------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `define*` | Declarative resource specification | Pure data/config struct (no SDK-managed runtime methods) | `tailor.config.ts` as resource entries | +| `create*` | Instantiation of a runtime-capable unit | Object with runtime-capable methods (`trigger`, `wait`, `resolve`) or user-written `body` function | Standalone files as default/named exports, or inside workflow job bodies | + +**`define*` examples:** `defineConfig`, `defineAuth`, `defineIdp`, `defineStaticWebSite`, `defineAIGateway`, `defineSecretManager`, `definePlugins` + +**`create*` examples:** `createResolver`, `createExecutor`, `createWorkflow`, `createWorkflowJob`, `createWaitPoint`, `createWaitPoints`, `createHttpAdapter` + +Decision rule: if the result has a method that calls the platform at runtime (`.trigger()`, `.wait()`, `.resolve()`, etc.) or carries a user-written `body` function, use `create*`. If the result is purely a typed config object that tooling/deployer reads, use `define*`. + ## Module Architecture and Import Rules The SDK enforces strict module boundaries to maintain a clean architecture: diff --git a/.agents/skills/e2e-setup/SKILL.md b/.agents/skills/e2e-setup/SKILL.md index 20fa0ce289..d9bbd50b47 100644 --- a/.agents/skills/e2e-setup/SKILL.md +++ b/.agents/skills/e2e-setup/SKILL.md @@ -2,7 +2,7 @@ name: e2e-setup description: > Set up the environment and run e2e tests in this repo (`example/e2e` and `packages/sdk/e2e`). - Covers tailor-sdk authentication, workspace selection, and re-deploying `example/` when + Covers tailor authentication, workspace selection, and re-deploying `example/` when the deployed app drifts from the current code. Use when running e2e tests locally, fixing e2e failures, or when a run errors with "Failed to refresh token", "Workspace ID not found", or mismatched counts/fields in resolver/workflow assertions. @@ -50,7 +50,7 @@ set -a; source .agents/skills/e2e-setup/ids.local.env; set +a **Sanity check on `TAILOR_PLATFORM_WORKSPACE_ID`.** Before running `example/e2e`, confirm the workspace still has `my-app` deployed: ``` -pnpm exec tailor-sdk workspace app list --workspace-id "$TAILOR_PLATFORM_WORKSPACE_ID" +pnpm exec tailor workspace app list --workspace-id "$TAILOR_PLATFORM_WORKSPACE_ID" ``` If it is gone, re-deploy per [Pre-deploy](#one-time-setup) step 4. @@ -63,18 +63,18 @@ The tests assert against the **deployed** state of `example/` (resolver count, w Only needed when `ids.local.env` has no `TAILOR_PLATFORM_WORKSPACE_ID` yet, or the saved workspace was deleted / no longer hosts `my-app`. Write the resolved workspace ID back to `ids.local.env` when finished. -1. Make sure you are logged in. `tailor-sdk login` opens a browser — **only the user can run it**; the agent must ask. Verify with `pnpm exec tailor-sdk workspace list` (errors with `Tailor Platform token not found.` if unauthenticated). +1. Make sure you are logged in. `tailor login` opens a browser — **only the user can run it**; the agent must ask. Verify with `pnpm exec tailor workspace list` (errors with `Tailor Platform token not found.` if unauthenticated). 2. Look up the organization and folder you want the workspace to live under. The `workspace create` flags below need both IDs: ``` - pnpm exec tailor-sdk organization list - pnpm exec tailor-sdk organization folder list --organization-id + pnpm exec tailor organization list + pnpm exec tailor organization folder list --organization-id # Optional: drill into a sub-folder - pnpm exec tailor-sdk organization folder list --organization-id --parent-folder-id + pnpm exec tailor organization folder list --organization-id --parent-folder-id ``` 3. Pick or create a personal workspace. Suggested name: `example-e2e` (descriptive of purpose; fall back to a personal prefix only if it collides under the same folder). The agent must not invent a name — use the suggestion or ask the user: ``` - pnpm exec tailor-sdk workspace list - pnpm exec tailor-sdk workspace create --name example-e2e --region asia-northeast --organization-id --folder-id + pnpm exec tailor workspace list + pnpm exec tailor workspace create --name example-e2e --region asia-northeast --organization-id --folder-id ``` 4. Deploy `example/` into it once: ``` @@ -83,7 +83,7 @@ Only needed when `ids.local.env` has no `TAILOR_PLATFORM_WORKSPACE_ID` yet, or t Use `run deploy`, not bare `deploy` — pnpm has a builtin `deploy` command that shadows the package script when invoked via `--filter`. 5. Confirm `my-app` is present: ``` - pnpm exec tailor-sdk workspace app list --workspace-id + pnpm exec tailor workspace app list --workspace-id ``` Optionally save the workspace as a profile (`~/.config/tailor-platform/config.yaml`) so `TAILOR_PLATFORM_PROFILE=` alone is enough. @@ -134,9 +134,9 @@ The script uses `loadAccessToken()` so it works with keyring/config credentials | Error | Cause | Fix | | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | -| `Failed to refresh token. Your session may have expired.` | The refresh token in `~/.config/tailor-platform/config.yaml` is expired. | `pnpm exec tailor-sdk login` (interactive; only the user can run this — the agent should ask). | +| `Failed to refresh token. Your session may have expired.` | The refresh token in `~/.config/tailor-platform/config.yaml` is expired. | `pnpm exec tailor login` (interactive; only the user can run this — the agent should ask). | | `Workspace ID not found.` | No `--workspace-id`, no `TAILOR_PLATFORM_WORKSPACE_ID`, no profile set. | Set `TAILOR_PLATFORM_WORKSPACE_ID` or `TAILOR_PLATFORM_PROFILE`. | -| `Application my-app does not have an auth configuration.` / `Machine user manager-machine-user not found.` | Wrong workspace selected, or `example/` was never deployed there. | Confirm with `tailor-sdk workspace app list`; deploy if missing. | +| `Application my-app does not have an auth configuration.` / `Machine user manager-machine-user not found.` | Wrong workspace selected, or `example/` was never deployed there. | Confirm with `tailor workspace app list`; deploy if missing. | | `TAILOR_PLATFORM_ORGANIZATION_ID` / `..._FOLDER_ID` unset (in `packages/sdk/e2e`) | The suite needs these to create workspaces. | `source .agents/skills/e2e-setup/ids.local.env` (or follow the fallback in [Stored IDs](#stored-ids-idslocalenv) to populate it). | ## When the user reports an e2e failure diff --git a/.agents/skills/llm-challenge/CREATING_PROBLEMS.md b/.agents/skills/llm-challenge/CREATING_PROBLEMS.md index 8fd74097df..dcdfaa96e4 100644 --- a/.agents/skills/llm-challenge/CREATING_PROBLEMS.md +++ b/.agents/skills/llm-challenge/CREATING_PROBLEMS.md @@ -18,7 +18,7 @@ Rules: - `verify.json`, when present, contains visible minimum-correctness checks only. Checks should encode conditions where missing evidence is definitely wrong, similar to type checking; do not put ideal implementations, hidden answers, scores, or broad quality judgments there. - Write `prompt.md` in English. - For `sdk-api`, do not include SDK API names, imports, code examples, or direct solution hints. -- For `cli`, the prompt may name the `tailor-sdk` binary, but must not name the target subcommand or exact arguments. +- For `cli`, the prompt may name the `tailor` binary, but must not name the target subcommand or exact arguments. - Keep `scaffold/` minimal and runnable enough for the task. Do not add `solution/`, evaluator tests, scoring metadata, or hidden hints. Validate discovery and focused behavior with narrow tests or a targeted dry run when practical. diff --git a/.agents/skills/tailor b/.agents/skills/tailor new file mode 120000 index 0000000000..2165e68b16 --- /dev/null +++ b/.agents/skills/tailor @@ -0,0 +1 @@ +../../packages/sdk/agent-skills/tailor \ No newline at end of file diff --git a/.agents/skills/tailor-sdk b/.agents/skills/tailor-sdk deleted file mode 120000 index 167d322cb7..0000000000 --- a/.agents/skills/tailor-sdk +++ /dev/null @@ -1 +0,0 @@ -../../packages/sdk/agent-skills/tailor-sdk \ No newline at end of file diff --git a/.changeset/apply-deploy-source-strings.md b/.changeset/apply-deploy-source-strings.md new file mode 100644 index 0000000000..bf47fb101b --- /dev/null +++ b/.changeset/apply-deploy-source-strings.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Rewrite `tailor-sdk apply` to `tailor-sdk deploy` in source files that contain embedded CLI command strings. diff --git a/.changeset/auth-attributes-rename.md b/.changeset/auth-attributes-rename.md new file mode 100644 index 0000000000..45744f30f8 --- /dev/null +++ b/.changeset/auth-attributes-rename.md @@ -0,0 +1,7 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/create-sdk": patch +"@tailor-platform/sdk-codemod": patch +--- + +Rename auth attribute module augmentation from `AttributeMap` to `Attributes`. diff --git a/.changeset/cli-plugins.md b/.changeset/cli-plugins.md new file mode 100644 index 0000000000..f697886554 --- /dev/null +++ b/.changeset/cli-plugins.md @@ -0,0 +1,10 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add CLI plugin support (beta). Running `tailor ` for an unknown subcommand now executes an external `tailor-` executable found on your PATH or in `node_modules/.bin` (project-local takes precedence), forwarding all following arguments. This also works for unknown subcommands nested under a known command — e.g. `tailor tailordb erd` dispatches to `tailor-tailordb-erd`. Builtins always take precedence, matching stops at the first unknown segment, and a command that takes its own arguments is never replaced by a plugin. The plugin receives the current Tailor Platform context via environment variables (`TAILOR_PLATFORM_TOKEN`, `TAILOR_PLATFORM_URL`, `TAILOR_PLATFORM_OAUTH2_CLIENT_ID`, `TAILOR_PLATFORM_WORKSPACE_ID`, `TAILOR_PLATFORM_USER`, `TAILOR_CONFIG_PATH`, `TAILOR_VERSION`, `TAILOR_BIN`); token, workspace, and user are best-effort, so auth-free plugins still run when you are not logged in. + +Also adds: + +- `tailor auth token` — print a valid access token (refreshing it if expired) for use by plugins and scripts. +- `tailor plugin list` — list discovered plugins and their executable paths. diff --git a/.changeset/codemod-llm-review.md b/.changeset/codemod-llm-review.md new file mode 100644 index 0000000000..dafb3bbabd --- /dev/null +++ b/.changeset/codemod-llm-review.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Add LLM-assisted review support to the codemod runner. A codemod can declare `suspiciousPatterns` plus a `prompt`; after running, files whose post-transform content still matches a suspicious pattern are reported as `llmReviews` (in the JSON output and on stderr) together with the codemod's migration prompt. This surfaces the cases a deterministic transform cannot safely complete (e.g. a value reached through a variable) so they can be finished with an LLM. The `auth.invoker(...)` codemod adopts this for its non-literal-argument cases. diff --git a/.changeset/codemod-migration-docs.md b/.changeset/codemod-migration-docs.md new file mode 100644 index 0000000000..6efdcd67d6 --- /dev/null +++ b/.changeset/codemod-migration-docs.md @@ -0,0 +1,10 @@ +--- +"@tailor-platform/sdk-codemod": patch +"@tailor-platform/sdk": patch +--- + +Generate the v2 migration guide (`packages/sdk/docs/migration/v2.md`) from the codemod registry, which is the single source of truth. Each entry renders its name, automation level (Automatic / Partially automatic / Manual), description, optional before/after `examples`, and — for changes the codemods cannot fully migrate on their own — the LLM/manual migration prompt. Run `pnpm codemod:docs:update` to regenerate and `pnpm codemod:docs:check` (wired into `pnpm check`) to verify it is in sync. + +`scriptPath` is now optional, so the registry can also describe codemod-less ("manual") migrations that ship only guidance (`examples` / `prompt` / `suspiciousPatterns`) with no automatic transform. A manual entry with a `prompt` but no scoping pattern is surfaced as a project-wide `llmReviews` entry at runtime. + +Add a `sdk-codemod list` command that prints every registered rule (id, name, kind, version range). diff --git a/.changeset/codemod-residual-matching.md b/.changeset/codemod-residual-matching.md new file mode 100644 index 0000000000..d62d977299 --- /dev/null +++ b/.changeset/codemod-residual-matching.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Reduce false-positive v2 codemod warnings and LLM-review prompts from source comments, string literals, and identifier substring matches. diff --git a/.changeset/codemod-runner-metadata.md b/.changeset/codemod-runner-metadata.md new file mode 100644 index 0000000000..e2e85700bc --- /dev/null +++ b/.changeset/codemod-runner-metadata.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Report the codemod runner identity in the JSON summary, including the source checkout commit and local build command when run from a branch build, so prerelease migration validation can distinguish exact npm packages from branch-head behavior. diff --git a/.changeset/execute-script-json-arg.md b/.changeset/execute-script-json-arg.md new file mode 100644 index 0000000000..5342c615d3 --- /dev/null +++ b/.changeset/execute-script-json-arg.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +`executeScript` now takes its `arg` as a JSON-serializable value instead of a pre-serialized JSON string. Pass the value directly (e.g. `arg: { a: 1 }`) instead of `arg: JSON.stringify({ a: 1 })`. + +Add the `v2/execute-script-arg` codemod, which unwraps `JSON.stringify(...)` passed as the `executeScript` `arg` option. Indirect forms (a stringified value held in a variable, etc.) cannot be rewritten automatically and are surfaced as an LLM-assisted review task with a migration prompt. diff --git a/.changeset/execute-script-review-scope.md b/.changeset/execute-script-review-scope.md new file mode 100644 index 0000000000..395507f08a --- /dev/null +++ b/.changeset/execute-script-review-scope.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Reduce noisy `executeScript` LLM-review prompts by flagging files only when unresolved `arg` stringification remains likely. diff --git a/.changeset/fix-rename-bin-source-files.md b/.changeset/fix-rename-bin-source-files.md new file mode 100644 index 0000000000..0ce99f44c7 --- /dev/null +++ b/.changeset/fix-rename-bin-source-files.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Apply the v2 `rename-bin` codemod to SDK CLI command strings in TypeScript and JavaScript source files. diff --git a/.changeset/fix-v2-prerelease-codemods.md b/.changeset/fix-v2-prerelease-codemods.md new file mode 100644 index 0000000000..90ba3dcf11 --- /dev/null +++ b/.changeset/fix-v2-prerelease-codemods.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Run v2 codemods when the target version is a v2 prerelease. diff --git a/.changeset/invoker-option-rename.md b/.changeset/invoker-option-rename.md new file mode 100644 index 0000000000..a6ddc8fee0 --- /dev/null +++ b/.changeset/invoker-option-rename.md @@ -0,0 +1,9 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +"@tailor-platform/create-sdk": patch +--- + +Rename resolver, executor, workflow trigger, and typed workflow start machine-user options from `authInvoker` to `invoker`. + +Update create-sdk templates and the v2 auth invoker codemod to generate the new `invoker` option. diff --git a/.changeset/keyring-default-storage.md b/.changeset/keyring-default-storage.md new file mode 100644 index 0000000000..b3beffd460 --- /dev/null +++ b/.changeset/keyring-default-storage.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Store CLI login tokens in the OS keyring by default when available. If the keyring is unavailable, tokens are stored in the platform config file. diff --git a/.changeset/open-download-review-scope.md b/.changeset/open-download-review-scope.md new file mode 100644 index 0000000000..baf39cabc8 --- /dev/null +++ b/.changeset/open-download-review-scope.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Limit the `openDownloadStream` migration review prompt to files that reference deprecated download stream APIs. diff --git a/.changeset/parser-schema-strict.md b/.changeset/parser-schema-strict.md new file mode 100644 index 0000000000..f06690fcd7 --- /dev/null +++ b/.changeset/parser-schema-strict.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Reject unknown keys in SDK parser schemas instead of silently dropping them from application definitions. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000..cdd1033bd8 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,39 @@ +{ + "mode": "pre", + "tag": "next", + "initialVersions": { + "@tailor-platform/create-sdk": "1.64.0", + "@tailor-platform/sdk": "1.64.0", + "@tailor-platform/sdk-codemod": "0.2.7" + }, + "changesets": [ + "apply-deploy-source-strings", + "codemod-llm-review", + "codemod-migration-docs", + "codemod-residual-matching", + "execute-script-json-arg", + "execute-script-review-scope", + "invoker-option-rename", + "keyring-default-storage", + "open-download-review-scope", + "principal-followup-review", + "principal-unify-codemod", + "remove-auth-invoker-helper", + "remove-define-generators", + "remove-function-test-run-input-wrapper", + "remove-open-download-stream", + "remove-runtime-globals-compatibility", + "remove-tailor-sdk-skills-shim", + "remove-v2-cli-aliases", + "remove-workflow-test-env-fallback", + "renovate-1516", + "renovate-1525", + "require-function-log-content-hash", + "runtime-globals-import", + "store-cli-users-by-subject", + "tailor-principal-type", + "timestamps-updated-at-create", + "v2-baseline", + "workflow-trigger-dispatch" + ] +} diff --git a/.changeset/principal-followup-review.md b/.changeset/principal-followup-review.md new file mode 100644 index 0000000000..cd77e30a51 --- /dev/null +++ b/.changeset/principal-followup-review.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Flag files that need project-specific review after the v2 principal migration, including resolver helper adapters and nullable `caller` follow-ups. diff --git a/.changeset/principal-unify-codemod.md b/.changeset/principal-unify-codemod.md new file mode 100644 index 0000000000..c2542022b5 --- /dev/null +++ b/.changeset/principal-unify-codemod.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": minor +--- + +Add the `v2/principal-unify` codemod so `tailor-sdk upgrade` can migrate SDK principal APIs to `TailorPrincipal`. diff --git a/.changeset/principal-unify-review-findings.md b/.changeset/principal-unify-review-findings.md new file mode 100644 index 0000000000..c9bab5eff5 --- /dev/null +++ b/.changeset/principal-unify-review-findings.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Report precise file-local findings for `principal-unify` review follow-ups, including nullable caller call sites and `context.user` helper adapters. diff --git a/.changeset/principal-unify-review-followup.md b/.changeset/principal-unify-review-followup.md new file mode 100644 index 0000000000..2dd4833ae3 --- /dev/null +++ b/.changeset/principal-unify-review-followup.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Fix `v2/principal-unify` review findings for nested SDK field parser invoker values and destructured context helper messages. diff --git a/.changeset/remove-auth-connection-token.md b/.changeset/remove-auth-connection-token.md new file mode 100644 index 0000000000..e78da78223 --- /dev/null +++ b/.changeset/remove-auth-connection-token.md @@ -0,0 +1,6 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Remove the deprecated `auth.getConnectionToken()` helper from values returned by `defineAuth()`. Use `authconnection.getConnectionToken(...)` from `@tailor-platform/sdk/runtime` in resolvers, executors, and workflows instead. The v2 codemod rewrites direct `auth.getConnectionToken(...)` calls when the `auth` binding is imported from `tailor.config`. diff --git a/.changeset/remove-auth-invoker-helper.md b/.changeset/remove-auth-invoker-helper.md new file mode 100644 index 0000000000..5c94a22e74 --- /dev/null +++ b/.changeset/remove-auth-invoker-helper.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Remove the deprecated `auth.invoker("")` helper. Pass machine user names directly as `authInvoker` strings in resolver, executor, and workflow APIs. diff --git a/.changeset/remove-define-generators.md b/.changeset/remove-define-generators.md new file mode 100644 index 0000000000..bf2676d7f1 --- /dev/null +++ b/.changeset/remove-define-generators.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Remove `defineGenerators()` and legacy `generators` config support. Use `definePlugins()` with the built-in plugin packages for code generation. diff --git a/.changeset/remove-function-test-run-input-wrapper.md b/.changeset/remove-function-test-run-input-wrapper.md new file mode 100644 index 0000000000..5341ba5257 --- /dev/null +++ b/.changeset/remove-function-test-run-input-wrapper.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Remove support for wrapping `tailor-sdk function test-run --arg` resolver input in an `input` object. Pass resolver input fields directly as the `--arg` JSON value. diff --git a/.changeset/remove-open-download-stream.md b/.changeset/remove-open-download-stream.md new file mode 100644 index 0000000000..e40b446c0e --- /dev/null +++ b/.changeset/remove-open-download-stream.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/create-sdk": patch +--- + +Remove the deprecated `openDownloadStream` file streaming API. Use `downloadStream` for streamed file downloads. + +The generated file utilities now emit `downloadFileStream`, which calls `downloadStream` and returns `FileDownloadStreamResponse`, instead of the removed `openFileDownloadStream` helper. diff --git a/.changeset/remove-runtime-globals-compatibility.md b/.changeset/remove-runtime-globals-compatibility.md new file mode 100644 index 0000000000..1917f11eee --- /dev/null +++ b/.changeset/remove-runtime-globals-compatibility.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Remove the v1 runtime globals compatibility layer. Importing from `@tailor-platform/sdk` no longer activates the ambient `tailor.*` / `tailordb.*` declarations; opt into globals with `@tailor-platform/sdk/runtime/globals` or use the typed wrappers from `@tailor-platform/sdk/runtime`. + +The capital-cased `Tailordb.*` namespace is removed. If your project still references `Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, or `typeof Tailordb.Client`, migrate before upgrading: run `pnpm dlx @tailor-platform/sdk-codemod v2/tailordb-namespace` to rewrite them to lowercase `tailordb.*`, then add `import "@tailor-platform/sdk/runtime/globals"` so the rewritten references resolve. diff --git a/.changeset/remove-tailor-sdk-skills-shim.md b/.changeset/remove-tailor-sdk-skills-shim.md new file mode 100644 index 0000000000..1038a3dfc7 --- /dev/null +++ b/.changeset/remove-tailor-sdk-skills-shim.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Remove the deprecated `tailor-sdk-skills` binary shim. Use `tailor-sdk skills install` to install the bundled Tailor SDK agent skill. diff --git a/.changeset/remove-tailorctl-config-migration.md b/.changeset/remove-tailorctl-config-migration.md new file mode 100644 index 0000000000..8911105818 --- /dev/null +++ b/.changeset/remove-tailorctl-config-migration.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Stop importing credentials and profiles from legacy `~/.tailorctl/config` when the platform config is missing. New CLI configs now start empty in the current platform config format. diff --git a/.changeset/remove-tsx.md b/.changeset/remove-tsx.md new file mode 100644 index 0000000000..3ca872d9cd --- /dev/null +++ b/.changeset/remove-tsx.md @@ -0,0 +1,19 @@ +--- +"@tailor-platform/sdk": major +--- + +Minimum Node.js version raised to 22.15.0; TypeScript loading switched from tsx to amaro + +Removes `tsx` (which pulled in esbuild's native binaries, ~10.5 MB) from +`dependencies` and replaces it with `amaro` (~3.8 MB, zero transitive deps). + +A small `ts-hook.mjs` provides the Node.js module hook with both a resolver +and a load hook (`amaro` for full TypeScript support including enums). +The resolver handles `.ts` extension fallback, directory barrel imports +(`./models` → `./models/index.ts`), and tsconfig `paths` aliases (reads +`tsconfig.json` following `extends` chains). +Dev-only scripts now use `node --experimental-strip-types` instead. + +Raises the minimum Node.js version to 22.15.0 (from >=22) to use +`module.registerHooks()`, which allows synchronous hook registration directly +in the main thread without a worker thread. diff --git a/.changeset/remove-v2-cli-aliases.md b/.changeset/remove-v2-cli-aliases.md new file mode 100644 index 0000000000..a5d3ebdc58 --- /dev/null +++ b/.changeset/remove-v2-cli-aliases.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Remove deprecated CLI aliases for the v2 command surface. Use `tailor-sdk deploy` instead of `tailor-sdk apply`, `tailor-sdk crashreport` instead of `tailor-sdk crash-report`, and the hyphenated `--machine-user` option instead of the hidden `--machineuser` alias. + +Fix the v2 CLI rename codemod to migrate the hidden `--machineuser` option to `--machine-user`. diff --git a/.changeset/remove-workflow-test-env-fallback.md b/.changeset/remove-workflow-test-env-fallback.md new file mode 100644 index 0000000000..830daa5c0f --- /dev/null +++ b/.changeset/remove-workflow-test-env-fallback.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Remove the deprecated workflow test env fallback. `WORKFLOW_TEST_ENV_KEY` is no longer exported, and `TAILOR_TEST_WORKFLOW_ENV` is no longer read when running workflows locally. Use `mockWorkflow().setEnv(...)` or pass `{ env }` to `runWorkflowLocally(...)` instead. diff --git a/.changeset/rename-bin-command.md b/.changeset/rename-bin-command.md new file mode 100644 index 0000000000..d696f409cb --- /dev/null +++ b/.changeset/rename-bin-command.md @@ -0,0 +1,14 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Rename the CLI binary from `tailor-sdk` to `tailor`. + +The output directory default changes from `.tailor-sdk` to `.tailor`, and the GitHub Actions lock file path changes from `.github/tailor-sdk.lock` to `.github/tailor.lock`. + +Run the `v2/rename-bin` codemod to migrate `tailor-sdk` invocations in package.json scripts, shell scripts, CI workflows, and documentation: + +```sh +npx @tailor-platform/sdk-codemod --from 1.x --to 2.0.0 +``` diff --git a/.changeset/rename-tailor-cli-env.md b/.changeset/rename-tailor-cli-env.md new file mode 100644 index 0000000000..1fe743e4d9 --- /dev/null +++ b/.changeset/rename-tailor-cli-env.md @@ -0,0 +1,9 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/create-sdk": patch +"@tailor-platform/sdk-codemod": patch +--- + +Standardize SDK-owned environment variables on the `TAILOR_*` namespace. + +Replace the removed SDK-specific environment variables with their new names: `TAILOR_CONFIG_PATH`, `TAILOR_DTS_PATH`, `TAILOR_CI_ALLOW_ID_INJECTION`, `TAILOR_DEPLOY_BUILD_ONLY`, `TAILOR_BUILD_OUTPUT_DIR`, `TAILOR_SKILLS_SOURCE`, `TAILOR_TEMPLATE_SDK_VERSION`, `TAILOR_PLATFORM_URL`, `TAILOR_PLATFORM_OAUTH2_CLIENT_ID`, `TAILOR_INLINE_SOURCEMAP`, `TAILOR_QUERY_NEWLINE_ON_ENTER`, and `TAILOR_APP_LOG_LEVEL`. The deprecated `TAILOR_TOKEN` fallback is removed; use `TAILOR_PLATFORM_TOKEN`. The v2 codemod rewrites unambiguous removed SDK environment variable names and flags generic names such as `LOG_LEVEL` and `PLATFORM_URL` for manual review. diff --git a/.changeset/require-function-log-content-hash.md b/.changeset/require-function-log-content-hash.md new file mode 100644 index 0000000000..e0b2c7cc54 --- /dev/null +++ b/.changeset/require-function-log-content-hash.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Require `FunctionExecution.contentHash` for `function logs` stack trace source mapping. Executions without a content hash now show raw stack traces instead of mapping against the current function bundle. diff --git a/.changeset/runtime-global-source-strings.md b/.changeset/runtime-global-source-strings.md new file mode 100644 index 0000000000..2b3d617b60 --- /dev/null +++ b/.changeset/runtime-global-source-strings.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Flag JavaScript files and embedded code strings that still reference ambient Tailor runtime globals during v2 migration review. diff --git a/.changeset/runtime-globals-import.md b/.changeset/runtime-globals-import.md new file mode 100644 index 0000000000..de9e7cfc71 --- /dev/null +++ b/.changeset/runtime-globals-import.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Flag files that still reference ambient Tailor runtime globals so the v2 migration can opt them into `@tailor-platform/sdk/runtime/globals`. diff --git a/.changeset/runtime-idp-wrapper.md b/.changeset/runtime-idp-wrapper.md new file mode 100644 index 0000000000..58c7001a3a --- /dev/null +++ b/.changeset/runtime-idp-wrapper.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Automatically migrate simple direct `tailor.idp.Client` runtime global usage to the typed `idp.Client` wrapper during v2 upgrades. diff --git a/.changeset/share-codemod-runtime-imports.md b/.changeset/share-codemod-runtime-imports.md new file mode 100644 index 0000000000..5f1cfa1be3 --- /dev/null +++ b/.changeset/share-codemod-runtime-imports.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Keep v2 codemods from reusing type-only runtime helper imports when adding runtime value imports. diff --git a/.changeset/store-cli-users-by-subject.md b/.changeset/store-cli-users-by-subject.md new file mode 100644 index 0000000000..715a5ccdaf --- /dev/null +++ b/.changeset/store-cli-users-by-subject.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Store CLI human users by stable subject ID in the platform config instead of email, while preserving email for display and migrating legacy email-keyed entries on login or token refresh. diff --git a/.changeset/strict-scalar-strings-codemod.md b/.changeset/strict-scalar-strings-codemod.md new file mode 100644 index 0000000000..8ce1ab50b1 --- /dev/null +++ b/.changeset/strict-scalar-strings-codemod.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": minor +--- + +Add the `v2/strict-scalar-strings` migration entry for the strict UUID/date/datetime/time/decimal field string types. The migration guide now documents the new scalar shapes and `is*String` / `parse*String` / `assert*String` helpers, and the runner surfaces the regenerate-and-typecheck migration steps as project-wide LLM guidance. diff --git a/.changeset/strict-tailor-field-strings.md b/.changeset/strict-tailor-field-strings.md new file mode 100644 index 0000000000..2b12bbe2cd --- /dev/null +++ b/.changeset/strict-tailor-field-strings.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Tighten Tailor field output types for UUID, date, datetime, time, and decimal fields, including generated Kysely types and runtime user IDs. Add scalar string guard, parse, and assertion helpers. Datetime parsing now accepts ISO 8601 datetime offsets. diff --git a/.changeset/tailor-output-ignore-dir.md b/.changeset/tailor-output-ignore-dir.md new file mode 100644 index 0000000000..b46ae71490 --- /dev/null +++ b/.changeset/tailor-output-ignore-dir.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Add the `v2/tailor-output-ignore-dir` codemod so SDK upgrades rewrite exact `.tailor-sdk/` ignore-file entries to `.tailor/` while leaving other `.tailor-sdk` paths unchanged. diff --git a/.changeset/tailor-principal-type.md b/.changeset/tailor-principal-type.md new file mode 100644 index 0000000000..e2946b79c1 --- /dev/null +++ b/.changeset/tailor-principal-type.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Unify function principal context around `TailorPrincipal`. + +Resolver contexts now use `caller` and `invoker` as `TailorPrincipal | null`, workflow and executor invokers also use `TailorPrincipal | null`, and event executor `actor` uses `TailorPrincipal | null` with `id`/`type` fields. The legacy `TailorUser`, `TailorInvoker`, `TailorActor`, `TailorActorType`, and `unauthenticatedTailorUser` exports are removed. diff --git a/.changeset/tailordb-shared-now-hook.md b/.changeset/tailordb-shared-now-hook.md new file mode 100644 index 0000000000..ef18c37f92 --- /dev/null +++ b/.changeset/tailordb-shared-now-hook.md @@ -0,0 +1,7 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add a `now` argument to TailorDB hooks. `now` is the operation timestamp and is shared across every field hooked in the same create/update, so multiple fields can be stamped with an identical `Date`. Hooks and validators are now applied per type rather than per field, which is what makes the shared timestamp possible. + +As part of this, all of a type's hooks now run together and observe the same submitted input: a type-level hook's `input` reflects what the client sent and does not include other fields' hook results. diff --git a/.changeset/timestamps-updated-at-create.md b/.changeset/timestamps-updated-at-create.md new file mode 100644 index 0000000000..c88c6344be --- /dev/null +++ b/.changeset/timestamps-updated-at-create.md @@ -0,0 +1,10 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/create-sdk": major +--- + +Set `db.fields.timestamps()` `updatedAt` when records are created and make the generated field non-null. `createdAt` keeps its existing create-time behavior, while `updatedAt` keeps its update-time behavior and now also gets a create hook that preserves provided values and falls back to the current time. + +Update create-sdk templates so scaffolded projects use the new non-null `updatedAt` Kysely types and seed schemas. + +Existing TailorDB schemas that already use this helper will change `updatedAt` from optional to required. Backfill existing records that have `updatedAt: null` before applying the schema change. diff --git a/.changeset/user-profile-type-schema.md b/.changeset/user-profile-type-schema.md new file mode 100644 index 0000000000..d5a8dc8da8 --- /dev/null +++ b/.changeset/user-profile-type-schema.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Validate auth user profile TailorDB types with the strict TailorDB parser schema. diff --git a/.changeset/v2-baseline.md b/.changeset/v2-baseline.md new file mode 100644 index 0000000000..8f2e53ac9b --- /dev/null +++ b/.changeset/v2-baseline.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Start the v2 release line. v2 introduces breaking changes to the SDK API and CLI; run `tailor-sdk upgrade` to apply the bundled codemods when migrating. Prereleases are published to the `next` dist-tag — install with `@tailor-platform/sdk@next`. diff --git a/.changeset/wait-point-rename.md b/.changeset/wait-point-rename.md new file mode 100644 index 0000000000..0621a005a7 --- /dev/null +++ b/.changeset/wait-point-rename.md @@ -0,0 +1,20 @@ +--- +"@tailor-platform/sdk": major +"@tailor-platform/create-sdk": major +"@tailor-platform/sdk-codemod": patch +--- + +Rename `defineWaitPoint` and `defineWaitPoints` to `createWaitPoint` and `createWaitPoints`. + +These functions create runtime instances with `.wait()` and `.resolve()` methods that call the platform API at runtime, so the `create*` prefix is more accurate. Update any usages: + +```diff +-import { defineWaitPoint, defineWaitPoints } from "@tailor-platform/sdk"; ++import { createWaitPoint, createWaitPoints } from "@tailor-platform/sdk"; + +-export const approval = defineWaitPoint("approval"); ++export const approval = createWaitPoint("approval"); + +-export const waitPoints = defineWaitPoints((define) => ({ ... })); ++export const waitPoints = createWaitPoints((define) => ({ ... })); +``` diff --git a/.changeset/workflow-trigger-dispatch.md b/.changeset/workflow-trigger-dispatch.md new file mode 100644 index 0000000000..553133b4bb --- /dev/null +++ b/.changeset/workflow-trigger-dispatch.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": major +--- + +Align workflow job `.trigger()` with the platform runtime. Job triggers now require a mocked workflow runtime in tests instead of running job bodies locally, and `trigger()` returns the job result directly instead of a Promise wrapper. Use `mockWorkflow()` to mock trigger results in tests, or `runWorkflowLocally()` for full-chain local workflow tests. diff --git a/.claude/rules/schema-types.md b/.claude/rules/schema-types.md index 7e4b505d90..2ae7685de6 100644 --- a/.claude/rules/schema-types.md +++ b/.claude/rules/schema-types.md @@ -39,7 +39,7 @@ modules** — files named `types.ts` (or `*.types.ts` under `configure/`): | `parser/service/tailordb/types.ts` | Parsed data structures (`TailorDBType`, `ParsedField`, permissions, ...) | | `parser/service/idp/types.ts` | Normalized IdP permission types shared by parser and CLI | | `plugin/types.ts` | Plugin authoring types, generation hook contexts, generator config | -| `runtime/types.ts` | Runtime principal/env types (`TailorUser`, `TailorActor`, `TailorEnv`) | +| `runtime/types.ts` | Runtime principal/env types (`TailorPrincipal`, `TailorEnv`) | Rules for pure type modules (enforced by oxlint): diff --git a/.claude/rules/sdk-internals.md b/.claude/rules/sdk-internals.md index e48ddabbee..caaa76fbd1 100644 --- a/.claude/rules/sdk-internals.md +++ b/.claude/rules/sdk-internals.md @@ -9,6 +9,21 @@ paths: # SDK Internals +## API Naming: `define*` vs `create*` + +Public functions in `src/configure/` follow a strict naming convention. + +| Prefix | Meaning | Output | Where used | +| --------- | --------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `define*` | Declarative resource specification | Pure data/config struct (no SDK-managed runtime methods) | `tailor.config.ts` as resource entries | +| `create*` | Instantiation of a runtime-capable unit | Object with runtime-capable methods (`trigger`, `wait`, `resolve`) or user-written `body` function | Standalone files as default/named exports, or inside workflow job bodies | + +**`define*` examples:** `defineConfig`, `defineAuth`, `defineIdp`, `defineStaticWebSite`, `defineAIGateway`, `defineSecretManager`, `definePlugins` + +**`create*` examples:** `createResolver`, `createExecutor`, `createWorkflow`, `createWorkflowJob`, `createWaitPoint`, `createWaitPoints`, `createHttpAdapter` + +Decision rule: if the result has a method that calls the platform at runtime (`.trigger()`, `.wait()`, `.resolve()`, etc.) or carries a user-written `body` function, use `create*`. If the result is purely a typed config object that tooling/deployer reads, use `define*`. + ## Module Architecture and Import Rules The SDK enforces strict module boundaries to maintain a clean architecture: diff --git a/.claude/skills/tailor b/.claude/skills/tailor new file mode 120000 index 0000000000..9f908626d7 --- /dev/null +++ b/.claude/skills/tailor @@ -0,0 +1 @@ +../../.agents/skills/tailor \ No newline at end of file diff --git a/.claude/skills/tailor-sdk b/.claude/skills/tailor-sdk deleted file mode 120000 index 5b9c704ab5..0000000000 --- a/.claude/skills/tailor-sdk +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/tailor-sdk \ No newline at end of file diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index b0d96f8471..7df22cd315 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -20,7 +20,7 @@ jobs: if: >- github.event.pull_request.user.login != 'renovate[bot]' && - github.event.pull_request.head.ref != 'changeset-release/main' && + !startsWith(github.event.pull_request.head.ref, 'changeset-release/') && !contains(github.event.pull_request.labels.*.name, 'skip-changeset') steps: @@ -60,7 +60,9 @@ jobs: - name: Validate changeset packages if: steps.filter.outputs.changeset == 'true' - run: pnpm changeset status --since origin/main + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: pnpm changeset status --since "origin/$BASE_REF" # `changeset status` only validates the plan; it doesn't run the JSON # edits that `changeset version` performs during the Release workflow. diff --git a/.github/workflows/check-license.yml b/.github/workflows/check-license.yml index 07eb653f1a..df5d66fd5b 100644 --- a/.github/workflows/check-license.yml +++ b/.github/workflows/check-license.yml @@ -2,7 +2,7 @@ name: Check Licenses on: push: - branches: [main] + branches: [main, v2] paths: - "pnpm-lock.yaml" - ".github/workflows/check-license.yml" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e49e13be1..c68b06cced 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: Lint / Format / Type Check on: push: - branches: [main] + branches: [main, v2] pull_request: concurrency: diff --git a/.github/workflows/cleanup-e2e-workspaces.yml b/.github/workflows/cleanup-e2e-workspaces.yml index e074c04fe8..ae178e8071 100644 --- a/.github/workflows/cleanup-e2e-workspaces.yml +++ b/.github/workflows/cleanup-e2e-workspaces.yml @@ -35,7 +35,7 @@ jobs: uses: ./.github/actions/install-deps - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} @@ -43,7 +43,7 @@ jobs: - name: List e2e workspaces working-directory: packages/sdk run: | - pnpm --silent tailor-sdk workspace list --json > /tmp/workspaces.json + pnpm --silent tailor workspace list --json > /tmp/workspaces.json jq -r '.[] | select(.name | test("^(e2e-ws-|template-e2e-|sdk-ci-)")) | "\(.id)\t\(.name)"' \ /tmp/workspaces.json > /tmp/e2e-workspaces.tsv echo "e2e workspace candidates:" @@ -91,7 +91,7 @@ jobs: echo "dry $name (run $run_id status=$status)" else echo "delete $name (run $run_id status=$status)" - if pnpm --silent tailor-sdk workspace delete --workspace-id "$id" --yes; then + if pnpm --silent tailor workspace delete --workspace-id "$id" --yes; then deleted=$((deleted + 1)) else failed=$((failed + 1)) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 47a9624341..aa82888aa5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -73,7 +73,7 @@ jobs: - name: Login as machine user if: runner.os != 'Windows' - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} @@ -81,7 +81,7 @@ jobs: - name: Create workspace if: runner.os != 'Windows' working-directory: example - run: pnpm --silent tailor-sdk workspace create --name "sdk-ci-${RUN_ID}-${SLUG}" --region asia-northeast --json > workspace.json + run: pnpm --silent tailor workspace create --name "sdk-ci-${RUN_ID}-${SLUG}" --region asia-northeast --json > workspace.json env: RUN_ID: ${{ github.run_id }} SLUG: ${{ matrix.slug }} @@ -104,14 +104,14 @@ jobs: working-directory: example run: pnpm run deploy env: - TAILOR_PLATFORM_SDK_BUILD_ONLY: ${{ runner.os == 'Windows' && 'true' || '' }} + TAILOR_DEPLOY_BUILD_ONLY: ${{ runner.os == 'Windows' && 'true' || '' }} - name: Verify all migrations applied if: runner.os != 'Windows' working-directory: example shell: bash run: | - STATUS=$(pnpm --silent tailor-sdk tailordb migration status --namespace tailordb) + STATUS=$(pnpm --silent tailor tailordb migration status --namespace tailordb) echo "$STATUS" # Check for pending migrations @@ -143,7 +143,7 @@ jobs: - name: Destroy workspace if: always() && runner.os != 'Windows' working-directory: example - run: pnpm tailor-sdk workspace delete --workspace-id "$TAILOR_PLATFORM_WORKSPACE_ID" --yes + run: pnpm tailor workspace delete --workspace-id "$TAILOR_PLATFORM_WORKSPACE_ID" --yes deploy-result: name: deploy-result # required status check context (ruleset status_check_main) — keep equal to the job id diff --git a/.github/workflows/docs-consistency-check.yml b/.github/workflows/docs-consistency-check.yml index 424452413d..605891a46e 100644 --- a/.github/workflows/docs-consistency-check.yml +++ b/.github/workflows/docs-consistency-check.yml @@ -111,7 +111,7 @@ jobs: For changed user-facing docs, check that they describe only what an SDK *user* needs and do not leak SDK/Platform internals. Flag the following: - - **Internal implementation details**: internal API / RPC / method / class names (e.g. `TestExecScript`), transport or wire-format terms the user never types (`proto` / `protobuf` / `gRPC` / `unary RPC` / `streaming method`), internal layer structure (`parser` / `configure` / `types` modules), runtime-internal delegation mechanics, internal wrapping behavior, internal on-disk layout or sanitization algorithms (e.g. how `.tailor-sdk/` names are derived), and AST / code-injection internals. + - **Internal implementation details**: internal API / RPC / method / class names (e.g. `TestExecScript`), transport or wire-format terms the user never types (`proto` / `protobuf` / `gRPC` / `unary RPC` / `streaming method`), internal layer structure (`parser` / `configure` / `types` modules), runtime-internal delegation mechanics, internal wrapping behavior, internal on-disk layout or sanitization algorithms (e.g. how `.tailor/` names are derived), and AST / code-injection internals. - **JSDoc duplication (SSOT)**: detailed API reference — full interface/type definitions, exhaustive field tables — copied into prose. JSDoc/IDE autocompletion is the single source of truth; the docs should explain intent and link out, not mirror every field. - **Private references**: links to or mentions of private/internal repositories or internal issue trackers (e.g. a `/` link or an `/#NNN` reference). - **Wrong-audience notes**: content addressed to someone other than the reader (e.g. a "Note for Platform developers" block inside user docs). diff --git a/.github/workflows/erd-viewer-preview.yml b/.github/workflows/erd-viewer-preview.yml index 1235493585..0a81fa8abe 100644 --- a/.github/workflows/erd-viewer-preview.yml +++ b/.github/workflows/erd-viewer-preview.yml @@ -34,7 +34,7 @@ jobs: with: persist-credentials: false - # install-deps builds the SDK once, so the `tailor-sdk` CLI bin resolves. + # install-deps builds the SDK once, so the `tailor` CLI bin resolves. - name: Install deps uses: ./.github/actions/install-deps @@ -77,7 +77,7 @@ jobs: if ! ( cd example TAILOR_PLATFORM_SDK_DTS_PATH="$RUNNER_TEMP/erd-dts/head-$NAMESPACE.d.ts" \ - pnpm exec tailor-sdk tailordb erd export --namespace "$NAMESPACE" --output "$head_output" + pnpm exec tailor tailordb erd export --namespace "$NAMESPACE" --output "$head_output" ) >"$head_log" 2>&1; then if grep -q 'not found in local config.db' "$head_log"; then cat "$head_log" @@ -95,7 +95,7 @@ jobs: if ! ( cd .erd-base/example TAILOR_PLATFORM_SDK_DTS_PATH="$RUNNER_TEMP/erd-dts/base-$NAMESPACE.d.ts" \ - "$GITHUB_WORKSPACE/node_modules/.bin/tailor-sdk" tailordb erd export --namespace "$NAMESPACE" --output "$base_output" + "$GITHUB_WORKSPACE/node_modules/.bin/tailor" tailordb erd export --namespace "$NAMESPACE" --output "$base_output" ) >"$base_log" 2>&1; then if grep -q 'not found in local config.db' "$base_log"; then cat "$base_log" @@ -122,7 +122,7 @@ jobs: diff_args+=(--base-html "$base_html") fi - pnpm exec tailor-sdk tailordb erd diff "${diff_args[@]}" + pnpm exec tailor tailordb erd diff "${diff_args[@]}" # archive: false (upload-artifact v7+) keeps the file unzipped so the HTML # can be viewed directly in the browser from the run's artifact list. Only diff --git a/.github/workflows/lockfile-audit.yml b/.github/workflows/lockfile-audit.yml index 59f8ad46da..9f33dd76ca 100644 --- a/.github/workflows/lockfile-audit.yml +++ b/.github/workflows/lockfile-audit.yml @@ -7,7 +7,7 @@ name: Lockfile Audit # and picomatch). This gate fails such PRs before they reach main. on: push: - branches: [main] + branches: [main, v2] paths: - pnpm-lock.yaml pull_request: diff --git a/.github/workflows/migration.yml b/.github/workflows/migration.yml index a7824a9802..784066fe0c 100644 --- a/.github/workflows/migration.yml +++ b/.github/workflows/migration.yml @@ -56,14 +56,14 @@ jobs: uses: ./.github/actions/install-deps - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} - name: Create workspace working-directory: example - run: pnpm --silent tailor-sdk workspace create --name "sdk-ci-migration-${RUN_ID}" --region asia-northeast --json > workspace.json + run: pnpm --silent tailor workspace create --name "sdk-ci-migration-${RUN_ID}" --region asia-northeast --json > workspace.json env: RUN_ID: ${{ github.run_id }} TAILOR_PLATFORM_ORGANIZATION_ID: ${{ secrets.TAILOR_PLATFORM_ORGANIZATION_ID }} @@ -83,7 +83,7 @@ jobs: - name: Destroy workspace if: always() working-directory: example - run: pnpm tailor-sdk workspace delete --workspace-id "$TAILOR_PLATFORM_WORKSPACE_ID" --yes + run: pnpm tailor workspace delete --workspace-id "$TAILOR_PLATFORM_WORKSPACE_ID" --yes migration-result: name: Migration result diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index a7e2ff316e..6a5f827bf9 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -1,7 +1,7 @@ name: Publish Any Commit on: push: - branches: [main] + branches: [main, v2] pull_request: paths: - packages/** @@ -64,7 +64,7 @@ jobs: # the build and hang the job. The build itself only needs create-sdk's own deps. - name: Build create-sdk with pkg.pr.new SDK env: - TAILOR_SDK_VERSION: ${{ steps.urls.outputs.sdk_url }} + TAILOR_TEMPLATE_SDK_VERSION: ${{ steps.urls.outputs.sdk_url }} run: | node packages/create-sdk/scripts/prepare-templates.js pnpm --config.verify-deps-before-run=false --filter @tailor-platform/create-sdk build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2419e93f95..44a6b75369 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - v2 concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/sdk-e2e.yml b/.github/workflows/sdk-e2e.yml index 2023754382..c8e6fba057 100644 --- a/.github/workflows/sdk-e2e.yml +++ b/.github/workflows/sdk-e2e.yml @@ -55,7 +55,7 @@ jobs: uses: ./.github/actions/install-deps - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} @@ -94,7 +94,7 @@ jobs: uses: ./.github/actions/install-deps - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} diff --git a/.github/workflows/sdk-metrics.yml b/.github/workflows/sdk-metrics.yml index 50ff2550ef..babdb977b1 100644 --- a/.github/workflows/sdk-metrics.yml +++ b/.github/workflows/sdk-metrics.yml @@ -2,14 +2,13 @@ name: SDK Metrics on: pull_request: - branches: [main] paths: - packages/** - package.json - pnpm-lock.yaml - .github/workflows/sdk-metrics.yml push: - branches: [main] + branches: [main, v2] workflow_dispatch: concurrency: @@ -41,7 +40,7 @@ jobs: uses: ./.github/actions/install-deps - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} @@ -83,7 +82,7 @@ jobs: uses: ./.github/actions/install-deps - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} diff --git a/.github/workflows/skills-sync-check.yml b/.github/workflows/skills-sync-check.yml index 5c32d04bef..6f25fc3341 100644 --- a/.github/workflows/skills-sync-check.yml +++ b/.github/workflows/skills-sync-check.yml @@ -6,7 +6,7 @@ on: - "packages/sdk/agent-skills/**" - ".github/workflows/skills-sync-check.yml" push: - branches: [main] + branches: [main, v2] paths: - "packages/sdk/agent-skills/**" diff --git a/.github/workflows/template-tests.yml b/.github/workflows/template-tests.yml index f2af6d5f00..e309a2f2ad 100644 --- a/.github/workflows/template-tests.yml +++ b/.github/workflows/template-tests.yml @@ -2,7 +2,7 @@ name: Template Tests on: push: - branches: [main] + branches: [main, v2] pull_request: paths: - packages/** diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3808f2e78d..3a7da1877e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Test on: push: - branches: [main] + branches: [main, v2] pull_request: concurrency: @@ -66,7 +66,7 @@ jobs: node-version: ${{ matrix.node }} - name: Login as machine user - run: pnpm tailor-sdk login --machineuser + run: pnpm tailor login --machine-user env: TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_ID }} TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET: ${{ secrets.PLATFORM_MACHINE_USER_CLIENT_SECRET }} @@ -77,9 +77,37 @@ jobs: - name: Run tests run: pnpm -r run test --project 'unit*' + plugin-windows: + needs: changes + if: needs.changes.outputs.should_run == 'true' + runs-on: windows-latest + name: Plugin tests (Windows) + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install deps + uses: ./.github/actions/install-deps + + - name: Build packages + run: pnpm -r --filter '!@tailor-platform/sdk' run build + shell: bash + + # Windows-specific PATHEXT resolution and .cmd/.ps1 spawn branches are + # only exercised here; the main unit suite runs on Linux. + - name: Run plugin tests + run: pnpm --filter @tailor-platform/sdk exec vitest run --project unit-plugin + shell: bash + test-summary: name: test-summary # required status check context (ruleset status_check_main) — keep equal to the job id - needs: [changes, test] + needs: [changes, test, plugin-windows] if: >- always() && (github.event.action != 'synchronize' || github.event.sender.login != 'tailor-pr-trigger[bot]') @@ -92,6 +120,7 @@ jobs: CHANGES_RESULT: ${{ needs.changes.result }} SHOULD_RUN: ${{ needs.changes.outputs.should_run }} TEST_RESULT: ${{ needs.test.result }} + PLUGIN_WINDOWS_RESULT: ${{ needs.plugin-windows.result }} run: | # Fail closed: if the paths-filter job itself failed, should_run is # empty and must not be mistaken for "no relevant changes". @@ -107,4 +136,8 @@ jobs: echo "Test jobs failed" exit 1 fi + if [[ "$PLUGIN_WINDOWS_RESULT" != "success" ]]; then + echo "Plugin tests (Windows) failed" + exit 1 + fi echo "All test jobs succeeded" diff --git a/.gitignore b/.gitignore index 9ec659334f..84e2f6a68e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,7 @@ coverage/ !.vscode/extensions.json # Tailor Platform SDK -**/.tailor-sdk/** +**/.tailor/** tailor.d.ts !packages/create-sdk/templates/*/tailor.d.ts @@ -46,7 +46,7 @@ tailor.d.ts .claude/skills/* !.claude/skills/llm-challenge !.claude/skills/docs-check -!.claude/skills/tailor-sdk +!.claude/skills/tailor !.claude/skills/e2e-setup AGENTS.local.md CLAUDE.local.md diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d9b88623c1..8c3014a43a 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -22,11 +22,12 @@ "pnpm-debug.log*", "lerna-debug.log*", "coverage/", - ".tailor-sdk", + ".tailor", "example/tests/fixtures/", "example/seed/", "packages/tailor-proto/", "packages/sdk-codemod/codemods/**/tests/", + "packages/sdk/docs/migration/v2.md", "generated/" ] } diff --git a/AGENTS.md b/AGENTS.md index b9a713b65e..67c36fc0ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,8 @@ When editing files matching these globs, read and follow the linked rule documen - `pnpm agent:rules:update` - Regenerate `AGENTS.md`'s path-scoped rule index and `.claude/rules/*.md` - `pnpm agent:rules:check` - Verify generated agent rule files are up to date +- `pnpm codemod:docs:update` - Regenerate `packages/sdk/docs/migration/v2.md` from the codemod registry +- `pnpm codemod:docs:check` - Verify migration docs are up to date ### CLI @@ -83,10 +85,10 @@ Key files: - `createWorkflow()` result **must** be default exported - All jobs **must** be named exports (including mainJob and triggered jobs) - Job names must be unique across the entire project -- `.trigger()` returns a `Promise>` — typically `await` it to read the value -- `defineWaitPoints(define => ({ key: define() }))` creates typed wait/resolve points +- Job `.trigger()` returns `Awaited` directly; read the value synchronously unless the job output itself is promise-like +- `createWaitPoints(define => ({ key: define() }))` creates typed wait/resolve points - Wait/resolve methods runtime-delegate to `tailor.workflow.wait/resolve` on the platform; acquire the mock with `using wf = mockWorkflow()` from `@tailor-platform/sdk/vitest` (with the `tailor-runtime` environment) and use `wf.setWaitHandler` / `wf.setResolveHandler` to mock in tests — see [testing.md](packages/sdk/docs/testing.md#jobs-that-wait-on-approval) -- Use `wps.key.wait()` for namespaced access, or `export const { key } = defineWaitPoints(...)` for destructured 2-level access +- Use `wps.key.wait()` for namespaced access, or `export const { key } = createWaitPoints(...)` for destructured 2-level access ### Executors @@ -108,7 +110,7 @@ Args include `event` (short name like `"created"`) and `rawEvent` (full event ty ### Plugins -`definePlugins()` takes plugin instances as rest arguments (see `example/tailor.config.ts`). The `kyselyTypePlugin` from `@tailor-platform/sdk/plugin/kysely-type` is required for `getDB()` in resolvers/executors/workflows. `defineGenerators()` is deprecated — use `definePlugins()` instead. +`definePlugins()` takes plugin instances as rest arguments (see `example/tailor.config.ts`). The `kyselyTypePlugin` from `@tailor-platform/sdk/plugin/kysely-type` is required for `getDB()` in resolvers/executors/workflows. ### Configuration diff --git a/docs/changeset.md b/docs/changeset.md index a1be7e1823..d5995c03c6 100644 --- a/docs/changeset.md +++ b/docs/changeset.md @@ -8,6 +8,14 @@ This project uses [Changesets](https://github.com/changesets/changesets) for ver pnpm changeset ``` +## Prerelease Codemod Validation + +When validating codemod behavior before a prerelease, record the exact runner that produced the result: + +- For package-based validation, cite an exact published npm version such as `@tailor-platform/sdk-codemod@0.3.0-next.3`. +- For branch-head validation, build the package locally, run `node packages/sdk-codemod/dist/index.js ...`, and cite the `runner.gitCommit` plus `runner.localBuildCommand` from the JSON summary. +- Do not cite an older npm prerelease for behavior that only exists on the branch head. Publish a new prerelease first, or keep the validation explicitly tied to the branch commit and local build command. + ## Version Bump Levels The key question for choosing a level: **How does this change affect SDK users?** diff --git a/docs/getting-started.md b/docs/getting-started.md index ef724ae2dd..86b90f1874 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,7 +4,7 @@ Guide for new SDK contributors. ## Prerequisites -- **Node.js** >= 22.14.0 +- **Node.js** >= 22.15.0 - **pnpm** (version pinned by `packageManager` in root `package.json`) - **GPG key** for commit signing (enforced by Lefthook post-commit hook) diff --git a/docs/telemetry.md b/docs/telemetry.md index 499f2c8455..23c8e3e447 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -27,12 +27,12 @@ docker run -d --name jaeger-otlp \ ```bash cd example -OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 pnpm tailor-sdk deploy --dry-run +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 pnpm tailor deploy --dry-run ``` ### 3. View traces -Open http://localhost:16686, select service **tailor-sdk**, and click **Find Traces**. +Open http://localhost:16686, select service **tailor**, and click **Find Traces**. ## Span Hierarchy @@ -78,7 +78,7 @@ Individual RPC calls are also traced as `rpc.*` child spans (e.g., `rpc.CreateAp ### List spans sorted by duration ```bash -curl -s "http://localhost:16686/api/traces?service=tailor-sdk&limit=1" | jq ' +curl -s "http://localhost:16686/api/traces?service=tailor&limit=1" | jq ' .data[0].spans[] | {operationName, duration_ms: (.duration / 1000 | . * 100 | round / 100)} ' | jq -s 'sort_by(-.duration_ms)' @@ -87,7 +87,7 @@ curl -s "http://localhost:16686/api/traces?service=tailor-sdk&limit=1" | jq ' ### Show span hierarchy with parent info ```bash -curl -s "http://localhost:16686/api/traces?service=tailor-sdk&limit=1" | jq ' +curl -s "http://localhost:16686/api/traces?service=tailor&limit=1" | jq ' .data[0] as $trace | $trace.spans | map({ operationName, @@ -100,7 +100,7 @@ curl -s "http://localhost:16686/api/traces?service=tailor-sdk&limit=1" | jq ' ### Compare two traces (before/after) ```bash -curl -s "http://localhost:16686/api/traces?service=tailor-sdk&limit=2" | jq ' +curl -s "http://localhost:16686/api/traces?service=tailor&limit=2" | jq ' [.data[] | { traceID: .traceID, spans: [.spans[] diff --git a/example/.gitignore b/example/.gitignore index 0453033971..45cfacc3bd 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -7,7 +7,7 @@ build/ *.tsbuildinfo # Tailor Platform SDK generated files -.tailor-sdk/ +.tailor/ # Test fixtures (keep expected/ only) tests/fixtures/* diff --git a/example/.oxlintrc.json b/example/.oxlintrc.json index 9bf159a66b..e59dc5750b 100644 --- a/example/.oxlintrc.json +++ b/example/.oxlintrc.json @@ -8,7 +8,7 @@ "builtin": true }, "ignorePatterns": [ - ".tailor-sdk/", + ".tailor/", "generated-perf", "generated/", "migrations", @@ -61,7 +61,10 @@ "typescript/prefer-includes": "error", "typescript/prefer-nullish-coalescing": "error", "typescript/prefer-regexp-exec": "error", - "typescript/prefer-string-starts-ends-with": "error" + "typescript/prefer-string-starts-ends-with": "error", + "tailor-zod/require-object-policy-comment": "error", + "zod/prefer-loose-object": "error", + "zod/prefer-strict-object": "error" }, "overrides": [ { @@ -102,5 +105,6 @@ "import/no-cycle": "error" } } - ] + ], + "jsPlugins": ["eslint-plugin-zod", "../scripts/oxlint/tailor-zod-plugin.cjs"] } diff --git a/example/adapters/echo.ts b/example/adapters/echo.ts index c2dd2d97a6..8c1548ad6c 100644 --- a/example/adapters/echo.ts +++ b/example/adapters/echo.ts @@ -8,10 +8,10 @@ export default createHttpAdapter({ pathPattern: "/echo", input: { get: () => ({ - query: `query { getResult: showUserInfo { user { role } } }`, + query: `query { getResult: showUserInfo { caller { role } } }`, }), post: () => ({ - query: `query { postResult: showUserInfo { user { role } } }`, + query: `query { postResult: showUserInfo { caller { role } } }`, }), }, output: (resp) => { diff --git a/example/adapters/whoami.ts b/example/adapters/whoami.ts index 4c6d22ecec..b57e794a36 100644 --- a/example/adapters/whoami.ts +++ b/example/adapters/whoami.ts @@ -35,7 +35,7 @@ export default createHttpAdapter({ get: () => ({ query: `query Whoami { showUserInfo { - user { + caller { id type role @@ -53,7 +53,7 @@ export default createHttpAdapter({ const data = resp.data as | { showUserInfo?: { - user?: Record; + caller?: Record; invoker?: Record; }; } @@ -63,7 +63,7 @@ export default createHttpAdapter({ const xml = `\n` + `` + - actorXml("user", info?.user) + + actorXml("caller", info?.caller) + actorXml("invoker", info?.invoker) + ``; return { diff --git a/example/e2e/globalSetup.ts b/example/e2e/globalSetup.ts index 2dfb8582f9..87fbf8165a 100644 --- a/example/e2e/globalSetup.ts +++ b/example/e2e/globalSetup.ts @@ -1,3 +1,4 @@ +import { setTimeout as delay } from "node:timers/promises"; import { loadAccessToken, loadWorkspaceId, @@ -6,6 +7,8 @@ import { } from "@tailor-platform/sdk/cli"; import type { TestProject } from "vitest/node"; +const machineUserTokenRetryDelays = [5_000, 10_000, 15_000, 20_000, 25_000]; + declare module "vitest" { export interface ProvidedContext { url: string; @@ -18,9 +21,7 @@ declare module "vitest" { export async function setup(project: TestProject) { const app = await show(); - const tokens = await getMachineUserToken({ - name: "manager-machine-user", - }); + const tokens = await getManagerMachineUserToken(); const workspaceId = await loadWorkspaceId(); const platformToken = await loadAccessToken(); @@ -30,3 +31,26 @@ export async function setup(project: TestProject) { project.provide("platformToken", platformToken); project.provide("appName", app.name); } + +async function getManagerMachineUserToken() { + for (let attempt = 0; ; attempt++) { + try { + return await getMachineUserToken({ + name: "manager-machine-user", + }); + } catch (error) { + const retryDelay = machineUserTokenRetryDelays[attempt]; + if (retryDelay === undefined) { + throw error; + } + + const message = error instanceof Error ? error.message : String(error); + console.warn( + `Failed to fetch manager-machine-user token: ${message}. Retrying in ${ + retryDelay / 1000 + }s.`, + ); + await delay(retryDelay); + } + } +} diff --git a/example/e2e/httpAdapter.test.ts b/example/e2e/httpAdapter.test.ts index 4271ff2230..05f3890b05 100644 --- a/example/e2e/httpAdapter.test.ts +++ b/example/e2e/httpAdapter.test.ts @@ -21,7 +21,7 @@ describe("HTTP adapter routing", () => { expect(res.headers.get("content-type") ?? "").toContain("application/xml"); const body = await res.text(); expect(body).toContain(""); - expect(body).toMatch(/[\s\S]*<\/user>/); + expect(body).toMatch(/[\s\S]*<\/caller>/); }); test("POST /api/whoami fails with 404 because the adapter only declares GET", async () => { diff --git a/example/e2e/resolver.test.ts b/example/e2e/resolver.test.ts index a16448dcb3..423ea61b2d 100644 --- a/example/e2e/resolver.test.ts +++ b/example/e2e/resolver.test.ts @@ -261,7 +261,7 @@ describe("dataplane", () => { const query = gql` query { showUserInfo { - user { + caller { id type workspaceId @@ -280,7 +280,7 @@ describe("dataplane", () => { expect(result.errors).toBeUndefined(); expect(result.data).toEqual({ showUserInfo: { - user: { + caller: { id: expect.any(String), type: "machine_user", workspaceId: expect.any(String), @@ -310,13 +310,15 @@ describe("dataplane", () => { const responseFields = userInfo?.response?.type?.fields ?? []; - const userField = responseFields.find((f) => f.name === "user"); - expect(userField?.description).toBe("Authenticated user"); - const userSubFields = userField?.type?.fields ?? []; - expect(userSubFields.find((f) => f.name === "id")?.description).toBe("User ID"); - expect(userSubFields.find((f) => f.name === "type")?.description).toBe("User type"); - expect(userSubFields.find((f) => f.name === "workspaceId")?.description).toBe("Workspace ID"); - expect(userSubFields.find((f) => f.name === "role")?.description).toBe("User role"); + const callerField = responseFields.find((f) => f.name === "caller"); + expect(callerField?.description).toBe("Authenticated caller"); + const callerSubFields = callerField?.type?.fields ?? []; + expect(callerSubFields.find((f) => f.name === "id")?.description).toBe("User ID"); + expect(callerSubFields.find((f) => f.name === "type")?.description).toBe("User type"); + expect(callerSubFields.find((f) => f.name === "workspaceId")?.description).toBe( + "Workspace ID", + ); + expect(callerSubFields.find((f) => f.name === "role")?.description).toBe("User role"); const invokerField = responseFields.find((f) => f.name === "invoker"); expect(invokerField?.description).toBe("Function invoker"); @@ -496,7 +498,7 @@ describe("dataplane", () => { const customerIdInput = triggerResolver?.inputs?.find((i) => i.name === "customerId"); expect(customerIdInput?.description).toBe("Customer ID for the order"); expect(customerIdInput?.type?.kind).toBe("ScalarType"); - expect(customerIdInput?.type?.name).toBe("String"); + expect(customerIdInput?.type?.name).toBe("ID"); // Verify response const responseFields = triggerResolver?.response?.type?.fields ?? []; diff --git a/example/e2e/tailordb.test.ts b/example/e2e/tailordb.test.ts index 91499ca178..0b2ba1d35a 100644 --- a/example/e2e/tailordb.test.ts +++ b/example/e2e/tailordb.test.ts @@ -51,7 +51,7 @@ describe("controlplane", () => { }, updatedAt: { type: "datetime", - required: false, + required: true, array: false, hooks: expect.any(Object), }, @@ -497,8 +497,8 @@ describe("dataplane", () => { createUser: { id: string; name: string; - createdAt?: string; - updatedAt?: string; + createdAt: string; + updatedAt: string; }; } const createResult = await graphQLClient.rawRequest(create); @@ -508,7 +508,7 @@ describe("dataplane", () => { id: expect.any(String), name: "alice", createdAt: expect.any(String), - updatedAt: null, + updatedAt: expect.any(String), }, }); const userId = createResult.data.createUser.id; diff --git a/example/e2e/utils.ts b/example/e2e/utils.ts index 7f70ec2ee4..605440afa0 100644 --- a/example/e2e/utils.ts +++ b/example/e2e/utils.ts @@ -5,7 +5,7 @@ import { GraphQLClient } from "graphql-request"; import { inject } from "vitest"; export function createOperatorClient() { - const baseUrl = process.env.PLATFORM_URL ?? "https://api.tailor.tech"; + const baseUrl = process.env.TAILOR_PLATFORM_URL ?? "https://api.tailor.tech"; const workspaceId = inject("workspaceId"); const platformToken = inject("platformToken"); @@ -23,7 +23,7 @@ function userAgentInterceptor(): Interceptor { return await next(req); } - req.header.set("User-Agent", "tailor-sdk-ci"); + req.header.set("User-Agent", "tailor-ci"); return await next(req); }; } diff --git a/example/generated/files.ts b/example/generated/files.ts index 0389b9acdb..b7e393b3d1 100644 --- a/example/generated/files.ts +++ b/example/generated/files.ts @@ -3,7 +3,7 @@ import type { FileUploadOptions, FileUploadResponse, FileMetadata, - FileStreamIterator, + FileDownloadStreamResponse, } from "@tailor-platform/sdk/runtime/file"; export interface TypeWithFiles { @@ -58,10 +58,10 @@ export async function getFileMetadata( return await file.getMetadata(namespaces[type], type, field, recordId); } -export async function openFileDownloadStream( +export async function downloadFileStream( type: T, field: TypeWithFiles[T]["fields"], recordId: string, -): Promise { - return await file.openDownloadStream(namespaces[type], type, field, recordId); +): Promise { + return await file.downloadStream(namespaces[type], type, field, recordId); } diff --git a/example/generated/tailordb.ts b/example/generated/tailordb.ts index db9ea0e47b..a34d57b51b 100644 --- a/example/generated/tailordb.ts +++ b/example/generated/tailordb.ts @@ -1,6 +1,7 @@ import { createGetDB, type Generated, + type UUIDString, type Timestamp, type ObjectColumnType, type Serial, @@ -16,7 +17,7 @@ import { export interface Namespace { "tailordb": { Customer: { - id: Generated; + id: Generated; name: string; email: string; phone: string | null; @@ -24,25 +25,25 @@ export interface Namespace { postalCode: string; address: string | null; city: string | null; - fullAddress: Generated; + fullAddress: string; state: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Invoice: { - id: Generated; + id: Generated; invoiceNumber: Serial; - salesOrderID: string; + salesOrderID: UUIDString; amount: number | null; sequentialId: Serial; status: "draft" | "sent" | "paid" | "cancelled" | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } NestedProfile: { - id: Generated; + id: Generated; userInfo: ObjectColumnType<{ name: string; age?: number | null; @@ -57,55 +58,55 @@ export interface Namespace { }>; archived: boolean | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } PurchaseOrder: { - id: Generated; - supplierID: string; + id: Generated; + supplierID: UUIDString; totalPrice: number; discount: number | null; status: string; attachedFiles: { - id: string; + id: UUIDString; name: string; size: number; type: "text" | "image"; }[]; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } SalesOrder: { - id: Generated; - customerID: string; - approvedByUserIDs: string[] | null; + id: Generated; + customerID: UUIDString; + approvedByUserIDs: UUIDString[] | null; totalPrice: number | null; discount: number | null; status: string | null; cancelReason: string | null; canceledAt: Timestamp | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } SalesOrderCreated: { - id: Generated; - salesOrderID: string; - customerID: string; + id: Generated; + salesOrderID: UUIDString; + customerID: UUIDString; totalPrice: number | null; status: string | null; } Selfie: { - id: Generated; + id: Generated; name: string; - parentID: string | null; - dependId: string | null; + parentID: UUIDString | null; + dependId: UUIDString | null; } Supplier: { - id: Generated; + id: Generated; name: string; phone: string; fax: string | null; @@ -115,42 +116,42 @@ export interface Namespace { state: "Alabama" | "Alaska"; city: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { - id: Generated; + id: Generated; name: string; email: string; status: string | null; department: string | null; role: "MANAGER" | "STAFF"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } UserLog: { - id: Generated; - userID: string; + id: Generated; + userID: UUIDString; message: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } UserSetting: { - id: Generated; + id: Generated; language: "jp" | "en"; - userID: string; + userID: UUIDString; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } }, "analyticsdb": { Event: { - id: Generated; + id: Generated; name: "CLICK" | "VIEW" | "PURCHASE"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/example/migrations/0002/diff.json b/example/migrations/0002/diff.json new file mode 100644 index 0000000000..e3abafca46 --- /dev/null +++ b/example/migrations/0002/diff.json @@ -0,0 +1,644 @@ +{ + "version": 1, + "namespace": "tailordb", + "createdAt": "2026-06-17T04:37:34.736Z", + "changes": [ + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "name", + "before": { + "type": "string", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value.length>5)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "Name must be longer than 5 characters" + } + ] + }, + "after": { + "type": "string", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value.length>5)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "Name must be longer than 5 characters" + } + ] + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "city", + "before": { + "type": "string", + "required": false, + "validate": [ + { + "script": { + "expr": "(({value})=>value?value.length>1:true)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "failed by `({value})=>value?value.length>1:true`" + }, + { + "script": { + "expr": "(({value})=>value?value.length<100:true)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "failed by `({value})=>value?value.length<100:true`" + } + ] + }, + "after": { + "type": "string", + "required": false, + "validate": [ + { + "script": { + "expr": "(({value})=>value?value.length>1:true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({value})=>value?value.length>1:true`" + }, + { + "script": { + "expr": "(({value})=>value?value.length<100:true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({value})=>value?value.length<100:true`" + } + ] + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "fullAddress", + "before": { + "type": "string", + "required": true, + "hooks": { + "create": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "update": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "string", + "required": true, + "hooks": { + "create": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Invoice", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Invoice", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "NestedProfile", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "NestedProfile", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "attachedFiles", + "before": { + "type": "nested", + "required": true, + "array": true, + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "size": { + "type": "integer", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value>0)({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + }, + "errorMessage": "failed by `({value})=>value>0`" + } + ] + }, + "type": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "text" + }, + { + "value": "image" + } + ] + } + } + }, + "after": { + "type": "nested", + "required": true, + "array": true, + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "size": { + "type": "integer", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value>0)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({value})=>value>0`" + } + ] + }, + "type": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "text" + }, + { + "value": "image" + } + ] + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "SalesOrder", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "SalesOrder", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Supplier", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Supplier", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "User", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "User", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserLog", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserLog", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserSetting", + "fieldName": "createdAt", + "before": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserSetting", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, user: { id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes } })" + } + } + }, + "after": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + } + ], + "hasBreakingChanges": false, + "breakingChanges": [], + "hasWarnings": false, + "warnings": [], + "requiresMigrationScript": false +} diff --git a/example/migrations/0003/db.ts b/example/migrations/0003/db.ts new file mode 100644 index 0000000000..9e1b783ec2 --- /dev/null +++ b/example/migrations/0003/db.ts @@ -0,0 +1,170 @@ +/** + * Auto-generated Kysely types for migration script. + * These types reflect the database schema state at this migration point. + * + * DO NOT EDIT - This file is auto-generated by the migration system. + */ + +import { + type ColumnType, + type Transaction as KyselyTransaction, + type UUIDString, + type DateTimeString, +} from "@tailor-platform/sdk/kysely"; +import type { Env } from "@tailor-platform/sdk"; + +type Timestamp = ColumnType; +type Generated = + T extends ColumnType + ? ColumnType + : ColumnType; + +interface Database { + Customer: { + id: Generated; + name: string; + email: string; + phone: string | null; + country: string; + postalCode: string; + address: string | null; + city: string | null; + fullAddress: string; + state: string; + createdAt: Timestamp; + updatedAt: ColumnType< + Date | DateTimeString | null, + Date | DateTimeString, + Date | DateTimeString + >; + }; + Invoice: { + id: Generated; + invoiceNumber: string; + salesOrderID: UUIDString; + amount: number | null; + sequentialId: number; + status: "draft" | "sent" | "paid" | "cancelled" | null; + createdAt: Timestamp; + updatedAt: ColumnType< + Date | DateTimeString | null, + Date | DateTimeString, + Date | DateTimeString + >; + }; + NestedProfile: { + id: Generated; + userInfo: string; + metadata: string; + archived: boolean | null; + createdAt: Timestamp; + updatedAt: ColumnType< + Date | DateTimeString | null, + Date | DateTimeString, + Date | DateTimeString + >; + }; + PurchaseOrder: { + id: Generated; + supplierID: UUIDString; + totalPrice: number; + discount: number | null; + status: string; + attachedFiles: string[]; + createdAt: Timestamp; + updatedAt: ColumnType< + Date | DateTimeString | null, + Date | DateTimeString, + Date | DateTimeString + >; + }; + SalesOrder: { + id: Generated; + customerID: UUIDString; + approvedByUserIDs: UUIDString[] | null; + totalPrice: number | null; + discount: number | null; + status: string | null; + cancelReason: string | null; + canceledAt: Timestamp | null; + createdAt: Timestamp; + updatedAt: ColumnType< + Date | DateTimeString | null, + Date | DateTimeString, + Date | DateTimeString + >; + }; + SalesOrderCreated: { + id: Generated; + salesOrderID: UUIDString; + customerID: UUIDString; + totalPrice: number | null; + status: string | null; + }; + Selfie: { + id: Generated; + name: string; + parentID: UUIDString | null; + dependId: UUIDString | null; + }; + Supplier: { + id: Generated; + name: string; + phone: string; + fax: string | null; + email: string | null; + postalCode: string; + country: string; + state: "Alabama" | "Alaska"; + city: string; + createdAt: Timestamp; + updatedAt: ColumnType< + Date | DateTimeString | null, + Date | DateTimeString, + Date | DateTimeString + >; + }; + User: { + id: Generated; + name: string; + email: string; + status: string | null; + department: string | null; + role: "MANAGER" | "STAFF"; + createdAt: Timestamp; + updatedAt: ColumnType< + Date | DateTimeString | null, + Date | DateTimeString, + Date | DateTimeString + >; + }; + UserLog: { + id: Generated; + userID: UUIDString; + message: string; + createdAt: Timestamp; + updatedAt: ColumnType< + Date | DateTimeString | null, + Date | DateTimeString, + Date | DateTimeString + >; + }; + UserSetting: { + id: Generated; + language: "jp" | "en"; + userID: UUIDString; + createdAt: Timestamp; + updatedAt: ColumnType< + Date | DateTimeString | null, + Date | DateTimeString, + Date | DateTimeString + >; + }; +} + +export type Transaction = KyselyTransaction; + +/** Context passed as the second argument to the migration's `main` function. */ +export type MigrationContext = { + env: keyof Env extends never ? Record : Env; +}; diff --git a/example/migrations/0003/diff.json b/example/migrations/0003/diff.json new file mode 100644 index 0000000000..b97ecf496a --- /dev/null +++ b/example/migrations/0003/diff.json @@ -0,0 +1,311 @@ +{ + "version": 1, + "namespace": "tailordb", + "createdAt": "2026-06-17T15:41:21.506Z", + "changes": [ + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Invoice", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "NestedProfile", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "SalesOrder", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "Supplier", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "User", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserLog", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "UserSetting", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + } + ], + "hasBreakingChanges": true, + "breakingChanges": [ + { + "typeName": "Customer", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "Invoice", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "NestedProfile", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "PurchaseOrder", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "SalesOrder", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "Supplier", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "User", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "UserLog", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + }, + { + "typeName": "UserSetting", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + } + ], + "hasWarnings": false, + "warnings": [], + "requiresMigrationScript": true, + "description": "set updatedAt on create" +} diff --git a/example/migrations/0003/migrate.ts b/example/migrations/0003/migrate.ts new file mode 100644 index 0000000000..8b6518888a --- /dev/null +++ b/example/migrations/0003/migrate.ts @@ -0,0 +1,70 @@ +/** + * Migration script for tailordb + * + * This script runs between the Pre-migration and Post-migration phases of + * 'tailor deploy'. Use it to transform existing data so that the schema + * change can complete safely (for breaking changes, this is hard-required; + * for warning-tier changes it is optional). Edit this file to implement + * your data migration logic. + * + * The transaction is managed by the deploy command. + * If any operation fails, all changes will be rolled back. + */ + +import type { Transaction } from "./db"; + +export async function main(trx: Transaction): Promise { + await trx + .updateTable("Customer") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("Invoice") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("NestedProfile") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("PurchaseOrder") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("SalesOrder") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("Supplier") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("User") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("UserLog") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); + + await trx + .updateTable("UserSetting") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); +} diff --git a/example/migrations/0004/diff.json b/example/migrations/0004/diff.json new file mode 100644 index 0000000000..a38bf0c384 --- /dev/null +++ b/example/migrations/0004/diff.json @@ -0,0 +1,194 @@ +{ + "version": 1, + "namespace": "tailordb", + "createdAt": "2026-06-24T04:44:47.663Z", + "changes": [ + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "name", + "before": { + "type": "string", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value.length>5)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "Name must be longer than 5 characters" + } + ] + }, + "after": { + "type": "string", + "required": true, + "validate": [ + { + "script": { + "expr": "(({ value })=>value.length > 5)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "Name must be longer than 5 characters" + } + ] + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "city", + "before": { + "type": "string", + "required": false, + "validate": [ + { + "script": { + "expr": "(({value})=>value?value.length>1:true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({value})=>value?value.length>1:true`" + }, + { + "script": { + "expr": "(({value})=>value?value.length<100:true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({value})=>value?value.length<100:true`" + } + ] + }, + "after": { + "type": "string", + "required": false, + "validate": [ + { + "script": { + "expr": "(({ value })=>value ? value.length > 1 : true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({ value })=>value ? value.length > 1 : true`" + }, + { + "script": { + "expr": "(({ value })=>value ? value.length < 100 : true)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({ value })=>value ? value.length < 100 : true`" + } + ] + } + }, + { + "kind": "field_modified", + "typeName": "Customer", + "fieldName": "fullAddress", + "before": { + "type": "string", + "required": true, + "hooks": { + "create": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(({data})=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "string", + "required": true, + "hooks": { + "create": { + "expr": "(({ data })=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(({ data })=>`${data.postalCode} ${data.address} ${data.city}`)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + { + "kind": "field_modified", + "typeName": "PurchaseOrder", + "fieldName": "attachedFiles", + "before": { + "type": "nested", + "required": true, + "array": true, + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "size": { + "type": "integer", + "required": true, + "validate": [ + { + "script": { + "expr": "(({value})=>value>0)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({value})=>value>0`" + } + ] + }, + "type": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "text" + }, + { + "value": "image" + } + ] + } + } + }, + "after": { + "type": "nested", + "required": true, + "array": true, + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "size": { + "type": "integer", + "required": true, + "validate": [ + { + "script": { + "expr": "(({ value })=>value > 0)({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "errorMessage": "failed by `({ value })=>value > 0`" + } + ] + }, + "type": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "text" + }, + { + "value": "image" + } + ] + } + } + } + } + ], + "hasBreakingChanges": false, + "breakingChanges": [], + "hasWarnings": false, + "warnings": [], + "requiresMigrationScript": false +} diff --git a/example/migrations/analyticsdb/0000/schema.json b/example/migrations/analyticsdb/0000/schema.json new file mode 100644 index 0000000000..4b5cfd05ff --- /dev/null +++ b/example/migrations/analyticsdb/0000/schema.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "namespace": "analyticsdb", + "createdAt": "2026-06-17T14:01:24.799Z", + "types": { + "Event": { + "name": "Event", + "pluralForm": "Events", + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "CLICK" + }, + { + "value": "VIEW" + }, + { + "value": "PURCHASE" + } + ] + }, + "createdAt": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "updatedAt": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + "settings": {}, + "files": { + "screenshot": "screenshot image" + } + } + } +} diff --git a/example/migrations/analyticsdb/0001/db.ts b/example/migrations/analyticsdb/0001/db.ts new file mode 100644 index 0000000000..11cb40173b --- /dev/null +++ b/example/migrations/analyticsdb/0001/db.ts @@ -0,0 +1,40 @@ +/** + * Auto-generated Kysely types for migration script. + * These types reflect the database schema state at this migration point. + * + * DO NOT EDIT - This file is auto-generated by the migration system. + */ + +import { + type ColumnType, + type Transaction as KyselyTransaction, + type UUIDString, + type DateTimeString, +} from "@tailor-platform/sdk/kysely"; +import type { Env } from "@tailor-platform/sdk"; + +type Timestamp = ColumnType; +type Generated = + T extends ColumnType + ? ColumnType + : ColumnType; + +interface Database { + Event: { + id: Generated; + name: "CLICK" | "VIEW" | "PURCHASE"; + createdAt: Timestamp; + updatedAt: ColumnType< + Date | DateTimeString | null, + Date | DateTimeString, + Date | DateTimeString + >; + }; +} + +export type Transaction = KyselyTransaction; + +/** Context passed as the second argument to the migration's `main` function. */ +export type MigrationContext = { + env: keyof Env extends never ? Record : Env; +}; diff --git a/example/migrations/analyticsdb/0001/diff.json b/example/migrations/analyticsdb/0001/diff.json new file mode 100644 index 0000000000..dfd2838012 --- /dev/null +++ b/example/migrations/analyticsdb/0001/diff.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "namespace": "analyticsdb", + "createdAt": "2026-06-17T14:01:59.003Z", + "changes": [ + { + "kind": "field_modified", + "typeName": "Event", + "fieldName": "updatedAt", + "before": { + "type": "datetime", + "required": false, + "description": "Record last update timestamp", + "hooks": { + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "after": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + } + ], + "hasBreakingChanges": true, + "breakingChanges": [ + { + "typeName": "Event", + "fieldName": "updatedAt", + "reason": "Field changed from optional to required" + } + ], + "hasWarnings": false, + "warnings": [], + "requiresMigrationScript": true +} diff --git a/example/migrations/analyticsdb/0001/migrate.ts b/example/migrations/analyticsdb/0001/migrate.ts new file mode 100644 index 0000000000..43f459e54b --- /dev/null +++ b/example/migrations/analyticsdb/0001/migrate.ts @@ -0,0 +1,22 @@ +/** + * Migration script for analyticsdb + * + * This script runs between the Pre-migration and Post-migration phases of + * 'tailor deploy'. Use it to transform existing data so that the schema + * change can complete safely (for breaking changes, this is hard-required; + * for warning-tier changes it is optional). Edit this file to implement + * your data migration logic. + * + * The transaction is managed by the deploy command. + * If any operation fails, all changes will be rolled back. + */ + +import type { Transaction } from "./db"; + +export async function main(trx: Transaction): Promise { + await trx + .updateTable("Event") + .set((eb) => ({ updatedAt: eb.ref("createdAt") })) + .where("updatedAt", "is", null) + .execute(); +} diff --git a/example/package.json b/example/package.json index 9ac9386d24..dff3b1d9fb 100644 --- a/example/package.json +++ b/example/package.json @@ -6,9 +6,9 @@ "license": "MIT", "type": "module", "scripts": { - "generate": "tailor-sdk generate -c tailor.config.ts", - "generate:watch": "tailor-sdk generate -c tailor.config.ts --watch", - "deploy": "tailor-sdk deploy -c tailor.config.ts", + "generate": "tailor generate -c tailor.config.ts", + "generate:watch": "tailor generate -c tailor.config.ts --watch", + "deploy": "tailor deploy -c tailor.config.ts", "test": "pnpm test:generator", "test:all": "pnpm test:generator:prepare && vitest", "test:generator": "pnpm test:generator:prepare && vitest --project generator", @@ -17,7 +17,7 @@ "test:e2e": "vitest --project e2e", "migration:e2e": "tsx tests/scripts/migration_e2e.ts", "seed:validate": "node ./seed/exec.mjs validate", - "seed:truncate": "tailor-sdk tailordb truncate -a", + "seed:truncate": "tailor tailordb truncate -a", "seed": "node ./seed/exec.mjs", "analyze:bundle": "tsx tests/scripts/analyze_minified_size.ts", "lint": "oxlint --type-aware .", @@ -35,6 +35,7 @@ "@connectrpc/connect-node": "2.1.2", "@types/node": "24.13.2", "@typescript/native-preview": "7.0.0-dev.20260621.1", + "eslint-plugin-zod": "4.7.0", "graphql": "17.0.1", "graphql-request": "7.4.0", "multiline-ts": "4.0.1", @@ -46,6 +47,6 @@ "vitest": "4.1.9" }, "engines": { - "node": ">= 22.14.0" + "node": ">= 22.15.0" } } diff --git a/example/resolvers/add.ts b/example/resolvers/add.ts index 2656931ca6..f3a0460b70 100644 --- a/example/resolvers/add.ts +++ b/example/resolvers/add.ts @@ -1,9 +1,9 @@ import { createResolver, t } from "@tailor-platform/sdk"; -const validators: [(a: { value: number }) => boolean, string][] = [ - [({ value }) => value >= 0, "Value must be non-negative"], - [({ value }) => value < 10, "Value must be less than 10"], -]; +const validators = [ + ({ value }: { value: number }) => (value >= 0 ? undefined : "Value must be non-negative"), + ({ value }: { value: number }) => (value < 10 ? undefined : "Value must be less than 10"), +] as const; export default createResolver({ name: "add", description: "Addition operation", diff --git a/example/resolvers/passThrough.ts b/example/resolvers/passThrough.ts index adeaa78e79..6fa5614619 100644 --- a/example/resolvers/passThrough.ts +++ b/example/resolvers/passThrough.ts @@ -2,8 +2,8 @@ import { createResolver, t } from "@tailor-platform/sdk"; import { nestedProfile } from "../tailordb/nested"; const inputFields = { - ...nestedProfile.pickFields(["id", "createdAt"], { optional: true }), - ...nestedProfile.omitFields(["id", "createdAt"]), + ...nestedProfile.pickFields(["id", "createdAt", "updatedAt"], { optional: true }), + ...nestedProfile.omitFields(["id", "createdAt", "updatedAt"]), }; export default createResolver({ operation: "query", @@ -13,10 +13,14 @@ export default createResolver({ id: t.uuid({ optional: true }), input: t.object(inputFields), }, - body: ({ input }) => ({ - ...input.input, - id: input.id ?? crypto.randomUUID(), - createdAt: new Date(), - }), + body: ({ input }) => { + const now = new Date(); + return { + ...input.input, + id: input.id ?? crypto.randomUUID(), + createdAt: input.input.createdAt ?? now, + updatedAt: input.input.updatedAt ?? now, + }; + }, output: nestedProfile.fields, }); diff --git a/example/resolvers/stepChain.ts b/example/resolvers/stepChain.ts index 123cdc8649..a5cd9dd04e 100644 --- a/example/resolvers/stepChain.ts +++ b/example/resolvers/stepChain.ts @@ -14,17 +14,15 @@ export default createResolver({ first: t .string() .description("User's first name") - .validate([ - ({ value }) => value.length >= 2, - "First name must be at least 2 characters", - ]), + .validate(({ value }) => + value.length >= 2 ? undefined : "First name must be at least 2 characters", + ), last: t .string() .description("User's last name") - .validate([ - ({ value }) => value.length >= 2, - "Last name must be at least 2 characters", - ]), + .validate(({ value }) => + value.length >= 2 ? undefined : "Last name must be at least 2 characters", + ), }) .description("User's full name"), activatedAt: t.datetime({ optional: true }).description("User activation timestamp"), diff --git a/example/resolvers/triggerWorkflow.ts b/example/resolvers/triggerWorkflow.ts index 5c6c3297b1..028b493fb4 100644 --- a/example/resolvers/triggerWorkflow.ts +++ b/example/resolvers/triggerWorkflow.ts @@ -7,16 +7,16 @@ export default createResolver({ operation: "mutation", input: { orderId: t.string().description("Order ID to process"), - customerId: t.string().description("Customer ID for the order"), + customerId: t.uuid().description("Customer ID for the order"), }, body: async ({ input }) => { - // Trigger the workflow with authInvoker (machine user name is type-narrowed via tailor.d.ts) + // Trigger the workflow with invoker (machine user name is type-narrowed via tailor.d.ts) const workflowRunId = await orderProcessingWorkflow.trigger( { orderId: input.orderId, customerId: input.customerId, }, - { authInvoker: "manager-machine-user" }, + { invoker: "manager-machine-user" }, ); return { diff --git a/example/resolvers/userInfo.ts b/example/resolvers/userInfo.ts index 4d4943dc6d..abcccba0be 100644 --- a/example/resolvers/userInfo.ts +++ b/example/resolvers/userInfo.ts @@ -6,11 +6,11 @@ export default createResolver({ operation: "query", body: (context) => { return { - user: { - id: context.user.id, - type: context.user.type, - workspaceId: context.user.workspaceId, - role: context.user.attributes?.role ?? "MANAGER", + caller: { + id: context.caller?.id ?? "", + type: context.caller?.type ?? "", + workspaceId: context.caller?.workspaceId ?? "", + role: context.caller?.attributes.role ?? "MANAGER", }, invoker: { id: context.invoker!.id, @@ -22,14 +22,14 @@ export default createResolver({ }, output: t .object({ - user: t + caller: t .object({ id: t.string().description("User ID"), type: t.string().description("User type"), workspaceId: t.string().description("Workspace ID"), role: t.enum(["MANAGER", "STAFF"]).description("User role"), }) - .description("Authenticated user"), + .description("Authenticated caller"), invoker: t .object({ id: t.string().description("Invoker ID"), @@ -40,5 +40,5 @@ export default createResolver({ .description("Function invoker"), }) .description("User and invoker information"), - authInvoker: "manager-machine-user", + invoker: "manager-machine-user", }); diff --git a/example/seed/data/Customer.schema.ts b/example/seed/data/Customer.schema.ts index 756c2f29ea..99285ca3a7 100644 --- a/example/seed/data/Customer.schema.ts +++ b/example/seed/data/Customer.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { customer } from "../../tailordb/customer"; const schemaType = t.object({ - ...customer.pickFields(["id","fullAddress","createdAt"], { optional: true }), - ...customer.omitFields(["id","fullAddress","createdAt"]), + ...customer.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...customer.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(customer); diff --git a/example/seed/data/Event.schema.ts b/example/seed/data/Event.schema.ts index 0bc3d8691f..94943c5524 100644 --- a/example/seed/data/Event.schema.ts +++ b/example/seed/data/Event.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { event } from "../../analyticsdb/event"; const schemaType = t.object({ - ...event.pickFields(["id","createdAt"], { optional: true }), - ...event.omitFields(["id","createdAt"]), + ...event.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...event.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(event); diff --git a/example/seed/data/Invoice.schema.ts b/example/seed/data/Invoice.schema.ts index b25da906fd..614863d24e 100644 --- a/example/seed/data/Invoice.schema.ts +++ b/example/seed/data/Invoice.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { invoice } from "../../tailordb/invoice"; const schemaType = t.object({ - ...invoice.pickFields(["id","createdAt"], { optional: true }), - ...invoice.omitFields(["id","createdAt","invoiceNumber","sequentialId"]), + ...invoice.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...invoice.omitFields(["id","createdAt","updatedAt","invoiceNumber","sequentialId"]), }); const hook = createTailorDBHook(invoice); diff --git a/example/seed/data/NestedProfile.schema.ts b/example/seed/data/NestedProfile.schema.ts index 2c52ea3778..18efb54dcd 100644 --- a/example/seed/data/NestedProfile.schema.ts +++ b/example/seed/data/NestedProfile.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { nestedProfile } from "../../tailordb/nested"; const schemaType = t.object({ - ...nestedProfile.pickFields(["id","createdAt"], { optional: true }), - ...nestedProfile.omitFields(["id","createdAt"]), + ...nestedProfile.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...nestedProfile.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(nestedProfile); diff --git a/example/seed/data/PurchaseOrder.schema.ts b/example/seed/data/PurchaseOrder.schema.ts index 3a26ef3a36..7b4aef58ac 100644 --- a/example/seed/data/PurchaseOrder.schema.ts +++ b/example/seed/data/PurchaseOrder.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { purchaseOrder } from "../../tailordb/purchaseOrder"; const schemaType = t.object({ - ...purchaseOrder.pickFields(["id","createdAt"], { optional: true }), - ...purchaseOrder.omitFields(["id","createdAt"]), + ...purchaseOrder.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...purchaseOrder.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(purchaseOrder); diff --git a/example/seed/data/SalesOrder.schema.ts b/example/seed/data/SalesOrder.schema.ts index 3f2533204a..132da34f6a 100644 --- a/example/seed/data/SalesOrder.schema.ts +++ b/example/seed/data/SalesOrder.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { salesOrder } from "../../tailordb/salesOrder"; const schemaType = t.object({ - ...salesOrder.pickFields(["id","createdAt"], { optional: true }), - ...salesOrder.omitFields(["id","createdAt"]), + ...salesOrder.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...salesOrder.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(salesOrder); diff --git a/example/seed/data/Supplier.schema.ts b/example/seed/data/Supplier.schema.ts index bac16337c0..722f453e7e 100644 --- a/example/seed/data/Supplier.schema.ts +++ b/example/seed/data/Supplier.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { supplier } from "../../tailordb/supplier"; const schemaType = t.object({ - ...supplier.pickFields(["id","createdAt"], { optional: true }), - ...supplier.omitFields(["id","createdAt"]), + ...supplier.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...supplier.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(supplier); diff --git a/example/seed/data/User.schema.ts b/example/seed/data/User.schema.ts index 6c5a84d864..16ee30b3f5 100644 --- a/example/seed/data/User.schema.ts +++ b/example/seed/data/User.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { user } from "../../tailordb/user"; const schemaType = t.object({ - ...user.pickFields(["id","createdAt"], { optional: true }), - ...user.omitFields(["id","createdAt"]), + ...user.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...user.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(user); diff --git a/example/seed/data/UserLog.schema.ts b/example/seed/data/UserLog.schema.ts index 32dfc98fab..1efa27fdd5 100644 --- a/example/seed/data/UserLog.schema.ts +++ b/example/seed/data/UserLog.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { userLog } from "../../tailordb/userLog"; const schemaType = t.object({ - ...userLog.pickFields(["id","createdAt"], { optional: true }), - ...userLog.omitFields(["id","createdAt"]), + ...userLog.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...userLog.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(userLog); diff --git a/example/seed/data/UserSetting.schema.ts b/example/seed/data/UserSetting.schema.ts index 553d42c9e4..fecf7e43ec 100644 --- a/example/seed/data/UserSetting.schema.ts +++ b/example/seed/data/UserSetting.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { userSetting } from "../../tailordb/userSetting"; const schemaType = t.object({ - ...userSetting.pickFields(["id","createdAt"], { optional: true }), - ...userSetting.omitFields(["id","createdAt"]), + ...userSetting.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...userSetting.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(userSetting); diff --git a/example/seed/exec.mjs b/example/seed/exec.mjs index 203be85443..534e5424f2 100644 --- a/example/seed/exec.mjs +++ b/example/seed/exec.mjs @@ -307,7 +307,6 @@ const operatorClient = await initOperatorClient(accessToken); workspaceId, name: "truncate-idp-user.ts", code: idpTruncateCode, - arg: JSON.stringify({}), invoker: { namespace: authNamespace, machineUserName, @@ -501,7 +500,7 @@ const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) workspaceId, name: `seed-${namespace}.ts`, code: bundled.bundledCode, - arg: JSON.stringify({ data: chunk.data, order: chunk.order, selfRefTypes }), + arg: { data: chunk.data, order: chunk.order, selfRefTypes }, invoker: { namespace: authNamespace, machineUserName, @@ -596,7 +595,7 @@ const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) workspaceId, name: "seed-idp-user.ts", code: idpSeedCode, - arg: JSON.stringify({ users: rows }), + arg: { users: rows }, invoker: { namespace: authNamespace, machineUserName, diff --git a/example/tailor.config.ts b/example/tailor.config.ts index 31826ace14..078e726ae1 100644 --- a/example/tailor.config.ts +++ b/example/tailor.config.ts @@ -112,7 +112,7 @@ export default defineConfig({ // SDK-managed app id — do not edit, except when copying this config to a separate app. id: "d0a3398a-f79c-4c2e-be1e-b81469bb0a43", name: "my-app", - logLevel: process.env.LOG_LEVEL ?? "DEBUG", + logLevel: process.env.TAILOR_APP_LOG_LEVEL ?? "DEBUG", env: { foo: 1, bar: "hello", @@ -129,7 +129,12 @@ export default defineConfig({ directory: "./migrations", }, }, - analyticsdb: { files: ["./analyticsdb/*.ts"] }, + analyticsdb: { + files: ["./analyticsdb/*.ts"], + migration: { + directory: "./migrations/analyticsdb", + }, + }, }, resolver: { "my-resolver": { files: ["./resolvers/*.ts"] }, diff --git a/example/tailordb/customer.ts b/example/tailordb/customer.ts index efde41cb1f..66bb11532a 100644 --- a/example/tailordb/customer.ts +++ b/example/tailordb/customer.ts @@ -3,28 +3,38 @@ import { defaultGqlPermission, defaultPermission } from "./permissions"; export const customer = db .type("Customer", "Customer information", { - name: db.string(), + name: db + .string() + .validate(({ value }) => + value.length <= 5 ? "Name must be longer than 5 characters" : undefined, + ), email: db.string(), phone: db.string({ optional: true }), country: db.string(), postalCode: db.string(), address: db.string({ optional: true }), city: db.string({ optional: true }).validate( - ({ value }) => (value ? value.length > 1 : true), - ({ value }) => (value ? value.length < 100 : true), + ({ value }) => + value && value.length <= 1 ? "City must be longer than 1 character" : undefined, + ({ value }) => + value && value.length >= 100 ? "City must be shorter than 100 characters" : undefined, ), fullAddress: db.string(), state: db.string(), ...db.fields.timestamps(), }) .hooks({ - fullAddress: { - create: ({ data }) => `${data.postalCode} ${data.address} ${data.city}`, - update: ({ data }) => `${data.postalCode} ${data.address} ${data.city}`, - }, + create: ({ input }) => ({ + fullAddress: `${input.postalCode} ${input.address} ${input.city}`, + }), + update: ({ input }) => ({ + fullAddress: `${input.postalCode} ${input.address} ${input.city}`, + }), }) - .validate({ - name: [({ value }) => value.length > 5, "Name must be longer than 5 characters"], + .validate(({ newRecord }, issues) => { + if (newRecord.country === "JP" && !newRecord.postalCode) { + issues("postalCode", "Postal code is required for Japan"); + } }) .permission(defaultPermission) .gqlPermission(defaultGqlPermission); diff --git a/example/tailordb/file.ts b/example/tailordb/file.ts index 38726367ca..3a553bd552 100644 --- a/example/tailordb/file.ts +++ b/example/tailordb/file.ts @@ -4,7 +4,7 @@ export const attachedFiles = db.object( { id: db.uuid(), name: db.string(), - size: db.int().validate(({ value }) => value > 0), + size: db.int().validate(({ value }) => (value <= 0 ? "Size must be positive" : undefined)), type: db.enum(["text", "image"]), }, { array: true }, diff --git a/example/tests/bundled_execution.test.ts b/example/tests/bundled_execution.test.ts index 846ebaf3ed..4ad3039688 100644 --- a/example/tests/bundled_execution.test.ts +++ b/example/tests/bundled_execution.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import "@tailor-platform/sdk/runtime/globals"; import { mockTailordb, mockWorkflow } from "@tailor-platform/sdk/vitest"; import { format as formatDate } from "date-fns"; import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; @@ -76,7 +77,7 @@ describe("bundled execution tests", () => { expect(result).toEqual(10); }); - test("resolvers/showUserInfo.js returns user and invoker information", async () => { + test("resolvers/showUserInfo.js returns caller and invoker information", async () => { using _invokerSpy = vi.spyOn(globalThis.tailor.context, "getInvoker").mockReturnValue({ id: "f1e2d3c4-b5a6-4798-89a0-1b2c3d4e5f60", type: "machine_user", @@ -87,7 +88,7 @@ describe("bundled execution tests", () => { const main = await importActualMain("resolvers/showUserInfo.js"); const payload = { - user: { + caller: { id: "57485cfe-fc74-4d46-8660-f0e95d1fbf98", type: "user", workspaceId: "b39bdd61-d442-4a4e-8599-33a78a4e19ab", @@ -96,7 +97,7 @@ describe("bundled execution tests", () => { }; const result = await main(payload); expect(result).toEqual({ - user: { + caller: { id: "57485cfe-fc74-4d46-8660-f0e95d1fbf98", type: "user", workspaceId: "b39bdd61-d442-4a4e-8599-33a78a4e19ab", @@ -133,7 +134,7 @@ describe("bundled execution tests", () => { }, }, user: { - id: "test-user-id", + id: "123e4567-e89b-12d3-a456-426614174000", type: "user", workspaceId: "test-workspace-id", }, @@ -166,15 +167,18 @@ describe("bundled execution tests", () => { }); const main = await importActualMain("executors/user-created.js"); - const payload = { newRecord: { id: "user-1" } }; + const payload = { newRecord: { id: "11111111-1111-4111-8111-111111111111" } }; const result = await main(payload); expect(result).toBeUndefined(); expect(db.executedQueries).toEqual([ - { query: 'select * from "User" where "id" = $1', params: ["user-1"] }, + { + query: 'select * from "User" where "id" = $1', + params: ["11111111-1111-4111-8111-111111111111"], + }, { query: 'insert into "UserLog" ("userID", "message") values ($1, $2)', - params: ["user-1", "User created: undefined (undefined)"], + params: ["11111111-1111-4111-8111-111111111111", "User created: undefined (undefined)"], }, ]); expect(db.createdClients).toMatchObject([{ namespace: "tailordb" }]); @@ -198,19 +202,22 @@ describe("bundled execution tests", () => { const main = await importActualMain("workflow-jobs/process-order.js"); const result = await main({ orderId: "order-123", - customerId: "customer-456", + customerId: "123e4567-e89b-12d3-a456-426614174000", }); expect(result).toEqual({ orderId: "order-123", - customerId: "customer-456", + customerId: "123e4567-e89b-12d3-a456-426614174000", customerEmail: "customer@example.com", notificationSent: true, processedAt: "2025-01-01 12:00:00", }); expect(wf.triggeredJobs).toEqual([ - { jobName: "fetch-customer", args: { customerId: "customer-456" } }, + { + jobName: "fetch-customer", + args: { customerId: "123e4567-e89b-12d3-a456-426614174000" }, + }, { jobName: "send-notification", args: { @@ -230,9 +237,9 @@ describe("bundled execution tests", () => { await expect( main({ orderId: "order-123", - customerId: "non-existent", + customerId: "00000000-0000-0000-0000-000000000000", }), - ).rejects.toThrow("Customer non-existent not found"); + ).rejects.toThrow("Customer 00000000-0000-0000-0000-000000000000 not found"); }); test("workflow-jobs/send-notification.js executes correctly", async () => { diff --git a/example/tests/fixtures/expected/db.ts b/example/tests/fixtures/expected/db.ts index db9ea0e47b..ae1e3c5bba 100644 --- a/example/tests/fixtures/expected/db.ts +++ b/example/tests/fixtures/expected/db.ts @@ -1,6 +1,7 @@ import { createGetDB, type Generated, + type UUIDString, type Timestamp, type ObjectColumnType, type Serial, @@ -16,7 +17,7 @@ import { export interface Namespace { "tailordb": { Customer: { - id: Generated; + id: Generated; name: string; email: string; phone: string | null; @@ -27,22 +28,22 @@ export interface Namespace { fullAddress: Generated; state: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Invoice: { - id: Generated; + id: Generated; invoiceNumber: Serial; - salesOrderID: string; + salesOrderID: UUIDString; amount: number | null; sequentialId: Serial; status: "draft" | "sent" | "paid" | "cancelled" | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } NestedProfile: { - id: Generated; + id: Generated; userInfo: ObjectColumnType<{ name: string; age?: number | null; @@ -57,55 +58,55 @@ export interface Namespace { }>; archived: boolean | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } PurchaseOrder: { - id: Generated; - supplierID: string; + id: Generated; + supplierID: UUIDString; totalPrice: number; discount: number | null; status: string; attachedFiles: { - id: string; + id: UUIDString; name: string; size: number; type: "text" | "image"; }[]; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } SalesOrder: { - id: Generated; - customerID: string; - approvedByUserIDs: string[] | null; + id: Generated; + customerID: UUIDString; + approvedByUserIDs: UUIDString[] | null; totalPrice: number | null; discount: number | null; status: string | null; cancelReason: string | null; canceledAt: Timestamp | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } SalesOrderCreated: { - id: Generated; - salesOrderID: string; - customerID: string; + id: Generated; + salesOrderID: UUIDString; + customerID: UUIDString; totalPrice: number | null; status: string | null; } Selfie: { - id: Generated; + id: Generated; name: string; - parentID: string | null; - dependId: string | null; + parentID: UUIDString | null; + dependId: UUIDString | null; } Supplier: { - id: Generated; + id: Generated; name: string; phone: string; fax: string | null; @@ -115,42 +116,42 @@ export interface Namespace { state: "Alabama" | "Alaska"; city: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { - id: Generated; + id: Generated; name: string; email: string; status: string | null; department: string | null; role: "MANAGER" | "STAFF"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } UserLog: { - id: Generated; - userID: string; + id: Generated; + userID: UUIDString; message: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } UserSetting: { - id: Generated; + id: Generated; language: "jp" | "en"; - userID: string; + userID: UUIDString; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } }, "analyticsdb": { Event: { - id: Generated; + id: Generated; name: "CLICK" | "VIEW" | "PURCHASE"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/example/tests/fixtures/expected/files.ts b/example/tests/fixtures/expected/files.ts index 0389b9acdb..b7e393b3d1 100644 --- a/example/tests/fixtures/expected/files.ts +++ b/example/tests/fixtures/expected/files.ts @@ -3,7 +3,7 @@ import type { FileUploadOptions, FileUploadResponse, FileMetadata, - FileStreamIterator, + FileDownloadStreamResponse, } from "@tailor-platform/sdk/runtime/file"; export interface TypeWithFiles { @@ -58,10 +58,10 @@ export async function getFileMetadata( return await file.getMetadata(namespaces[type], type, field, recordId); } -export async function openFileDownloadStream( +export async function downloadFileStream( type: T, field: TypeWithFiles[T]["fields"], recordId: string, -): Promise { - return await file.openDownloadStream(namespaces[type], type, field, recordId); +): Promise { + return await file.downloadStream(namespaces[type], type, field, recordId); } diff --git a/example/tests/fixtures/expected/seed/data/Customer.schema.ts b/example/tests/fixtures/expected/seed/data/Customer.schema.ts index 145333a65e..11ecb751c8 100644 --- a/example/tests/fixtures/expected/seed/data/Customer.schema.ts +++ b/example/tests/fixtures/expected/seed/data/Customer.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { customer } from "../../../../../tailordb/customer"; const schemaType = t.object({ - ...customer.pickFields(["id","fullAddress","createdAt"], { optional: true }), - ...customer.omitFields(["id","fullAddress","createdAt"]), + ...customer.pickFields(["id","fullAddress","createdAt","updatedAt"], { optional: true }), + ...customer.omitFields(["id","fullAddress","createdAt","updatedAt"]), }); const hook = createTailorDBHook(customer); diff --git a/example/tests/fixtures/expected/seed/data/Event.schema.ts b/example/tests/fixtures/expected/seed/data/Event.schema.ts index 2c9f19fc40..50a3f4d02d 100644 --- a/example/tests/fixtures/expected/seed/data/Event.schema.ts +++ b/example/tests/fixtures/expected/seed/data/Event.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { event } from "../../../../../analyticsdb/event"; const schemaType = t.object({ - ...event.pickFields(["id","createdAt"], { optional: true }), - ...event.omitFields(["id","createdAt"]), + ...event.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...event.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(event); diff --git a/example/tests/fixtures/expected/seed/data/Invoice.schema.ts b/example/tests/fixtures/expected/seed/data/Invoice.schema.ts index 020f3b52ed..a2a7cfc499 100644 --- a/example/tests/fixtures/expected/seed/data/Invoice.schema.ts +++ b/example/tests/fixtures/expected/seed/data/Invoice.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { invoice } from "../../../../../tailordb/invoice"; const schemaType = t.object({ - ...invoice.pickFields(["id","createdAt"], { optional: true }), - ...invoice.omitFields(["id","createdAt","invoiceNumber","sequentialId"]), + ...invoice.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...invoice.omitFields(["id","createdAt","updatedAt","invoiceNumber","sequentialId"]), }); const hook = createTailorDBHook(invoice); diff --git a/example/tests/fixtures/expected/seed/data/NestedProfile.schema.ts b/example/tests/fixtures/expected/seed/data/NestedProfile.schema.ts index 38dc3b4e12..2424ec58c8 100644 --- a/example/tests/fixtures/expected/seed/data/NestedProfile.schema.ts +++ b/example/tests/fixtures/expected/seed/data/NestedProfile.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { nestedProfile } from "../../../../../tailordb/nested"; const schemaType = t.object({ - ...nestedProfile.pickFields(["id","createdAt"], { optional: true }), - ...nestedProfile.omitFields(["id","createdAt"]), + ...nestedProfile.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...nestedProfile.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(nestedProfile); diff --git a/example/tests/fixtures/expected/seed/data/PurchaseOrder.schema.ts b/example/tests/fixtures/expected/seed/data/PurchaseOrder.schema.ts index 4e4f269a49..c733271327 100644 --- a/example/tests/fixtures/expected/seed/data/PurchaseOrder.schema.ts +++ b/example/tests/fixtures/expected/seed/data/PurchaseOrder.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { purchaseOrder } from "../../../../../tailordb/purchaseOrder"; const schemaType = t.object({ - ...purchaseOrder.pickFields(["id","createdAt"], { optional: true }), - ...purchaseOrder.omitFields(["id","createdAt"]), + ...purchaseOrder.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...purchaseOrder.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(purchaseOrder); diff --git a/example/tests/fixtures/expected/seed/data/SalesOrder.schema.ts b/example/tests/fixtures/expected/seed/data/SalesOrder.schema.ts index db0543332a..37a210130c 100644 --- a/example/tests/fixtures/expected/seed/data/SalesOrder.schema.ts +++ b/example/tests/fixtures/expected/seed/data/SalesOrder.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { salesOrder } from "../../../../../tailordb/salesOrder"; const schemaType = t.object({ - ...salesOrder.pickFields(["id","createdAt"], { optional: true }), - ...salesOrder.omitFields(["id","createdAt"]), + ...salesOrder.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...salesOrder.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(salesOrder); diff --git a/example/tests/fixtures/expected/seed/data/Supplier.schema.ts b/example/tests/fixtures/expected/seed/data/Supplier.schema.ts index 3fb6af855a..db46278031 100644 --- a/example/tests/fixtures/expected/seed/data/Supplier.schema.ts +++ b/example/tests/fixtures/expected/seed/data/Supplier.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { supplier } from "../../../../../tailordb/supplier"; const schemaType = t.object({ - ...supplier.pickFields(["id","createdAt"], { optional: true }), - ...supplier.omitFields(["id","createdAt"]), + ...supplier.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...supplier.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(supplier); diff --git a/example/tests/fixtures/expected/seed/data/User.schema.ts b/example/tests/fixtures/expected/seed/data/User.schema.ts index 4c9d247078..21342b67d1 100644 --- a/example/tests/fixtures/expected/seed/data/User.schema.ts +++ b/example/tests/fixtures/expected/seed/data/User.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { user } from "../../../../../tailordb/user"; const schemaType = t.object({ - ...user.pickFields(["id","createdAt"], { optional: true }), - ...user.omitFields(["id","createdAt"]), + ...user.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...user.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(user); diff --git a/example/tests/fixtures/expected/seed/data/UserLog.schema.ts b/example/tests/fixtures/expected/seed/data/UserLog.schema.ts index fd3b3c52bc..556c81e96b 100644 --- a/example/tests/fixtures/expected/seed/data/UserLog.schema.ts +++ b/example/tests/fixtures/expected/seed/data/UserLog.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { userLog } from "../../../../../tailordb/userLog"; const schemaType = t.object({ - ...userLog.pickFields(["id","createdAt"], { optional: true }), - ...userLog.omitFields(["id","createdAt"]), + ...userLog.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...userLog.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(userLog); diff --git a/example/tests/fixtures/expected/seed/data/UserSetting.schema.ts b/example/tests/fixtures/expected/seed/data/UserSetting.schema.ts index d5f2fb052b..87fec2df1f 100644 --- a/example/tests/fixtures/expected/seed/data/UserSetting.schema.ts +++ b/example/tests/fixtures/expected/seed/data/UserSetting.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { userSetting } from "../../../../../tailordb/userSetting"; const schemaType = t.object({ - ...userSetting.pickFields(["id","createdAt"], { optional: true }), - ...userSetting.omitFields(["id","createdAt"]), + ...userSetting.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...userSetting.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(userSetting); diff --git a/example/tests/fixtures/expected/seed/exec.mjs b/example/tests/fixtures/expected/seed/exec.mjs index 0f47f91d95..b69257e4f7 100644 --- a/example/tests/fixtures/expected/seed/exec.mjs +++ b/example/tests/fixtures/expected/seed/exec.mjs @@ -307,7 +307,6 @@ const operatorClient = await initOperatorClient(accessToken); workspaceId, name: "truncate-idp-user.ts", code: idpTruncateCode, - arg: JSON.stringify({}), invoker: { namespace: authNamespace, machineUserName, @@ -501,7 +500,7 @@ const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) workspaceId, name: `seed-${namespace}.ts`, code: bundled.bundledCode, - arg: JSON.stringify({ data: chunk.data, order: chunk.order, selfRefTypes }), + arg: { data: chunk.data, order: chunk.order, selfRefTypes }, invoker: { namespace: authNamespace, machineUserName, @@ -596,7 +595,7 @@ const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) workspaceId, name: "seed-idp-user.ts", code: idpSeedCode, - arg: JSON.stringify({ users: rows }), + arg: { users: rows }, invoker: { namespace: authNamespace, machineUserName, diff --git a/example/tests/invoker.types.ts b/example/tests/invoker.types.ts new file mode 100644 index 0000000000..07b31daad5 --- /dev/null +++ b/example/tests/invoker.types.ts @@ -0,0 +1,17 @@ +// Type-level checks against the generated `tailor.d.ts`. Once `tailor +// generate` augments `MachineUserNameRegistry`, `MachineUserName` narrows to the +// registered machine user union for both SDK entries — `@tailor-platform/sdk` +// (resolver `invoker`) and `@tailor-platform/sdk/cli` (workflow-start +// `invoker`), which share the single `@tailor-platform/sdk` augmentation. +import type { MachineUserName as SdkMachineUserName } from "@tailor-platform/sdk"; +import type { MachineUserName as CliMachineUserName } from "@tailor-platform/sdk/cli"; + +const sdkInvoker: SdkMachineUserName = "manager-machine-user"; +const cliInvoker: CliMachineUserName = "manager-machine-user"; + +// @ts-expect-error - unknown machine user names are rejected once tailor.d.ts is generated +const unknownSdkInvoker: SdkMachineUserName = "unknown-machine-user"; +// @ts-expect-error - unknown machine user names are rejected once tailor.d.ts is generated +const unknownCliInvoker: CliMachineUserName = "unknown-machine-user"; + +export const invokerTypeChecks = [sdkInvoker, cliInvoker, unknownSdkInvoker, unknownCliInvoker]; diff --git a/example/tests/migration-fixtures/app/migrations/.gitkeep b/example/tests/migration-fixtures/app/migrations/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/example/tests/migration-fixtures/app/migrations/0000/schema.json b/example/tests/migration-fixtures/app/migrations/0000/schema.json new file mode 100644 index 0000000000..7dbfe28c24 --- /dev/null +++ b/example/tests/migration-fixtures/app/migrations/0000/schema.json @@ -0,0 +1,382 @@ +{ + "version": 1, + "namespace": "migrationdb", + "createdAt": "2026-06-24T04:43:34.470Z", + "types": { + "SalesOrder": { + "name": "SalesOrder", + "pluralForm": "SalesOrders", + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "customerID": { + "type": "uuid", + "required": true + }, + "status": { + "type": "string", + "required": false + }, + "totalPrice": { + "type": "integer", + "required": false + }, + "createdAt": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "updatedAt": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + "settings": {}, + "permissions": { + "record": { + "create": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "read": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "update": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "delete": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ] + }, + "gql": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "actions": ["create", "read", "update", "delete", "aggregate", "bulkUpsert"], + "permit": "allow" + } + ] + } + }, + "Supplier": { + "name": "Supplier", + "pluralForm": "Suppliers", + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": false + }, + "country": { + "type": "string", + "required": false + }, + "phone": { + "type": "string", + "required": true + }, + "state": { + "type": "enum", + "required": true, + "allowedValues": [ + { + "value": "Alabama" + }, + { + "value": "Alaska" + } + ] + }, + "city": { + "type": "string", + "required": true + }, + "createdAt": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "updatedAt": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + "settings": {}, + "permissions": { + "record": { + "create": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "read": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "update": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "delete": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ] + }, + "gql": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "actions": ["create", "read", "update", "delete", "aggregate", "bulkUpsert"], + "permit": "allow" + } + ] + } + }, + "User": { + "name": "User", + "pluralForm": "Users", + "fields": { + "id": { + "type": "uuid", + "required": true + }, + "name": { + "type": "string", + "required": true + }, + "email": { + "type": "string", + "required": true + }, + "createdAt": { + "type": "datetime", + "required": true, + "description": "Record creation timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + }, + "updatedAt": { + "type": "datetime", + "required": true, + "description": "Record update timestamp", + "hooks": { + "create": { + "expr": "(({ value }) => value ?? /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + }, + "update": { + "expr": "(() => /* @__PURE__ */ new Date())({ value: _value, data: _data, invoker: (($raw) => {\n if (!$raw) {\n return null;\n }\n const type = $raw?.type === \"USER_TYPE_USER\"\n ? \"user\"\n : $raw?.type === \"USER_TYPE_MACHINE_USER\"\n ? \"machine_user\"\n : $raw?.type;\n const id = $raw.id;\n if (!type || type === \"USER_TYPE_UNSPECIFIED\" || id === \"00000000-0000-0000-0000-000000000000\") {\n return null;\n }\n return {\n id,\n type,\n workspaceId: $raw.workspace_id ?? $raw.workspaceId,\n attributes: $raw.attribute_map ?? $raw.attributeMap ?? {},\n attributeList: $raw.attributes ?? [],\n };\n})(user) })" + } + } + } + }, + "settings": {}, + "permissions": { + "record": { + "create": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "read": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "update": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ], + "delete": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "permit": "allow" + } + ] + }, + "gql": [ + { + "conditions": [ + [ + { + "user": "role" + }, + "eq", + "MANAGER" + ] + ], + "actions": ["create", "read", "update", "delete", "aggregate", "bulkUpsert"], + "permit": "allow" + } + ] + } + } + } +} diff --git a/example/tests/migration-fixtures/app/tailordb/.gitkeep b/example/tests/migration-fixtures/app/tailordb/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/example/tests/migration-fixtures/app/tailordb/salesOrder.ts b/example/tests/migration-fixtures/app/tailordb/salesOrder.ts new file mode 100644 index 0000000000..3f6891755f --- /dev/null +++ b/example/tests/migration-fixtures/app/tailordb/salesOrder.ts @@ -0,0 +1,12 @@ +import { db } from "@tailor-platform/sdk"; +import { defaultGqlPermission, defaultPermission } from "../permissions"; + +export const salesOrder = db + .type("SalesOrder", { + customerID: db.uuid(), + status: db.string({ optional: true }), + totalPrice: db.int({ optional: true }), + ...db.fields.timestamps(), + }) + .permission(defaultPermission) + .gqlPermission(defaultGqlPermission); diff --git a/example/tests/migration-fixtures/app/tailordb/supplier.ts b/example/tests/migration-fixtures/app/tailordb/supplier.ts new file mode 100644 index 0000000000..ba1019ca95 --- /dev/null +++ b/example/tests/migration-fixtures/app/tailordb/supplier.ts @@ -0,0 +1,14 @@ +import { db } from "@tailor-platform/sdk"; +import { defaultGqlPermission, defaultPermission } from "../permissions"; + +export const supplier = db + .type("Supplier", { + name: db.string({ optional: true }), + country: db.string({ optional: true }), + phone: db.string(), + state: db.enum(["Alabama", "Alaska"]), + city: db.string(), + ...db.fields.timestamps(), + }) + .permission(defaultPermission) + .gqlPermission(defaultGqlPermission); diff --git a/example/tests/migration-fixtures/app/tailordb/user.ts b/example/tests/migration-fixtures/app/tailordb/user.ts new file mode 100644 index 0000000000..d70b9ade49 --- /dev/null +++ b/example/tests/migration-fixtures/app/tailordb/user.ts @@ -0,0 +1,11 @@ +import { db } from "@tailor-platform/sdk"; +import { defaultGqlPermission, defaultPermission } from "../permissions"; + +export const user = db + .type("User", { + name: db.string(), + email: db.string(), + ...db.fields.timestamps(), + }) + .permission(defaultPermission) + .gqlPermission(defaultGqlPermission); diff --git a/example/tests/scripts/generate_files.ts b/example/tests/scripts/generate_files.ts index fb29fe3710..668d67a435 100644 --- a/example/tests/scripts/generate_files.ts +++ b/example/tests/scripts/generate_files.ts @@ -6,7 +6,7 @@ import { generate, deploy } from "@tailor-platform/sdk/cli"; // Disable inline sourcemaps during test fixture generation so that snapshot // comparisons remain stable across environments. -process.env.TAILOR_ENABLE_INLINE_SOURCEMAP ??= "false"; +process.env.TAILOR_INLINE_SOURCEMAP ??= "false"; const __filename = url.fileURLToPath(import.meta.url); @@ -52,7 +52,7 @@ export async function generateExpectedFiles(): Promise { console.log("Removed existing expected directory"); } - process.env.TAILOR_SDK_OUTPUT_DIR = expectedDir; + process.env.TAILOR_BUILD_OUTPUT_DIR = expectedDir; await generate({ configPath: "./tests/tailor.config.expected.ts", }); @@ -92,7 +92,6 @@ async function listGeneratedFiles(dirPath: string, depth = 0, maxDepth = 3): Pro } } -const generatorsCompatDir = "tests/fixtures/generators"; const pluginsCompatDir = "tests/fixtures/plugins"; function bundledScriptFileName(kind: string, name: string): string { @@ -103,14 +102,13 @@ function bundledScriptFileName(kind: string, name: string): string { } export async function generateCompatFiles(): Promise { - for (const dir of [generatorsCompatDir, pluginsCompatDir]) { + for (const dir of [pluginsCompatDir]) { if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true }); } - await generate({ configPath: "./tests/tailor.config.generators-compat.ts" }); await generate({ configPath: "./tests/tailor.config.plugins-compat.ts" }); // Also run deploy --buildOnly for plugins-compat (used by bundled_execution tests) - process.env.TAILOR_SDK_OUTPUT_DIR = pluginsCompatDir; + process.env.TAILOR_BUILD_OUTPUT_DIR = pluginsCompatDir; const result = await deploy({ configPath: "./tests/tailor.config.plugins-compat.ts", buildOnly: true, diff --git a/example/tests/scripts/migration_e2e.ts b/example/tests/scripts/migration_e2e.ts index 0b98c22262..5fb48ac005 100644 --- a/example/tests/scripts/migration_e2e.ts +++ b/example/tests/scripts/migration_e2e.ts @@ -14,6 +14,8 @@ import { } from "@tailor-platform/sdk/cli"; import { AuthInvokerSchema } from "@tailor-platform/tailor-proto/auth_resource_pb"; +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const exampleDir = path.resolve(scriptDir, "..", ".."); const fixtureRoot = path.resolve(exampleDir, "tests", "migration-fixtures"); @@ -27,15 +29,15 @@ const templateMigrationsDir = path.resolve(fixtureRoot, "templates"); const namespace = "migrationdb"; const machineUserName = "manager-machine-user"; -const tailorSdkBin = path.resolve( +const tailorBin = path.resolve( exampleDir, "node_modules", ".bin", - process.platform === "win32" ? "tailor-sdk.cmd" : "tailor-sdk", + process.platform === "win32" ? "tailor.cmd" : "tailor", ); -const runTailorSdk = (args: string[]) => { - execFileSync(tailorSdkBin, args, { +const runTailor = (args: string[]) => { + execFileSync(tailorBin, args, { cwd: appDir, env: { ...process.env, @@ -45,11 +47,11 @@ const runTailorSdk = (args: string[]) => { }; const runDeploy = () => { - runTailorSdk(["deploy", "-c", configPath, "--yes"]); + runTailor(["deploy", "-c", configPath, "--yes"]); }; const runMigrateGenerate = () => { - runTailorSdk(["tailordb", "migration", "generate", "-c", configPath, "--yes"]); + runTailor(["tailordb", "migration", "generate", "-c", configPath, "--yes"]); }; const resetMigrations = () => { @@ -270,7 +272,7 @@ const seedData = async ( workspaceId, name: `${label}.js`, code: bundled.bundledCode, - arg: JSON.stringify({ data, order }), + arg: { data, order } as unknown as JsonValue, invoker, }); if (!result.success) { diff --git a/example/tests/tailor.config.generators-compat.ts b/example/tests/tailor.config.generators-compat.ts deleted file mode 100644 index 36a32aec5f..0000000000 --- a/example/tests/tailor.config.generators-compat.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineGenerators } from "@tailor-platform/sdk"; -import config, { auth } from "../tailor.config"; -export default config; -export { auth }; -export const generators = defineGenerators( - ["@tailor-platform/kysely-type", { distPath: "./tests/fixtures/generators/db.ts" }], - ["@tailor-platform/enum-constants", { distPath: "./tests/fixtures/generators/enums.ts" }], - ["@tailor-platform/file-utils", { distPath: "./tests/fixtures/generators/files.ts" }], - [ - "@tailor-platform/seed", - { distPath: "./tests/fixtures/generators/seed", machineUserName: "manager-machine-user" }, - ], -); diff --git a/example/vitest.config.ts b/example/vitest.config.ts index d9abe067b7..3d48ee0a1a 100644 --- a/example/vitest.config.ts +++ b/example/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ // Disable inline sourcemaps during tests to keep bundled output stable // for size and fixture comparisons. env: { - TAILOR_ENABLE_INLINE_SOURCEMAP: "false", + TAILOR_INLINE_SOURCEMAP: "false", }, projects: [ { diff --git a/example/workflows/approval.ts b/example/workflows/approval.ts index 6c113e77c0..5266b45fd5 100644 --- a/example/workflows/approval.ts +++ b/example/workflows/approval.ts @@ -1,6 +1,6 @@ -import { createWorkflow, createWorkflowJob, defineWaitPoints } from "@tailor-platform/sdk"; +import { createWaitPoints, createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; -export const { approval } = defineWaitPoints((define) => ({ +export const { approval } = createWaitPoints((define) => ({ /** Approval for order processing */ approval: define<{ message: string; requestId: string }, { approved: boolean }>(), })); diff --git a/example/workflows/jobs/fetch-customer.ts b/example/workflows/jobs/fetch-customer.ts index 98bd60bf04..844ce069aa 100644 --- a/example/workflows/jobs/fetch-customer.ts +++ b/example/workflows/jobs/fetch-customer.ts @@ -1,9 +1,14 @@ import { createWorkflowJob } from "@tailor-platform/sdk"; import { getDB } from "../../generated/tailordb"; +import type { DateTimeString, UUIDString } from "@tailor-platform/sdk"; + +function serializeDateTime(value: Date | DateTimeString): string { + return value instanceof Date ? value.toISOString() : value; +} export const fetchCustomer = createWorkflowJob({ name: "fetch-customer", - body: async (input: { customerId: string }) => { + body: async (input: { customerId: UUIDString }) => { const db = getDB("tailordb"); const customer = await db .selectFrom("Customer") @@ -13,8 +18,8 @@ export const fetchCustomer = createWorkflowJob({ if (!customer) return undefined; return { ...customer, - createdAt: customer.createdAt.toISOString(), - updatedAt: customer.updatedAt?.toISOString() ?? null, + createdAt: serializeDateTime(customer.createdAt), + updatedAt: serializeDateTime(customer.updatedAt), }; }, }); diff --git a/example/workflows/order-processing.ts b/example/workflows/order-processing.ts index da0d512b2f..567caa6dd5 100644 --- a/example/workflows/order-processing.ts +++ b/example/workflows/order-processing.ts @@ -1,17 +1,18 @@ import { createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; import { fetchCustomer } from "./jobs/fetch-customer"; import { sendNotification } from "./jobs/send-notification"; +import type { UUIDString } from "@tailor-platform/sdk"; // Note: We're NOT importing generateReport and archiveData // Those jobs should be completely excluded from the bundle export const processOrder = createWorkflowJob({ name: "process-order", - body: async (input: { orderId: string; customerId: string }, { env }) => { + body: (input: { orderId: string; customerId: UUIDString }, { env }) => { // Log env for demonstration console.log("Environment:", env); // Fetch customer information using trigger - const customer = await fetchCustomer.trigger({ + const customer = fetchCustomer.trigger({ customerId: input.customerId, }); @@ -20,7 +21,7 @@ export const processOrder = createWorkflowJob({ } // Send notification to customer using trigger - const notification = await sendNotification.trigger({ + const notification = sendNotification.trigger({ message: `Your order ${input.orderId} is being processed`, recipient: customer.email, }); diff --git a/example/workflows/sample.ts b/example/workflows/sample.ts index 49956d92e0..8033ecc024 100644 --- a/example/workflows/sample.ts +++ b/example/workflows/sample.ts @@ -21,10 +21,10 @@ export const check_inventory = createWorkflowJob({ export const validate_order = createWorkflowJob({ name: "validate-order", - body: async (input: { orderId: string }) => { + body: (input: { orderId: string }) => { console.log("Order ID:", input.orderId); - const inventoryResult = await check_inventory.trigger(); - const paymentResult = await process_payment.trigger(); + const inventoryResult = check_inventory.trigger(); + const paymentResult = process_payment.trigger(); return { inventoryResult, paymentResult }; }, }); diff --git a/lefthook.yml b/lefthook.yml index 004b68c5b5..454d4752aa 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -19,7 +19,7 @@ pre-commit: - "pnpm-debug.log*" - "lerna-debug.log*" - "coverage/**" - - ".tailor-sdk/**" + - ".tailor/**" - "example/tests/fixtures/**" - "example/generated/**" - "example/seed/**" diff --git a/llm-challenge/.oxlintrc.json b/llm-challenge/.oxlintrc.json index c3ba83401f..065b8506d9 100644 --- a/llm-challenge/.oxlintrc.json +++ b/llm-challenge/.oxlintrc.json @@ -2,6 +2,10 @@ "$schema": "./node_modules/oxlint/configuration_schema.json", "rules": { "unicorn/no-array-reverse": "error", - "unicorn/no-array-sort": "error" - } + "unicorn/no-array-sort": "error", + "tailor-zod/require-object-policy-comment": "error", + "zod/prefer-loose-object": "error", + "zod/prefer-strict-object": "error" + }, + "jsPlugins": ["eslint-plugin-zod", "../scripts/oxlint/tailor-zod-plugin.cjs"] } diff --git a/llm-challenge/package.json b/llm-challenge/package.json index 26cfc3e303..5fd59390e3 100644 --- a/llm-challenge/package.json +++ b/llm-challenge/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@types/node": "24.13.2", + "eslint-plugin-zod": "4.7.0", "oxlint": "1.72.0", "oxlint-tsgolint": "0.24.0", "tsx": "4.22.5", @@ -19,6 +20,6 @@ "vitest": "4.1.9" }, "engines": { - "node": ">= 22.14.0" + "node": ">= 22.15.0" } } diff --git a/llm-challenge/problems/cli/generate/prompt.md b/llm-challenge/problems/cli/generate/prompt.md index 4a2b3852cb..66b266b5ef 100644 --- a/llm-challenge/problems/cli/generate/prompt.md +++ b/llm-challenge/problems/cli/generate/prompt.md @@ -1,4 +1,4 @@ -Use the local `tailor-sdk` binary available after installing dependencies. +Use the local `tailor` binary available after installing dependencies. Set up a minimal Tailor SDK project for a task list application, then use the CLI to produce the generated project artifacts. Do not rely on a globally installed SDK. diff --git a/llm-challenge/problems/cli/help-error-recovery/prompt.md b/llm-challenge/problems/cli/help-error-recovery/prompt.md index 51b2ed7988..54692179a4 100644 --- a/llm-challenge/problems/cli/help-error-recovery/prompt.md +++ b/llm-challenge/problems/cli/help-error-recovery/prompt.md @@ -1,4 +1,4 @@ -Use the local `tailor-sdk` binary available after installing dependencies. +Use the local `tailor` binary available after installing dependencies. Start by using the CLI help and error output to discover how to perform a simple project maintenance action. Then run the corrected command and leave a short note in the workspace describing what you ran and why. diff --git a/llm-challenge/problems/cli/help-error-recovery/verify.json b/llm-challenge/problems/cli/help-error-recovery/verify.json index e2360bb18d..a6538fb54c 100644 --- a/llm-challenge/problems/cli/help-error-recovery/verify.json +++ b/llm-challenge/problems/cli/help-error-recovery/verify.json @@ -12,7 +12,7 @@ "id": "note-names-local-cli", "kind": "content-match", "glob": "**/*.md", - "pattern": "tailor-sdk|pnpm", + "pattern": "tailor|pnpm", "flags": "i", "description": "maintenance note names the local CLI command path" } diff --git a/llm-challenge/problems/cli/tailordb-migrate-generate/prompt.md b/llm-challenge/problems/cli/tailordb-migrate-generate/prompt.md index 98b7366b61..96d083d687 100644 --- a/llm-challenge/problems/cli/tailordb-migrate-generate/prompt.md +++ b/llm-challenge/problems/cli/tailordb-migrate-generate/prompt.md @@ -1,4 +1,4 @@ -Use the local `tailor-sdk` binary available after installing dependencies. +Use the local `tailor` binary available after installing dependencies. Create a minimal TailorDB model change for a customer directory, then use the CLI to create the migration artifacts needed for that change. Discover the necessary command shape from the CLI itself rather than assuming a global tool. diff --git a/llm-challenge/problems/cli/tailordb-migrate-script/prompt.md b/llm-challenge/problems/cli/tailordb-migrate-script/prompt.md index 655b902d48..ba90038352 100644 --- a/llm-challenge/problems/cli/tailordb-migrate-script/prompt.md +++ b/llm-challenge/problems/cli/tailordb-migrate-script/prompt.md @@ -1,4 +1,4 @@ -Use the local `tailor-sdk` binary available after installing dependencies. +Use the local `tailor` binary available after installing dependencies. Create a minimal TailorDB schema evolution for an inventory item and use the CLI to produce the executable migration script for it. Discover the needed command shape from local help and project files. diff --git a/llm-challenge/src/challenge.test.ts b/llm-challenge/src/challenge.test.ts index 3ca2615e1f..3149a00f10 100644 --- a/llm-challenge/src/challenge.test.ts +++ b/llm-challenge/src/challenge.test.ts @@ -298,12 +298,12 @@ describe("artifact summary", () => { await fs.mkdir(path.join(worktreePath, "src"), { recursive: true }); await fs.mkdir(path.join(worktreePath, "node_modules/pkg"), { recursive: true }); await fs.mkdir(path.join(worktreePath, ".pnpm-home/store"), { recursive: true }); - await fs.mkdir(path.join(worktreePath, ".tailor-sdk/cache"), { recursive: true }); + await fs.mkdir(path.join(worktreePath, ".tailor/cache"), { recursive: true }); await fs.mkdir(path.join(worktreePath, ".turbo/cache"), { recursive: true }); await fs.writeFile(path.join(worktreePath, "src/app.ts"), "export {};\n"); await fs.writeFile(path.join(worktreePath, "node_modules/pkg/index.js"), ""); await fs.writeFile(path.join(worktreePath, ".pnpm-home/store/index.db"), ""); - await fs.writeFile(path.join(worktreePath, ".tailor-sdk/cache/generated.json"), "{}"); + await fs.writeFile(path.join(worktreePath, ".tailor/cache/generated.json"), "{}"); await fs.writeFile(path.join(worktreePath, ".turbo/cache/state.json"), "{}"); await runCommand("git", ["init"], { cwd: worktreePath }); @@ -373,7 +373,7 @@ describe("artifact summary", () => { expect(summary.files).toContain("src/app.ts"); expect(summary.files).not.toContain("node_modules/pkg/index.js"); expect(summary.files).not.toContain(".pnpm-home/store/index.db"); - expect(summary.files).not.toContain(".tailor-sdk/cache/generated.json"); + expect(summary.files).not.toContain(".tailor/cache/generated.json"); expect(summary.files).not.toContain(".turbo/cache/state.json"); expect(summary.gitStatus).toContain("?? src/app.ts"); expect(summary.commands.map((command) => command.command)).toEqual([ @@ -583,9 +583,9 @@ describe("verification summary", () => { const problemRoot = path.join(dir, "problem"); const worktreePath = path.join(dir, "work"); await fs.mkdir(path.join(problemRoot, "scaffold"), { recursive: true }); - await fs.mkdir(path.join(worktreePath, ".tailor-sdk/cache"), { recursive: true }); + await fs.mkdir(path.join(worktreePath, ".tailor/cache"), { recursive: true }); await fs.writeFile(path.join(worktreePath, "package.json"), "{}\n"); - await fs.writeFile(path.join(worktreePath, ".tailor-sdk/cache/generated.ts"), "cacheOnly\n"); + await fs.writeFile(path.join(worktreePath, ".tailor/cache/generated.ts"), "cacheOnly\n"); const verifyPath = path.join(problemRoot, "verify.json"); await fs.writeFile( verifyPath, @@ -717,7 +717,7 @@ describe("workspace preparation", () => { const dir = await makeTempDir(); const worktreePath = path.join(dir, "work"); await Promise.all( - ["node_modules", ".pnpm-store", ".pnpm-home", ".cache", ".turbo", ".tailor-sdk/cache"].map( + ["node_modules", ".pnpm-store", ".pnpm-home", ".cache", ".turbo", ".tailor/cache"].map( (name) => fs.mkdir(path.join(worktreePath, name), { recursive: true }), ), ); @@ -730,7 +730,7 @@ describe("workspace preparation", () => { ".pnpm-home", ".cache", ".turbo", - ".tailor-sdk/cache", + ".tailor/cache", ]) { await expect(fs.access(path.join(worktreePath, name))).rejects.toThrow("ENOENT"); } diff --git a/llm-challenge/src/workspace-files.ts b/llm-challenge/src/workspace-files.ts index 1d5725f037..b70631e59c 100644 --- a/llm-challenge/src/workspace-files.ts +++ b/llm-challenge/src/workspace-files.ts @@ -11,7 +11,7 @@ const EXCLUDED_DIRS = new Set([ ".turbo", "node_modules", ]); -const EXCLUDED_PATHS = new Set([".tailor-sdk/cache"]); +const EXCLUDED_PATHS = new Set([".tailor/cache"]); /** * Recursively list workspace files as posix-style paths relative to diff --git a/llm-challenge/src/workspace.ts b/llm-challenge/src/workspace.ts index c050be820b..f575e7ad1b 100644 --- a/llm-challenge/src/workspace.ts +++ b/llm-challenge/src/workspace.ts @@ -43,7 +43,7 @@ const GITIGNORE_PATTERNS = [ ".pnpm-store/", ".pnpm-home/", ".cache/", - ".tailor-sdk/cache/", + ".tailor/cache/", ]; export async function prepareWorkspace(options: { @@ -82,8 +82,8 @@ export function profileForProblem( export async function pruneWorkspaceDeps(worktreePath: string): Promise { await Promise.all( - ["node_modules", ".pnpm-store", ".pnpm-home", ".cache", ".turbo", ".tailor-sdk/cache"].map( - (name) => fs.rm(path.join(worktreePath, name), { recursive: true, force: true }), + ["node_modules", ".pnpm-store", ".pnpm-home", ".cache", ".turbo", ".tailor/cache"].map((name) => + fs.rm(path.join(worktreePath, name), { recursive: true, force: true }), ), ); } diff --git a/oxlint.vitest.json b/oxlint.vitest.json index 8fcd798ab5..1edb5b8122 100644 --- a/oxlint.vitest.json +++ b/oxlint.vitest.json @@ -9,7 +9,7 @@ "style": "off", "restriction": "off" }, - "ignorePatterns": ["**/node_modules/**", "**/dist/**", "**/.agent/**", "**/.tailor-sdk/**"], + "ignorePatterns": ["**/node_modules/**", "**/dist/**", "**/.agent/**", "**/.tailor/**"], "rules": { "vitest/consistent-each-for": "error", "vitest/consistent-test-it": [ diff --git a/package.json b/package.json index ad3efc0c24..05284188a6 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,11 @@ "deploy": "pnpm run build && pnpm -r run deploy", "agent:rules:update": "node scripts/sync-agent-rules.mjs --write", "agent:rules:check": "node scripts/sync-agent-rules.mjs --check", + "codemod:docs:update": "tsx packages/sdk-codemod/scripts/sync-codemod-docs.ts --write", + "codemod:docs:check": "tsx packages/sdk-codemod/scripts/sync-codemod-docs.ts --check", "check": "pnpm run build && pnpm --filter @tailor-platform/sdk --filter example run generate && pnpm run format && pnpm run \"/^check:/\"", "check:agent-rules": "pnpm run agent:rules:check", + "check:codemod-docs": "pnpm run codemod:docs:check", "check:lint": "pnpm -r run lint:fix && pnpm run lint:vitest", "check:typecheck": "pnpm -r run typecheck:go", "check:knip": "pnpm -r run knip", @@ -42,6 +45,7 @@ "@changesets/cli": "3.0.0-next.8", "@tailor-platform/sdk": "workspace:^", "@types/node": "24.13.2", + "eslint-plugin-zod": "4.7.0", "knip": "6.24.0", "lefthook": "2.1.9", "oxfmt": "0.57.0", @@ -52,7 +56,7 @@ "typescript": "6.0.3" }, "engines": { - "node": ">= 22.14.0" + "node": ">= 22.15.0" }, "packageManager": "pnpm@11.9.0" } diff --git a/packages/create-sdk/.oxlintrc.json b/packages/create-sdk/.oxlintrc.json index fdce5f9cf6..b39d54bbd5 100644 --- a/packages/create-sdk/.oxlintrc.json +++ b/packages/create-sdk/.oxlintrc.json @@ -63,7 +63,10 @@ "typescript/prefer-includes": "error", "typescript/prefer-nullish-coalescing": "error", "typescript/prefer-regexp-exec": "error", - "typescript/prefer-string-starts-ends-with": "error" + "typescript/prefer-string-starts-ends-with": "error", + "tailor-zod/require-object-policy-comment": "error", + "zod/prefer-loose-object": "error", + "zod/prefer-strict-object": "error" }, "overrides": [ { @@ -156,5 +159,6 @@ "node": true } } - ] + ], + "jsPlugins": ["eslint-plugin-zod", "../../scripts/oxlint/tailor-zod-plugin.cjs"] } diff --git a/packages/create-sdk/CHANGELOG.md b/packages/create-sdk/CHANGELOG.md index decf97712f..d2dbc6164b 100644 --- a/packages/create-sdk/CHANGELOG.md +++ b/packages/create-sdk/CHANGELOG.md @@ -1,5 +1,37 @@ # @tailor-platform/create-sdk +## 2.0.0-next.2 +### Major Changes + + + +- [#1498](https://github.com/tailor-platform/sdk/pull/1498) [`83145db`](https://github.com/tailor-platform/sdk/commit/83145db9a0d243aa68c1b641c2b6026771a62188) Thanks [@dqn](https://github.com/dqn)! - Set `db.fields.timestamps()` `updatedAt` when records are created and make the generated field non-null. `createdAt` keeps its existing create-time behavior, while `updatedAt` keeps its update-time behavior and now also gets a create hook that preserves provided values and falls back to the current time. + + Update create-sdk templates so scaffolded projects use the new non-null `updatedAt` Kysely types and seed schemas. + + Existing TailorDB schemas that already use this helper will change `updatedAt` from optional to required. Backfill existing records that have `updatedAt: null` before applying the schema change. + +### Patch Changes + + + +- [#1509](https://github.com/tailor-platform/sdk/pull/1509) [`7cadaa7`](https://github.com/tailor-platform/sdk/commit/7cadaa7c4987b81130ca80ba80bc5d5b26276394) Thanks [@dqn](https://github.com/dqn)! - Rename resolver, executor, workflow trigger, and typed workflow start machine-user options from `authInvoker` to `invoker`. + + Update create-sdk templates and the v2 auth invoker codemod to generate the new `invoker` option. + + +- [#1484](https://github.com/tailor-platform/sdk/pull/1484) [`a376dc8`](https://github.com/tailor-platform/sdk/commit/a376dc8cd053d20744c90104e8b44ed2729ffe8c) Thanks [@dqn](https://github.com/dqn)! - Remove the deprecated `openDownloadStream` file streaming API. Use `downloadStream` for streamed file downloads. + + The generated file utilities now emit `downloadFileStream`, which calls `downloadStream` and returns `FileDownloadStreamResponse`, instead of the removed `openFileDownloadStream` helper. + +## 2.0.0-next.1 + +## 2.0.0-next.0 + +## 1.66.0 + +## 1.71.0 + ## 1.74.0 ## 1.73.3 @@ -12,8 +44,6 @@ ## 1.72.0 -## 1.71.0 - ## 1.70.1 ### Patch Changes @@ -39,7 +69,6 @@ ## 1.66.1 ## 1.66.0 - ## 1.65.0 ## 1.64.0 diff --git a/packages/create-sdk/package.json b/packages/create-sdk/package.json index 1b59eedbbf..d3521bb13c 100644 --- a/packages/create-sdk/package.json +++ b/packages/create-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@tailor-platform/create-sdk", - "version": "1.74.0", + "version": "2.0.0-next.2", "description": "A CLI tool to quickly create a new Tailor Platform SDK project", "license": "MIT", "repository": { @@ -35,6 +35,7 @@ }, "devDependencies": { "@types/node": "24.13.2", + "eslint-plugin-zod": "4.7.0", "oxlint": "1.72.0", "oxlint-tsgolint": "0.24.0", "tsdown": "0.22.3", diff --git a/packages/create-sdk/scripts/prepare-templates.js b/packages/create-sdk/scripts/prepare-templates.js index 92bab0dd96..1c5dd7574b 100644 --- a/packages/create-sdk/scripts/prepare-templates.js +++ b/packages/create-sdk/scripts/prepare-templates.js @@ -4,11 +4,11 @@ import { readFileSync, writeFileSync, readdirSync, existsSync, copyFileSync } fr import { resolve } from "node:path"; // Get SDK version or URL from environment variable or package.json -const sdkVersionOrUrl = process.env.TAILOR_SDK_VERSION; +const sdkVersionOrUrl = process.env.TAILOR_TEMPLATE_SDK_VERSION; let version; if (sdkVersionOrUrl) { - // If TAILOR_SDK_VERSION is set, use it (can be version string or pkg-pr-new URL) + // If TAILOR_TEMPLATE_SDK_VERSION is set, use it (can be version string or pkg-pr-new URL) version = sdkVersionOrUrl; console.log(`Using SDK version from environment: ${version}`); } else { diff --git a/packages/create-sdk/src/context.ts b/packages/create-sdk/src/context.ts index 5cd54d5c6d..c969736777 100644 --- a/packages/create-sdk/src/context.ts +++ b/packages/create-sdk/src/context.ts @@ -31,7 +31,7 @@ const templateHints: Record = { workflow: "Workflow patterns with job chaining and testing", executor: "Executor trigger types (record, resolver, schedule, webhook)", "static-web-site": "Static website with auth and IdP integration", - generators: "Built-in generators: kysely, enums, files, seed", + generators: "Built-in generation plugins: kysely, enums, files, seed", }; const validateName = (name: string | undefined) => { diff --git a/packages/create-sdk/src/index.ts b/packages/create-sdk/src/index.ts index 6e9fbacdea..53d9e73cb8 100644 --- a/packages/create-sdk/src/index.ts +++ b/packages/create-sdk/src/index.ts @@ -15,6 +15,7 @@ const main = async () => { const cmd = defineCommand({ name: packageJson.name ?? "create-sdk", description: packageJson.description, + // strip unknown keys args: z.object({ name: arg(z.string().optional(), { positional: true, diff --git a/packages/create-sdk/templates/executor/.gitignore b/packages/create-sdk/templates/executor/.gitignore index d04123787b..f3b2377c83 100644 --- a/packages/create-sdk/templates/executor/.gitignore +++ b/packages/create-sdk/templates/executor/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/executor/.oxlintrc.json b/packages/create-sdk/templates/executor/.oxlintrc.json index 537e488b88..46b42a60a3 100644 --- a/packages/create-sdk/templates/executor/.oxlintrc.json +++ b/packages/create-sdk/templates/executor/.oxlintrc.json @@ -7,7 +7,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "tailor.d.ts"], "rules": { "no-case-declarations": "error", "no-empty": "error", diff --git a/packages/create-sdk/templates/executor/package.json b/packages/create-sdk/templates/executor/package.json index 8af512c2c0..79d14a3b45 100644 --- a/packages/create-sdk/templates/executor/package.json +++ b/packages/create-sdk/templates/executor/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "generate": "tailor-sdk generate", - "deploy": "tailor-sdk deploy", + "generate": "tailor generate", + "deploy": "tailor deploy", "test": "vitest --project unit", "test:unit": "vitest --project unit", "format": "oxfmt --write .", diff --git a/packages/create-sdk/templates/executor/src/executor/onUserCreated.test.ts b/packages/create-sdk/templates/executor/src/executor/onUserCreated.test.ts index 22d1113fcb..80d12b2bd4 100644 --- a/packages/create-sdk/templates/executor/src/executor/onUserCreated.test.ts +++ b/packages/create-sdk/templates/executor/src/executor/onUserCreated.test.ts @@ -11,7 +11,7 @@ describe("onUserCreated executor", () => { } await onUserCreated.operation.body({ newRecord: { - id: "user-1", + id: "11111111-1111-4111-8111-111111111111", name: "Alice", email: "alice@example.com", role: "ADMIN", @@ -23,7 +23,7 @@ describe("onUserCreated executor", () => { expect(createAuditLog).toHaveBeenCalledExactlyOnceWith({ action: "USER_CREATED", entityType: "User", - entityId: "user-1", + entityId: "11111111-1111-4111-8111-111111111111", message: "Admin user created: Alice (alice@example.com)", }); }); diff --git a/packages/create-sdk/templates/executor/src/executor/shared.test.ts b/packages/create-sdk/templates/executor/src/executor/shared.test.ts index b56c0fce87..c7a70ab980 100644 --- a/packages/create-sdk/templates/executor/src/executor/shared.test.ts +++ b/packages/create-sdk/templates/executor/src/executor/shared.test.ts @@ -8,7 +8,7 @@ describe("createAuditLog", () => { await createAuditLog({ action: "USER_CREATED", entityType: "User", - entityId: "test-id", + entityId: "123e4567-e89b-12d3-a456-426614174000", message: "Test audit log", }); diff --git a/packages/create-sdk/templates/executor/src/executor/shared.ts b/packages/create-sdk/templates/executor/src/executor/shared.ts index 0483a944b8..d8e5abe1ba 100644 --- a/packages/create-sdk/templates/executor/src/executor/shared.ts +++ b/packages/create-sdk/templates/executor/src/executor/shared.ts @@ -1,9 +1,10 @@ import { getDB } from "../generated/db"; +import type { UUIDString } from "@tailor-platform/sdk"; interface AuditLogInput { action: string; entityType: string; - entityId: string; + entityId: UUIDString; message: string; } diff --git a/packages/create-sdk/templates/executor/src/generated/db.ts b/packages/create-sdk/templates/executor/src/generated/db.ts index 1bdcc0d1fa..ecce78ef04 100644 --- a/packages/create-sdk/templates/executor/src/generated/db.ts +++ b/packages/create-sdk/templates/executor/src/generated/db.ts @@ -1,6 +1,7 @@ import { createGetDB, type Generated, + type UUIDString, type Timestamp, type NamespaceDB, type NamespaceInsertable, @@ -14,32 +15,32 @@ import { export interface Namespace { "main-db": { AuditLog: { - id: Generated; + id: Generated; action: string; entityType: string; - entityId: string; + entityId: UUIDString; message: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Notification: { - id: Generated; - userId: string; + id: Generated; + userId: UUIDString; title: string; body: string; isRead: boolean; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { - id: Generated; + id: Generated; name: string; email: string; role: "ADMIN" | "MEMBER"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/executor/tailor.d.ts b/packages/create-sdk/templates/executor/tailor.d.ts index 84fb93e2dd..c01151438e 100644 --- a/packages/create-sdk/templates/executor/tailor.d.ts +++ b/packages/create-sdk/templates/executor/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: string; } interface AttributeList { diff --git a/packages/create-sdk/templates/generators/.gitignore b/packages/create-sdk/templates/generators/.gitignore index d04123787b..f3b2377c83 100644 --- a/packages/create-sdk/templates/generators/.gitignore +++ b/packages/create-sdk/templates/generators/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/generators/.oxlintrc.json b/packages/create-sdk/templates/generators/.oxlintrc.json index d195e83029..83f6e2b99c 100644 --- a/packages/create-sdk/templates/generators/.oxlintrc.json +++ b/packages/create-sdk/templates/generators/.oxlintrc.json @@ -7,7 +7,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "src/seed/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "src/seed/", "tailor.d.ts"], "rules": { "no-case-declarations": "error", "no-empty": "error", diff --git a/packages/create-sdk/templates/generators/package.json b/packages/create-sdk/templates/generators/package.json index 0a30ffdf58..45412eb934 100644 --- a/packages/create-sdk/templates/generators/package.json +++ b/packages/create-sdk/templates/generators/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "generate": "tailor-sdk generate", - "deploy": "tailor-sdk deploy", + "generate": "tailor generate", + "deploy": "tailor deploy", "test": "vitest --project unit", "test:unit": "vitest --project unit", "format": "oxfmt --write .", diff --git a/packages/create-sdk/templates/generators/src/generated/db.ts b/packages/create-sdk/templates/generators/src/generated/db.ts index 0da558bfe9..fb38144226 100644 --- a/packages/create-sdk/templates/generators/src/generated/db.ts +++ b/packages/create-sdk/templates/generators/src/generated/db.ts @@ -1,6 +1,7 @@ import { createGetDB, type Generated, + type UUIDString, type Timestamp, type NamespaceDB, type NamespaceInsertable, @@ -14,41 +15,41 @@ import { export interface Namespace { "main-db": { Category: { - id: Generated; + id: Generated; name: string; slug: string; - parentCategoryId: string | null; + parentCategoryId: UUIDString | null; } Order: { - id: Generated; - productId: string; - userId: string; + id: Generated; + productId: UUIDString; + userId: UUIDString; quantity: number; totalPrice: number; status: "PENDING" | "CONFIRMED" | "SHIPPED" | "DELIVERED" | "CANCELLED"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Product: { - id: Generated; + id: Generated; name: string; description: string | null; price: number; status: "DRAFT" | "ACTIVE" | "DISCONTINUED"; - categoryId: string | null; + categoryId: UUIDString | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { - id: Generated; + id: Generated; name: string; email: string; role: "ADMIN" | "MEMBER" | "VIEWER"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/generators/src/generated/files.ts b/packages/create-sdk/templates/generators/src/generated/files.ts index e7498a3177..a18d7ada15 100644 --- a/packages/create-sdk/templates/generators/src/generated/files.ts +++ b/packages/create-sdk/templates/generators/src/generated/files.ts @@ -3,7 +3,7 @@ import type { FileUploadOptions, FileUploadResponse, FileMetadata, - FileStreamIterator, + FileDownloadStreamResponse, } from "@tailor-platform/sdk/runtime/file"; export interface TypeWithFiles { @@ -50,10 +50,10 @@ export async function getFileMetadata( return await file.getMetadata(namespaces[type], type, field, recordId); } -export async function openFileDownloadStream( +export async function downloadFileStream( type: T, field: TypeWithFiles[T]["fields"], recordId: string, -): Promise { - return await file.openDownloadStream(namespaces[type], type, field, recordId); +): Promise { + return await file.downloadStream(namespaces[type], type, field, recordId); } diff --git a/packages/create-sdk/templates/generators/src/resolver/getProduct.test.ts b/packages/create-sdk/templates/generators/src/resolver/getProduct.test.ts index c569ebcd07..0239c0b4e1 100644 --- a/packages/create-sdk/templates/generators/src/resolver/getProduct.test.ts +++ b/packages/create-sdk/templates/generators/src/resolver/getProduct.test.ts @@ -1,4 +1,3 @@ -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { mockTailordb } from "@tailor-platform/sdk/vitest"; import { describe, expect, test } from "vitest"; import resolver from "./getProduct"; @@ -8,7 +7,7 @@ describe("getProduct resolver", () => { using db = mockTailordb(); // Select product db.enqueueResult({ - id: "product-1", + id: "00000000-0000-4000-8000-000000000001", name: "Widget", price: 9.99, status: "ACTIVE", @@ -21,8 +20,9 @@ describe("getProduct resolver", () => { db.enqueueResult({ name: "Gadgets" }); const result = await resolver.body({ - input: { productId: "product-1" }, - user: unauthenticatedTailorUser, + input: { productId: "00000000-0000-4000-8000-000000000001" }, + caller: null, + invoker: null, env: {}, }); @@ -39,7 +39,7 @@ describe("getProduct resolver", () => { using db = mockTailordb(); // Select product (no categoryId) db.enqueueResult({ - id: "product-2", + id: "00000000-0000-4000-8000-000000000002", name: "Standalone Item", price: 19.99, status: "DRAFT", @@ -50,8 +50,9 @@ describe("getProduct resolver", () => { }); const result = await resolver.body({ - input: { productId: "product-2" }, - user: unauthenticatedTailorUser, + input: { productId: "00000000-0000-4000-8000-000000000002" }, + caller: null, + invoker: null, env: {}, }); diff --git a/packages/create-sdk/templates/generators/src/seed/data/Order.schema.ts b/packages/create-sdk/templates/generators/src/seed/data/Order.schema.ts index dffeb95f35..569a38ee1e 100644 --- a/packages/create-sdk/templates/generators/src/seed/data/Order.schema.ts +++ b/packages/create-sdk/templates/generators/src/seed/data/Order.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { order } from "../../db/order"; const schemaType = t.object({ - ...order.pickFields(["id","createdAt"], { optional: true }), - ...order.omitFields(["id","createdAt"]), + ...order.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...order.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(order); diff --git a/packages/create-sdk/templates/generators/src/seed/data/Product.schema.ts b/packages/create-sdk/templates/generators/src/seed/data/Product.schema.ts index 2bf00829c9..9a7a449bdb 100644 --- a/packages/create-sdk/templates/generators/src/seed/data/Product.schema.ts +++ b/packages/create-sdk/templates/generators/src/seed/data/Product.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { product } from "../../db/product"; const schemaType = t.object({ - ...product.pickFields(["id","createdAt"], { optional: true }), - ...product.omitFields(["id","createdAt"]), + ...product.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...product.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(product); diff --git a/packages/create-sdk/templates/generators/src/seed/data/User.schema.ts b/packages/create-sdk/templates/generators/src/seed/data/User.schema.ts index 2cbbdf2c56..5dbc6522e0 100644 --- a/packages/create-sdk/templates/generators/src/seed/data/User.schema.ts +++ b/packages/create-sdk/templates/generators/src/seed/data/User.schema.ts @@ -4,8 +4,8 @@ import { createTailorDBHook, createStandardSchema } from "@tailor-platform/sdk/t import { user } from "../../db/user"; const schemaType = t.object({ - ...user.pickFields(["id","createdAt"], { optional: true }), - ...user.omitFields(["id","createdAt"]), + ...user.pickFields(["id","createdAt","updatedAt"], { optional: true }), + ...user.omitFields(["id","createdAt","updatedAt"]), }); const hook = createTailorDBHook(user); diff --git a/packages/create-sdk/templates/generators/src/seed/exec.mjs b/packages/create-sdk/templates/generators/src/seed/exec.mjs index 865ed0666e..0097ec9d97 100644 --- a/packages/create-sdk/templates/generators/src/seed/exec.mjs +++ b/packages/create-sdk/templates/generators/src/seed/exec.mjs @@ -388,7 +388,7 @@ const seedViaTestExecScript = async (namespace, typesToSeed, deps, selfRefTypes) workspaceId, name: `seed-${namespace}.ts`, code: bundled.bundledCode, - arg: JSON.stringify({ data: chunk.data, order: chunk.order, selfRefTypes }), + arg: { data: chunk.data, order: chunk.order, selfRefTypes }, invoker: { namespace: authNamespace, machineUserName, diff --git a/packages/create-sdk/templates/generators/tailor.d.ts b/packages/create-sdk/templates/generators/tailor.d.ts index 84fb93e2dd..c01151438e 100644 --- a/packages/create-sdk/templates/generators/tailor.d.ts +++ b/packages/create-sdk/templates/generators/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: string; } interface AttributeList { diff --git a/packages/create-sdk/templates/hello-world/.gitignore b/packages/create-sdk/templates/hello-world/.gitignore index d04123787b..f3b2377c83 100644 --- a/packages/create-sdk/templates/hello-world/.gitignore +++ b/packages/create-sdk/templates/hello-world/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/hello-world/.oxlintrc.json b/packages/create-sdk/templates/hello-world/.oxlintrc.json index 627555ccc1..76577f7bc6 100644 --- a/packages/create-sdk/templates/hello-world/.oxlintrc.json +++ b/packages/create-sdk/templates/hello-world/.oxlintrc.json @@ -7,7 +7,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "tailor.d.ts"], "rules": { "no-case-declarations": "error", "no-empty": "error", diff --git a/packages/create-sdk/templates/hello-world/README.md b/packages/create-sdk/templates/hello-world/README.md index df894c082d..7b1ffca1ae 100644 --- a/packages/create-sdk/templates/hello-world/README.md +++ b/packages/create-sdk/templates/hello-world/README.md @@ -9,12 +9,12 @@ This project was bootstrapped with [Create Tailor Platform SDK](https://www.npmj 1. Create a new workspace: ```bash -npx tailor-sdk login -npx tailor-sdk workspace create --name --region -npx tailor-sdk workspace list -# For yarn: yarn tailor-sdk -# For pnpm: pnpm tailor-sdk -# For bun: bun tailor-sdk +npx tailor login +npx tailor workspace create --name --region +npx tailor workspace list +# For yarn: yarn tailor +# For pnpm: pnpm tailor +# For bun: bun tailor # OR # Create a new workspace using Tailor Platform Console diff --git a/packages/create-sdk/templates/hello-world/package.json b/packages/create-sdk/templates/hello-world/package.json index 9e424860ad..83127a0d0b 100644 --- a/packages/create-sdk/templates/hello-world/package.json +++ b/packages/create-sdk/templates/hello-world/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "deploy": "tailor-sdk deploy", - "generate": "tailor-sdk generate", + "deploy": "tailor deploy", + "generate": "tailor generate", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "lint": "oxlint --type-aware .", diff --git a/packages/create-sdk/templates/hello-world/src/generated/kysely-tailordb.ts b/packages/create-sdk/templates/hello-world/src/generated/kysely-tailordb.ts index c42a196513..60cb1acae8 100644 --- a/packages/create-sdk/templates/hello-world/src/generated/kysely-tailordb.ts +++ b/packages/create-sdk/templates/hello-world/src/generated/kysely-tailordb.ts @@ -1,6 +1,7 @@ import { createGetDB, type Generated, + type UUIDString, type Timestamp, type NamespaceDB, type NamespaceInsertable, @@ -14,12 +15,12 @@ import { export interface Namespace { "main-db": { User: { - id: Generated; + id: Generated; name: string; email: string; role: "MANAGER" | "STAFF"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/hello-world/tailor.d.ts b/packages/create-sdk/templates/hello-world/tailor.d.ts index 4f350ad00b..60ce677016 100644 --- a/packages/create-sdk/templates/hello-world/tailor.d.ts +++ b/packages/create-sdk/templates/hello-world/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap {} + interface Attributes {} interface AttributeList { __tuple?: []; } diff --git a/packages/create-sdk/templates/inventory-management/.gitignore b/packages/create-sdk/templates/inventory-management/.gitignore index d04123787b..f3b2377c83 100644 --- a/packages/create-sdk/templates/inventory-management/.gitignore +++ b/packages/create-sdk/templates/inventory-management/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/inventory-management/.oxlintrc.json b/packages/create-sdk/templates/inventory-management/.oxlintrc.json index 221090c46d..99137dc774 100644 --- a/packages/create-sdk/templates/inventory-management/.oxlintrc.json +++ b/packages/create-sdk/templates/inventory-management/.oxlintrc.json @@ -7,7 +7,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "tailor.d.ts"], "rules": { "no-case-declarations": "error", "no-empty": "error", diff --git a/packages/create-sdk/templates/inventory-management/README.md b/packages/create-sdk/templates/inventory-management/README.md index c129f1ab55..8f86cd1d5c 100644 --- a/packages/create-sdk/templates/inventory-management/README.md +++ b/packages/create-sdk/templates/inventory-management/README.md @@ -9,12 +9,12 @@ This project was bootstrapped with [Create Tailor Platform SDK](https://www.npmj 1. Create a new workspace: ```bash -npx tailor-sdk login -npx tailor-sdk workspace create --name --region -npx tailor-sdk workspace list -# For yarn: yarn tailor-sdk -# For pnpm: pnpm tailor-sdk -# For bun: bun tailor-sdk +npx tailor login +npx tailor workspace create --name --region +npx tailor workspace list +# For yarn: yarn tailor +# For pnpm: pnpm tailor +# For bun: bun tailor # OR # Create a new workspace using Tailor Platform Console @@ -36,9 +36,9 @@ npm run deploy -- --workspace-id ```bash # Get Manager's token -npx tailor-sdk machineuser token manager --workspace-id +npx tailor machineuser token manager --workspace-id # Get Staff's token -npx tailor-sdk machineuser token staff --workspace-id +npx tailor machineuser token staff --workspace-id ``` ## Features diff --git a/packages/create-sdk/templates/inventory-management/package.json b/packages/create-sdk/templates/inventory-management/package.json index 56a61dfca5..8c1c84beeb 100644 --- a/packages/create-sdk/templates/inventory-management/package.json +++ b/packages/create-sdk/templates/inventory-management/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "deploy": "tailor-sdk deploy", - "generate": "tailor-sdk generate", + "deploy": "tailor deploy", + "generate": "tailor generate", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "lint": "oxlint --type-aware .", diff --git a/packages/create-sdk/templates/inventory-management/src/db/inventory.ts b/packages/create-sdk/templates/inventory-management/src/db/inventory.ts index 6deb5d7807..77d9511664 100644 --- a/packages/create-sdk/templates/inventory-management/src/db/inventory.ts +++ b/packages/create-sdk/templates/inventory-management/src/db/inventory.ts @@ -11,7 +11,7 @@ export const inventory = db quantity: db .int() .description("Quantity of the product in inventory") - .validate(({ value }) => value >= 0), + .validate(({ value }) => (value < 0 ? "Quantity must be non-negative" : undefined)), ...db.fields.timestamps(), }) .permission(permissionLoggedIn) diff --git a/packages/create-sdk/templates/inventory-management/src/db/orderItem.ts b/packages/create-sdk/templates/inventory-management/src/db/orderItem.ts index 2fc8c572ec..609adcb2cd 100644 --- a/packages/create-sdk/templates/inventory-management/src/db/orderItem.ts +++ b/packages/create-sdk/templates/inventory-management/src/db/orderItem.ts @@ -16,19 +16,21 @@ export const orderItem = db quantity: db .int() .description("Quantity of the product") - .validate(({ value }) => value >= 0), + .validate(({ value }) => (value < 0 ? "Quantity must be non-negative" : undefined)), unitPrice: db .float() .description("Unit price of the product") - .validate(({ value }) => value >= 0), - totalPrice: db.float({ optional: true }).description("Total price of the order item"), + .validate(({ value }) => (value < 0 ? "Unit price must be non-negative" : undefined)), + totalPrice: db.float().default(0).description("Total price of the order item"), ...db.fields.timestamps(), }) .hooks({ - totalPrice: { - create: ({ data }) => (data?.quantity ?? 0) * (data.unitPrice ?? 0), - update: ({ data }) => (data?.quantity ?? 0) * (data.unitPrice ?? 0), - }, + create: ({ input }) => ({ + totalPrice: (input?.quantity ?? 0) * (input.unitPrice ?? 0), + }), + update: ({ input }) => ({ + totalPrice: (input?.quantity ?? 0) * (input.unitPrice ?? 0), + }), }) .permission(permissionLoggedIn) .gqlPermission(gqlPermissionLoggedIn); diff --git a/packages/create-sdk/templates/inventory-management/src/executor/checkInventory.ts b/packages/create-sdk/templates/inventory-management/src/executor/checkInventory.ts index 93bc9d1cf1..091b671d89 100644 --- a/packages/create-sdk/templates/inventory-management/src/executor/checkInventory.ts +++ b/packages/create-sdk/templates/inventory-management/src/executor/checkInventory.ts @@ -1,6 +1,5 @@ import { createExecutor, recordUpdatedTrigger } from "@tailor-platform/sdk"; import { inventory } from "../db/inventory"; -import config from "../../tailor.config"; import { getDB } from "../generated/kysely-tailordb"; export default createExecutor({ @@ -22,6 +21,6 @@ export default createExecutor({ }) .execute(); }, - invoker: config.auth.invoker("manager"), + invoker: "manager", }, }); diff --git a/packages/create-sdk/templates/inventory-management/src/generated/kysely-tailordb.ts b/packages/create-sdk/templates/inventory-management/src/generated/kysely-tailordb.ts index 001ea023eb..6777748fe6 100644 --- a/packages/create-sdk/templates/inventory-management/src/generated/kysely-tailordb.ts +++ b/packages/create-sdk/templates/inventory-management/src/generated/kysely-tailordb.ts @@ -1,6 +1,7 @@ import { createGetDB, type Generated, + type UUIDString, type Timestamp, type NamespaceDB, type NamespaceInsertable, @@ -14,76 +15,76 @@ import { export interface Namespace { "main-db": { Category: { - id: Generated; + id: Generated; name: string; description: string | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Contact: { - id: Generated; + id: Generated; name: string; email: string; phone: string | null; address: string | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Inventory: { - id: Generated; - productId: string; + id: Generated; + productId: UUIDString; quantity: number; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Notification: { - id: Generated; + id: Generated; message: string; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Order: { - id: Generated; + id: Generated; name: string; description: string | null; orderDate: Timestamp; orderType: "PURCHASE" | "SALES"; - contactId: string; + contactId: UUIDString; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } OrderItem: { - id: Generated; - orderId: string; - productId: string; + id: Generated; + orderId: UUIDString; + productId: UUIDString; quantity: number; unitPrice: number; - totalPrice: Generated; + totalPrice: Generated; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Product: { - id: Generated; + id: Generated; name: string; description: string | null; - categoryId: string; + categoryId: UUIDString; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { - id: Generated; + id: Generated; name: string; email: string; role: "MANAGER" | "STAFF"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/inventory-management/src/resolver/registerOrder.ts b/packages/create-sdk/templates/inventory-management/src/resolver/registerOrder.ts index ee1f332a7c..e3d9c53330 100644 --- a/packages/create-sdk/templates/inventory-management/src/resolver/registerOrder.ts +++ b/packages/create-sdk/templates/inventory-management/src/resolver/registerOrder.ts @@ -4,8 +4,8 @@ import { orderItem } from "../db/orderItem"; import { type DB, getDB } from "../generated/kysely-tailordb"; const input = { - order: t.object(order.omitFields(["id", "createdAt"])), - items: t.object(orderItem.omitFields(["id", "createdAt"]), { array: true }), + order: t.object(order.omitFields(["id", "createdAt", "updatedAt"])), + items: t.object(orderItem.omitFields(["id", "createdAt", "updatedAt"]), { array: true }), }; interface Input { order: t.infer; diff --git a/packages/create-sdk/templates/inventory-management/tailor.d.ts b/packages/create-sdk/templates/inventory-management/tailor.d.ts index f27325cbf1..07de613290 100644 --- a/packages/create-sdk/templates/inventory-management/tailor.d.ts +++ b/packages/create-sdk/templates/inventory-management/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: "MANAGER" | "STAFF"; } interface AttributeList { diff --git a/packages/create-sdk/templates/multi-application/.gitignore b/packages/create-sdk/templates/multi-application/.gitignore index d04123787b..f3b2377c83 100644 --- a/packages/create-sdk/templates/multi-application/.gitignore +++ b/packages/create-sdk/templates/multi-application/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/multi-application/.oxlintrc.json b/packages/create-sdk/templates/multi-application/.oxlintrc.json index 627555ccc1..76577f7bc6 100644 --- a/packages/create-sdk/templates/multi-application/.oxlintrc.json +++ b/packages/create-sdk/templates/multi-application/.oxlintrc.json @@ -7,7 +7,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "tailor.d.ts"], "rules": { "no-case-declarations": "error", "no-empty": "error", diff --git a/packages/create-sdk/templates/multi-application/README.md b/packages/create-sdk/templates/multi-application/README.md index 99b14121a0..fa1a564629 100644 --- a/packages/create-sdk/templates/multi-application/README.md +++ b/packages/create-sdk/templates/multi-application/README.md @@ -16,8 +16,8 @@ This project contains two applications: `user` and `admin`. 1. Create a new workspace: ```bash -npx tailor-sdk login -npx tailor-sdk workspace create --name --region +npx tailor login +npx tailor workspace create --name --region ``` 2. Deploy the project: diff --git a/packages/create-sdk/templates/multi-application/apps/admin/db/adminNote.ts b/packages/create-sdk/templates/multi-application/apps/admin/db/adminNote.ts index b3f5997c31..c9383e3bff 100644 --- a/packages/create-sdk/templates/multi-application/apps/admin/db/adminNote.ts +++ b/packages/create-sdk/templates/multi-application/apps/admin/db/adminNote.ts @@ -8,7 +8,9 @@ export const adminNote = db .type("AdminNote", { title: db.string(), content: db.string(), - authorId: db.uuid().hooks({ create: ({ user }) => user.id }), + authorId: db.uuid().hooks({ + create: ({ invoker }) => invoker?.id ?? crypto.randomUUID(), + }), ...db.fields.timestamps(), }) // NOTE: This permits all operations for simplicity. diff --git a/packages/create-sdk/templates/multi-application/package.json b/packages/create-sdk/templates/multi-application/package.json index deafa1c6fc..b8bcfdc0de 100644 --- a/packages/create-sdk/templates/multi-application/package.json +++ b/packages/create-sdk/templates/multi-application/package.json @@ -5,8 +5,8 @@ "type": "module", "scripts": { "deploy": "pnpm run deploy:user && pnpm run deploy:admin", - "deploy:user": "tailor-sdk deploy -c apps/user/tailor.config.ts", - "deploy:admin": "tailor-sdk deploy -c apps/admin/tailor.config.ts", + "deploy:user": "tailor deploy -c apps/user/tailor.config.ts", + "deploy:admin": "tailor deploy -c apps/admin/tailor.config.ts", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "lint": "oxlint --type-aware .", diff --git a/packages/create-sdk/templates/resolver/.gitignore b/packages/create-sdk/templates/resolver/.gitignore index d04123787b..f3b2377c83 100644 --- a/packages/create-sdk/templates/resolver/.gitignore +++ b/packages/create-sdk/templates/resolver/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/resolver/.oxlintrc.json b/packages/create-sdk/templates/resolver/.oxlintrc.json index 537e488b88..46b42a60a3 100644 --- a/packages/create-sdk/templates/resolver/.oxlintrc.json +++ b/packages/create-sdk/templates/resolver/.oxlintrc.json @@ -7,7 +7,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "tailor.d.ts"], "rules": { "no-case-declarations": "error", "no-empty": "error", diff --git a/packages/create-sdk/templates/resolver/README.md b/packages/create-sdk/templates/resolver/README.md index f0c531a617..f5c771dce1 100644 --- a/packages/create-sdk/templates/resolver/README.md +++ b/packages/create-sdk/templates/resolver/README.md @@ -8,11 +8,11 @@ Demonstrates all resolver patterns with comprehensive testing approaches. - Database query resolver (Kysely with transactions) - Database mutation resolver (dependency injection pattern) - Environment variable access -- User context access +- Caller and invoker context access ## Testing Approaches -1. **Direct `body()` call** - Simple resolvers with `unauthenticatedTailorUser` +1. **Direct `body()` call** - Simple resolvers with explicit `caller` / `invoker` context values 2. **`tailor-runtime` environment + `mockTailordb`** - Database resolvers via `mockTailordb` from `@tailor-platform/sdk/vitest` (no `vi.stubGlobal` needed) 3. **Dependency injection** - Extract `DbOperations` interface for testability diff --git a/packages/create-sdk/templates/resolver/package.json b/packages/create-sdk/templates/resolver/package.json index da891645fe..fa5339da2b 100644 --- a/packages/create-sdk/templates/resolver/package.json +++ b/packages/create-sdk/templates/resolver/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "generate": "tailor-sdk generate", - "deploy": "tailor-sdk deploy", + "generate": "tailor generate", + "deploy": "tailor deploy", "test": "vitest --project unit", "test:unit": "vitest --project unit", "format": "oxfmt --write .", diff --git a/packages/create-sdk/templates/resolver/src/generated/db.ts b/packages/create-sdk/templates/resolver/src/generated/db.ts index e36ba8aa93..870db86c14 100644 --- a/packages/create-sdk/templates/resolver/src/generated/db.ts +++ b/packages/create-sdk/templates/resolver/src/generated/db.ts @@ -1,6 +1,7 @@ import { createGetDB, type Generated, + type UUIDString, type Timestamp, type NamespaceDB, type NamespaceInsertable, @@ -14,12 +15,12 @@ import { export interface Namespace { "main-db": { User: { - id: Generated; + id: Generated; name: string; email: string; age: number; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/resolver/src/resolver/add.test.ts b/packages/create-sdk/templates/resolver/src/resolver/add.test.ts index 23ee623077..373e38462c 100644 --- a/packages/create-sdk/templates/resolver/src/resolver/add.test.ts +++ b/packages/create-sdk/templates/resolver/src/resolver/add.test.ts @@ -1,4 +1,3 @@ -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { describe, expect, test } from "vitest"; import resolver from "./add"; @@ -6,7 +5,8 @@ describe("add resolver", () => { test("adds two positive numbers", async () => { const result = await resolver.body({ input: { left: 1, right: 2 }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); expect(result).toBe(3); @@ -15,7 +15,8 @@ describe("add resolver", () => { test("handles negative numbers", async () => { const result = await resolver.body({ input: { left: -5, right: 3 }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); expect(result).toBe(-2); diff --git a/packages/create-sdk/templates/resolver/src/resolver/incrementUserAge.test.ts b/packages/create-sdk/templates/resolver/src/resolver/incrementUserAge.test.ts index 3649f21feb..574aee83f5 100644 --- a/packages/create-sdk/templates/resolver/src/resolver/incrementUserAge.test.ts +++ b/packages/create-sdk/templates/resolver/src/resolver/incrementUserAge.test.ts @@ -1,4 +1,3 @@ -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { mockTailordb } from "@tailor-platform/sdk/vitest"; import { describe, expect, test } from "vitest"; import resolver from "./incrementUserAge"; @@ -15,7 +14,8 @@ describe("incrementUserAge resolver", () => { const result = await resolver.body({ input: { email: "test@example.com" }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); expect(result).toEqual({ oldAge: 30, newAge: 31 }); @@ -32,7 +32,8 @@ describe("incrementUserAge resolver", () => { const result = resolver.body({ input: { email: "test@example.com" }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); await expect(result).rejects.toThrowError(/no result/i); diff --git a/packages/create-sdk/templates/resolver/src/resolver/showEnv.test.ts b/packages/create-sdk/templates/resolver/src/resolver/showEnv.test.ts index 1973897a98..19316895b0 100644 --- a/packages/create-sdk/templates/resolver/src/resolver/showEnv.test.ts +++ b/packages/create-sdk/templates/resolver/src/resolver/showEnv.test.ts @@ -1,4 +1,3 @@ -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { describe, expect, test } from "vitest"; import resolver from "./showEnv"; @@ -6,7 +5,8 @@ describe("showEnv resolver", () => { test("returns environment variables", async () => { const result = await resolver.body({ input: undefined as never, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); expect(result).toEqual({ appName: "Resolver Template", version: 1 }); diff --git a/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.test.ts b/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.test.ts index 2a2f9739ad..2db8085d4d 100644 --- a/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.test.ts +++ b/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.test.ts @@ -1,4 +1,4 @@ -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; +import type { TailorPrincipal } from "@tailor-platform/sdk"; import { describe, expect, test } from "vitest"; import resolver from "./showUserInfo"; @@ -6,30 +6,33 @@ describe("showUserInfo resolver", () => { test("returns default user info", async () => { const result = await resolver.body({ input: undefined as never, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); expect(result).toEqual({ - userId: unauthenticatedTailorUser.id, - userType: unauthenticatedTailorUser.type, - workspaceId: unauthenticatedTailorUser.workspaceId, + userId: "anonymous", + userType: "anonymous", + workspaceId: "", }); }); test("returns custom user info", async () => { - const customUser = { - ...unauthenticatedTailorUser, - id: "user-123", + const customCaller = { + id: "123e4567-e89b-12d3-a456-426614174000", type: "machine_user" as const, workspaceId: "ws-456", - }; + attributes: { role: "admin" }, + attributeList: [], + } satisfies TailorPrincipal; const result = await resolver.body({ input: undefined as never, - user: customUser, + caller: customCaller, + invoker: customCaller, env: { appName: "Resolver Template", version: 1 }, }); expect(result).toEqual({ - userId: "user-123", + userId: "123e4567-e89b-12d3-a456-426614174000", userType: "machine_user", workspaceId: "ws-456", }); diff --git a/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.ts b/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.ts index b848df6406..6e33d0f8a2 100644 --- a/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.ts +++ b/packages/create-sdk/templates/resolver/src/resolver/showUserInfo.ts @@ -6,9 +6,9 @@ const resolver = createResolver({ operation: "query", body: (context) => { return { - userId: context.user.id, - userType: context.user.type, - workspaceId: context.user.workspaceId, + userId: context.caller?.id ?? "anonymous", + userType: context.caller?.type ?? "anonymous", + workspaceId: context.caller?.workspaceId ?? "", }; }, output: t.object({ diff --git a/packages/create-sdk/templates/resolver/src/resolver/upsertUsers.test.ts b/packages/create-sdk/templates/resolver/src/resolver/upsertUsers.test.ts index b953f1a33c..3ce7d25d2e 100644 --- a/packages/create-sdk/templates/resolver/src/resolver/upsertUsers.test.ts +++ b/packages/create-sdk/templates/resolver/src/resolver/upsertUsers.test.ts @@ -1,4 +1,3 @@ -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { createKyselyMock } from "@tailor-platform/sdk/vitest"; import { describe, expect, test, vi } from "vitest"; import { getDB, type Namespace } from "../generated/db"; @@ -14,7 +13,9 @@ describe("upsertUsers resolver", () => { mock.setQueryResolver((query) => { switch (query.kind) { case "SelectQueryNode": - return query.parameters.includes("exists@example.com") ? [{ id: "user-1" }] : []; + return query.parameters.includes("exists@example.com") + ? [{ id: "11111111-1111-4111-8111-111111111111" }] + : []; case "InsertQueryNode": case "UpdateQueryNode": return { numAffectedRows: 1 }; @@ -30,7 +31,8 @@ describe("upsertUsers resolver", () => { { name: "Existing", email: "exists@example.com", age: 41 }, ], }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); diff --git a/packages/create-sdk/templates/resolver/tailor.d.ts b/packages/create-sdk/templates/resolver/tailor.d.ts index 37b157d399..22fe5946bb 100644 --- a/packages/create-sdk/templates/resolver/tailor.d.ts +++ b/packages/create-sdk/templates/resolver/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: string; } interface AttributeList { diff --git a/packages/create-sdk/templates/static-web-site/.gitignore b/packages/create-sdk/templates/static-web-site/.gitignore index d04123787b..f3b2377c83 100644 --- a/packages/create-sdk/templates/static-web-site/.gitignore +++ b/packages/create-sdk/templates/static-web-site/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/static-web-site/.oxlintrc.json b/packages/create-sdk/templates/static-web-site/.oxlintrc.json index 537e488b88..46b42a60a3 100644 --- a/packages/create-sdk/templates/static-web-site/.oxlintrc.json +++ b/packages/create-sdk/templates/static-web-site/.oxlintrc.json @@ -7,7 +7,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "tailor.d.ts"], "rules": { "no-case-declarations": "error", "no-empty": "error", diff --git a/packages/create-sdk/templates/static-web-site/package.json b/packages/create-sdk/templates/static-web-site/package.json index 43a0b068c4..b68d141123 100644 --- a/packages/create-sdk/templates/static-web-site/package.json +++ b/packages/create-sdk/templates/static-web-site/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "deploy": "tailor-sdk deploy", - "generate": "tailor-sdk generate", + "deploy": "tailor deploy", + "generate": "tailor generate", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "lint": "oxlint --type-aware .", diff --git a/packages/create-sdk/templates/static-web-site/tailor.d.ts b/packages/create-sdk/templates/static-web-site/tailor.d.ts index 58cba9bc50..863e217f37 100644 --- a/packages/create-sdk/templates/static-web-site/tailor.d.ts +++ b/packages/create-sdk/templates/static-web-site/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: "ADMIN" | "MEMBER"; } interface AttributeList { diff --git a/packages/create-sdk/templates/tailordb/.gitignore b/packages/create-sdk/templates/tailordb/.gitignore index d04123787b..f3b2377c83 100644 --- a/packages/create-sdk/templates/tailordb/.gitignore +++ b/packages/create-sdk/templates/tailordb/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/tailordb/.oxlintrc.json b/packages/create-sdk/templates/tailordb/.oxlintrc.json index 537e488b88..46b42a60a3 100644 --- a/packages/create-sdk/templates/tailordb/.oxlintrc.json +++ b/packages/create-sdk/templates/tailordb/.oxlintrc.json @@ -7,7 +7,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "tailor.d.ts"], "rules": { "no-case-declarations": "error", "no-empty": "error", diff --git a/packages/create-sdk/templates/tailordb/package.json b/packages/create-sdk/templates/tailordb/package.json index cf16f8c6b6..e58f289d1c 100644 --- a/packages/create-sdk/templates/tailordb/package.json +++ b/packages/create-sdk/templates/tailordb/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "generate": "tailor-sdk generate", - "deploy": "tailor-sdk deploy", + "generate": "tailor generate", + "deploy": "tailor deploy", "test": "vitest --project unit", "test:unit": "vitest --project unit", "format": "oxfmt --write .", diff --git a/packages/create-sdk/templates/tailordb/src/db/comment.ts b/packages/create-sdk/templates/tailordb/src/db/comment.ts index 5f8f180676..44e3297a16 100644 --- a/packages/create-sdk/templates/tailordb/src/db/comment.ts +++ b/packages/create-sdk/templates/tailordb/src/db/comment.ts @@ -5,7 +5,9 @@ import { user } from "./user"; export const comment = db .type("Comment", "A comment on a task", { - body: db.string().validate([({ value }) => value.length >= 1, "Comment must not be empty"]), + body: db + .string() + .validate(({ value }) => (value.length < 1 ? "Comment must not be empty" : undefined)), taskId: db.uuid().relation({ type: "n-1", toward: { type: task }, diff --git a/packages/create-sdk/templates/tailordb/src/db/task.ts b/packages/create-sdk/templates/tailordb/src/db/task.ts index 4e4a561437..2da97f74d4 100644 --- a/packages/create-sdk/templates/tailordb/src/db/task.ts +++ b/packages/create-sdk/templates/tailordb/src/db/task.ts @@ -5,12 +5,10 @@ import { user } from "./user"; export const task = db .type("Task", "A task with comprehensive features", { - title: db - .string() - .validate( - [({ value }) => value.length >= 3, "Title must be at least 3 characters"], - [({ value }) => value.length <= 200, "Title must be at most 200 characters"], - ), + title: db.string().validate( + ({ value }) => (value.length >= 3 ? undefined : "Title must be at least 3 characters"), + ({ value }) => (value.length <= 200 ? undefined : "Title must be at most 200 characters"), + ), description: db.string({ optional: true }), status: db.enum([ { value: "TODO", description: "Not started" }, @@ -18,12 +16,10 @@ export const task = db { value: "DONE", description: "Completed" }, { value: "CANCELLED", description: "No longer needed" }, ]), - priority: db - .int() - .validate( - [({ value }) => value >= 0, "Priority must be non-negative"], - [({ value }) => value <= 4, "Priority must be at most 4"], - ), + priority: db.int().validate( + ({ value }) => (value >= 0 ? undefined : "Priority must be non-negative"), + ({ value }) => (value <= 4 ? undefined : "Priority must be at most 4"), + ), dueDate: db.datetime({ optional: true }), assigneeId: db.uuid({ optional: true }).relation({ type: "n-1", @@ -33,26 +29,22 @@ export const task = db type: "n-1", toward: { type: category }, }), - isArchived: db.bool().description("Whether the task is archived"), + isArchived: db + .bool() + .description("Whether the task is archived") + .hooks({ + create: () => false, + }), ...db.fields.timestamps(), }) - .hooks({ - isArchived: { - create: () => false, - }, - }) .indexes( { fields: ["status", "priority"], unique: false }, { fields: ["assigneeId", "status"], unique: false, name: "task_assignee_status_idx" }, ) - .validate({ - status: [ - ({ value, data }) => { - const d = data as { dueDate: string | null }; - return !(value === "DONE" && d.dueDate === null); - }, - "Completed tasks must have a due date", - ], + .validate(({ newRecord }, issues) => { + if (newRecord.status === "DONE" && !newRecord.dueDate) { + issues("status", "Completed tasks must have a due date"); + } }) .permission(rolePermission) .gqlPermission(roleGqlPermission); diff --git a/packages/create-sdk/templates/tailordb/src/generated/db.ts b/packages/create-sdk/templates/tailordb/src/generated/db.ts index f68627d277..a8cef984df 100644 --- a/packages/create-sdk/templates/tailordb/src/generated/db.ts +++ b/packages/create-sdk/templates/tailordb/src/generated/db.ts @@ -1,6 +1,7 @@ import { createGetDB, type Generated, + type UUIDString, type Timestamp, type ObjectColumnType, type NamespaceDB, @@ -15,48 +16,48 @@ import { export interface Namespace { "main-db": { Category: { - id: Generated; + id: Generated; name: string; description: string | null; - parentCategoryId: string | null; + parentCategoryId: UUIDString | null; } Comment: { - id: Generated; + id: Generated; body: string; - taskId: string; - authorId: string; + taskId: UUIDString; + authorId: UUIDString; metadata: ObjectColumnType<{ source: string; editedAt?: Timestamp | null; isInternal: boolean; }>; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } Task: { - id: Generated; + id: Generated; title: string; description: string | null; status: "TODO" | "IN_PROGRESS" | "DONE" | "CANCELLED"; priority: number; dueDate: Timestamp | null; - assigneeId: string | null; - categoryId: string | null; + assigneeId: UUIDString | null; + categoryId: UUIDString | null; isArchived: Generated; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { - id: Generated; + id: Generated; name: string; email: string; role: "ADMIN" | "MEMBER" | "VIEWER"; bio: string | null; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/tailordb/tailor.d.ts b/packages/create-sdk/templates/tailordb/tailor.d.ts index 445be30a3f..d241e8f154 100644 --- a/packages/create-sdk/templates/tailordb/tailor.d.ts +++ b/packages/create-sdk/templates/tailordb/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: string; } interface AttributeList { diff --git a/packages/create-sdk/templates/workflow/.gitignore b/packages/create-sdk/templates/workflow/.gitignore index d04123787b..f3b2377c83 100644 --- a/packages/create-sdk/templates/workflow/.gitignore +++ b/packages/create-sdk/templates/workflow/.gitignore @@ -1,2 +1,2 @@ node_modules/ -.tailor-sdk/ +.tailor/ diff --git a/packages/create-sdk/templates/workflow/.oxlintrc.json b/packages/create-sdk/templates/workflow/.oxlintrc.json index 537e488b88..46b42a60a3 100644 --- a/packages/create-sdk/templates/workflow/.oxlintrc.json +++ b/packages/create-sdk/templates/workflow/.oxlintrc.json @@ -7,7 +7,7 @@ "env": { "builtin": true }, - "ignorePatterns": [".tailor-sdk/", "src/generated/", "tailor.d.ts"], + "ignorePatterns": [".tailor/", "src/generated/", "tailor.d.ts"], "rules": { "no-case-declarations": "error", "no-empty": "error", diff --git a/packages/create-sdk/templates/workflow/README.md b/packages/create-sdk/templates/workflow/README.md index 8476d85fc4..7fdf55a069 100644 --- a/packages/create-sdk/templates/workflow/README.md +++ b/packages/create-sdk/templates/workflow/README.md @@ -7,7 +7,7 @@ Demonstrates workflow patterns with job chaining, trigger testing, and dependenc - Workflow with multiple jobs (`createWorkflow`, `createWorkflowJob`) - Job chaining via `.trigger()` - Database operations in workflow jobs (DI pattern) -- Integration testing with `workflow.mainJob.trigger()` +- Integration testing with `runWorkflowLocally()` ## Getting Started diff --git a/packages/create-sdk/templates/workflow/e2e/globalSetup.ts b/packages/create-sdk/templates/workflow/e2e/globalSetup.ts index 6af16a7540..3ad71e9695 100644 --- a/packages/create-sdk/templates/workflow/e2e/globalSetup.ts +++ b/packages/create-sdk/templates/workflow/e2e/globalSetup.ts @@ -55,7 +55,7 @@ export async function setup(project: TestProject): Promise { process.env.TAILOR_PLATFORM_WORKSPACE_ID = createdWorkspace.id; // This pipeline deploys a fresh app into a per-run workspace, so let the // SDK inject a missing app id even in CI (normally a hard error there). - process.env.TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION = "true"; + process.env.TAILOR_CI_ALLOW_ID_INJECTION = "true"; await deployApplication(); } diff --git a/packages/create-sdk/templates/workflow/e2e/workflow.test.ts b/packages/create-sdk/templates/workflow/e2e/workflow.test.ts index 2bc2adc365..67f3b7e158 100644 --- a/packages/create-sdk/templates/workflow/e2e/workflow.test.ts +++ b/packages/create-sdk/templates/workflow/e2e/workflow.test.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; import { describe, expect, test } from "vitest"; import { startWorkflow } from "@tailor-platform/sdk/cli"; -import config from "../tailor.config"; import userProfileSyncWorkflow from "../src/workflow/sync-profile"; describe("workflow", () => { @@ -11,7 +10,7 @@ describe("workflow", () => { const { executionId, wait } = await startWorkflow({ workflow: userProfileSyncWorkflow, - authInvoker: config.auth.invoker("admin"), + invoker: "admin", arg: { name: "workflow-test-user", email: testEmail, diff --git a/packages/create-sdk/templates/workflow/package.json b/packages/create-sdk/templates/workflow/package.json index 14a3c33ada..76c570bf24 100644 --- a/packages/create-sdk/templates/workflow/package.json +++ b/packages/create-sdk/templates/workflow/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "generate": "tailor-sdk generate", - "deploy": "tailor-sdk deploy", + "generate": "tailor generate", + "deploy": "tailor deploy", "test": "vitest --project unit", "test:unit": "vitest --project unit", "test:e2e": "vitest --project e2e", diff --git a/packages/create-sdk/templates/workflow/src/generated/db.ts b/packages/create-sdk/templates/workflow/src/generated/db.ts index 51e0e14cbc..e660a08da1 100644 --- a/packages/create-sdk/templates/workflow/src/generated/db.ts +++ b/packages/create-sdk/templates/workflow/src/generated/db.ts @@ -1,6 +1,7 @@ import { createGetDB, type Generated, + type UUIDString, type Timestamp, type NamespaceDB, type NamespaceInsertable, @@ -14,21 +15,21 @@ import { export interface Namespace { "main-db": { Order: { - id: Generated; + id: Generated; customerName: string; amount: number; status: "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED"; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } User: { - id: Generated; + id: Generated; name: string; email: string; age: number; createdAt: Generated; - updatedAt: Timestamp | null; + updatedAt: Generated; } } } diff --git a/packages/create-sdk/templates/workflow/src/resolver/resolveApproval.test.ts b/packages/create-sdk/templates/workflow/src/resolver/resolveApproval.test.ts index 39c9d82817..3b95af598c 100644 --- a/packages/create-sdk/templates/workflow/src/resolver/resolveApproval.test.ts +++ b/packages/create-sdk/templates/workflow/src/resolver/resolveApproval.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "vitest"; import { mockWorkflow } from "@tailor-platform/sdk/vitest"; -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import resolver from "./resolveApproval"; describe("resolveApproval resolver", () => { @@ -16,7 +15,8 @@ describe("resolveApproval resolver", () => { const result = await resolver.body({ input: { executionId: "exec-1", approved: true }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: {}, }); @@ -33,7 +33,8 @@ describe("resolveApproval resolver", () => { const result = await resolver.body({ input: { executionId: "exec-2", approved: false }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: {}, }); diff --git a/packages/create-sdk/templates/workflow/src/workflow/approval.test.ts b/packages/create-sdk/templates/workflow/src/workflow/approval.test.ts index a2a33c0cfd..ace962bba4 100644 --- a/packages/create-sdk/templates/workflow/src/workflow/approval.test.ts +++ b/packages/create-sdk/templates/workflow/src/workflow/approval.test.ts @@ -7,7 +7,10 @@ describe("approval workflow", () => { using wf = mockWorkflow(); wf.setWaitHandler((_key, _payload) => ({ approved: true })); - const result = await processWithApproval.body({ orderId: "order-1" }, { env: {} }); + const result = await processWithApproval.body( + { orderId: "order-1" }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ orderId: "order-1", status: "approved" }); expect(wf.waitCalls).toEqual([ @@ -22,7 +25,10 @@ describe("approval workflow", () => { using wf = mockWorkflow(); wf.setWaitHandler({ approved: false }); - const result = await processWithApproval.body({ orderId: "order-2" }, { env: {} }); + const result = await processWithApproval.body( + { orderId: "order-2" }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ orderId: "order-2", status: "rejected" }); }); diff --git a/packages/create-sdk/templates/workflow/src/workflow/approval.ts b/packages/create-sdk/templates/workflow/src/workflow/approval.ts index 950450ed26..b8038baae4 100644 --- a/packages/create-sdk/templates/workflow/src/workflow/approval.ts +++ b/packages/create-sdk/templates/workflow/src/workflow/approval.ts @@ -1,6 +1,6 @@ -import { createWorkflow, createWorkflowJob, defineWaitPoints } from "@tailor-platform/sdk"; +import { createWaitPoints, createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; -export const { approval } = defineWaitPoints((define) => ({ +export const { approval } = createWaitPoints((define) => ({ /** Approval for order processing */ approval: define<{ message: string; orderId: string }, { approved: boolean }>(), })); diff --git a/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.test.ts b/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.test.ts index db1a1bc4ba..6389972f99 100644 --- a/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.test.ts +++ b/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.test.ts @@ -1,3 +1,4 @@ +import { runWorkflowLocally } from "@tailor-platform/sdk/vitest"; import { describe, expect, test, vi } from "vitest"; import workflow, { fulfillOrder, @@ -9,18 +10,24 @@ import workflow, { describe("order fulfillment workflow", () => { describe("individual job tests with .body()", () => { test("validateOrder accepts valid order", () => { - const result = validateOrder.body({ orderId: "order-1", amount: 100 }, { env: {} }); + const result = validateOrder.body( + { orderId: "order-1", amount: 100 }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ valid: true, orderId: "order-1" }); }); test("validateOrder rejects zero amount", () => { - expect(() => validateOrder.body({ orderId: "order-1", amount: 0 }, { env: {} })).toThrow( - "Order amount must be positive", - ); + expect(() => + validateOrder.body({ orderId: "order-1", amount: 0 }, { env: {}, invoker: null }), + ).toThrow("Order amount must be positive"); }); test("processPayment returns transaction", () => { - const result = processPayment.body({ orderId: "order-1", amount: 100 }, { env: {} }); + const result = processPayment.body( + { orderId: "order-1", amount: 100 }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ transactionId: "txn-order-1", amount: 100, @@ -31,7 +38,7 @@ describe("order fulfillment workflow", () => { test("sendConfirmation returns confirmation", () => { const result = sendConfirmation.body( { orderId: "order-1", transactionId: "txn-1" }, - { env: {} }, + { env: {}, invoker: null }, ); expect(result).toEqual({ orderId: "order-1", @@ -43,22 +50,25 @@ describe("order fulfillment workflow", () => { describe("orchestration tests with mocked triggers", () => { test("fulfillOrder chains all jobs", async () => { - using _validateSpy = vi.spyOn(validateOrder, "trigger").mockResolvedValue({ + using _validateSpy = vi.spyOn(validateOrder, "trigger").mockReturnValue({ valid: true, orderId: "order-1", }); - using _paymentSpy = vi.spyOn(processPayment, "trigger").mockResolvedValue({ + using _paymentSpy = vi.spyOn(processPayment, "trigger").mockReturnValue({ transactionId: "txn-order-1", amount: 100, status: "completed" as const, }); - using _confirmSpy = vi.spyOn(sendConfirmation, "trigger").mockResolvedValue({ + using _confirmSpy = vi.spyOn(sendConfirmation, "trigger").mockReturnValue({ orderId: "order-1", transactionId: "txn-order-1", confirmed: true, }); - const result = await fulfillOrder.body({ orderId: "order-1", amount: 100 }, { env: {} }); + const result = await fulfillOrder.body( + { orderId: "order-1", amount: 100 }, + { env: {}, invoker: null }, + ); expect(validateOrder.trigger).toHaveBeenCalledWith({ orderId: "order-1", @@ -81,22 +91,25 @@ describe("order fulfillment workflow", () => { }); test("workflow.mainJob.body() chains all jobs", async () => { - using _validateSpy = vi.spyOn(validateOrder, "trigger").mockResolvedValue({ + using _validateSpy = vi.spyOn(validateOrder, "trigger").mockReturnValue({ valid: true, orderId: "order-2", }); - using _paymentSpy = vi.spyOn(processPayment, "trigger").mockResolvedValue({ + using _paymentSpy = vi.spyOn(processPayment, "trigger").mockReturnValue({ transactionId: "txn-order-2", amount: 200, status: "completed" as const, }); - using _confirmSpy = vi.spyOn(sendConfirmation, "trigger").mockResolvedValue({ + using _confirmSpy = vi.spyOn(sendConfirmation, "trigger").mockReturnValue({ orderId: "order-2", transactionId: "txn-order-2", confirmed: true, }); - const result = await workflow.mainJob.body({ orderId: "order-2", amount: 200 }, { env: {} }); + const result = await workflow.mainJob.body( + { orderId: "order-2", amount: 200 }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ orderId: "order-2", @@ -107,9 +120,9 @@ describe("order fulfillment workflow", () => { }); }); - describe("integration tests with .trigger()", () => { - test("workflow.mainJob.trigger() executes all jobs", async () => { - const result = await workflow.mainJob.trigger({ + describe("integration tests with runWorkflowLocally()", () => { + test("runWorkflowLocally() executes all jobs", async () => { + const result = await runWorkflowLocally(workflow, { orderId: "order-3", amount: 300, }); diff --git a/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.ts b/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.ts index 1634068114..5e6de9b583 100644 --- a/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.ts +++ b/packages/create-sdk/templates/workflow/src/workflow/order-fulfillment.ts @@ -34,8 +34,8 @@ export const sendConfirmation = createWorkflowJob({ export const fulfillOrder = createWorkflowJob({ name: "fulfill-order", - body: async (input: { orderId: string; amount: number }) => { - const validation = await validateOrder.trigger({ + body: (input: { orderId: string; amount: number }) => { + const validation = validateOrder.trigger({ orderId: input.orderId, amount: input.amount, }); @@ -44,12 +44,12 @@ export const fulfillOrder = createWorkflowJob({ throw new Error("Order validation failed"); } - const payment = await processPayment.trigger({ + const payment = processPayment.trigger({ orderId: input.orderId, amount: input.amount, }); - const confirmation = await sendConfirmation.trigger({ + const confirmation = sendConfirmation.trigger({ orderId: input.orderId, transactionId: payment.transactionId, }); diff --git a/packages/create-sdk/templates/workflow/tailor.d.ts b/packages/create-sdk/templates/workflow/tailor.d.ts index 382290f499..762531a0c5 100644 --- a/packages/create-sdk/templates/workflow/tailor.d.ts +++ b/packages/create-sdk/templates/workflow/tailor.d.ts @@ -1,9 +1,9 @@ // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap { + interface Attributes { role: string; } interface AttributeList { diff --git a/packages/create-sdk/templates/workflow/vitest.config.ts b/packages/create-sdk/templates/workflow/vitest.config.ts index cc5905a974..d57ae2c43f 100644 --- a/packages/create-sdk/templates/workflow/vitest.config.ts +++ b/packages/create-sdk/templates/workflow/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: { label: "e2e", color: "green" }, include: ["e2e/**/*.test.ts"], globalSetup: "e2e/globalSetup.ts", + testTimeout: 60_000, }, }, ], diff --git a/packages/sdk-codemod/.oxlintrc.json b/packages/sdk-codemod/.oxlintrc.json index a9488014ab..09c7307e2a 100644 --- a/packages/sdk-codemod/.oxlintrc.json +++ b/packages/sdk-codemod/.oxlintrc.json @@ -28,7 +28,10 @@ "typescript/no-namespace": "error", "typescript/no-require-imports": "error", "typescript/no-unnecessary-type-constraint": "error", - "typescript/no-unsafe-function-type": "error" + "typescript/no-unsafe-function-type": "error", + "tailor-zod/require-object-policy-comment": "error", + "zod/prefer-loose-object": "error", + "zod/prefer-strict-object": "error" }, "overrides": [ { @@ -56,5 +59,6 @@ "prefer-spread": "error" } } - ] + ], + "jsPlugins": ["eslint-plugin-zod", "../../scripts/oxlint/tailor-zod-plugin.cjs"] } diff --git a/packages/sdk-codemod/CHANGELOG.md b/packages/sdk-codemod/CHANGELOG.md index 470a7515d1..625d21ea8b 100644 --- a/packages/sdk-codemod/CHANGELOG.md +++ b/packages/sdk-codemod/CHANGELOG.md @@ -1,5 +1,98 @@ # @tailor-platform/sdk-codemod +## 0.3.0-next.2 +### Minor Changes + + + +- [#1473](https://github.com/tailor-platform/sdk/pull/1473) [`7ddf3c7`](https://github.com/tailor-platform/sdk/commit/7ddf3c716adf85a66a75d554da7730b5406f84b1) Thanks [@dqn](https://github.com/dqn)! - Add the `v2/principal-unify` codemod so `tailor-sdk upgrade` can migrate SDK principal APIs to `TailorPrincipal`. + + +### Patch Changes + + + +- [#1515](https://github.com/tailor-platform/sdk/pull/1515) [`dcf66a1`](https://github.com/tailor-platform/sdk/commit/dcf66a1e648f5287eaea9ea330eb4ad726a4d363) Thanks [@dqn](https://github.com/dqn)! - Rewrite `tailor-sdk apply` to `tailor-sdk deploy` in source files that contain embedded CLI command strings. + + + +- [#1482](https://github.com/tailor-platform/sdk/pull/1482) [`8b5870e`](https://github.com/tailor-platform/sdk/commit/8b5870e85db1efec7647acb98226f8161e3d1583) Thanks [@toiroakr](https://github.com/toiroakr)! - Add LLM-assisted review support to the codemod runner. A codemod can declare `suspiciousPatterns` plus a `prompt`; after running, files whose post-transform content still matches a suspicious pattern are reported as `llmReviews` (in the JSON output and on stderr) together with the codemod's migration prompt. This surfaces the cases a deterministic transform cannot safely complete (e.g. a value reached through a variable) so they can be finished with an LLM. The `auth.invoker(...)` codemod adopts this for its non-literal-argument cases. + + + +- [#1495](https://github.com/tailor-platform/sdk/pull/1495) [`6234022`](https://github.com/tailor-platform/sdk/commit/6234022d7dc03813b8dade831b86f63a5f7a20e6) Thanks [@toiroakr](https://github.com/toiroakr)! - Generate the v2 migration guide (`packages/sdk/docs/migration/v2.md`) from the codemod registry, which is the single source of truth. Each entry renders its name, automation level (Automatic / Partially automatic / Manual), description, optional before/after `examples`, and — for changes the codemods cannot fully migrate on their own — the LLM/manual migration prompt. Run `pnpm codemod:docs:update` to regenerate and `pnpm codemod:docs:check` (wired into `pnpm check`) to verify it is in sync. + + `scriptPath` is now optional, so the registry can also describe codemod-less ("manual") migrations that ship only guidance (`examples` / `prompt` / `suspiciousPatterns`) with no automatic transform. A manual entry with a `prompt` but no scoping pattern is surfaced as a project-wide `llmReviews` entry at runtime. + + Add a `sdk-codemod list` command that prints every registered rule (id, name, kind, version range). + + +- [#1517](https://github.com/tailor-platform/sdk/pull/1517) [`a649764`](https://github.com/tailor-platform/sdk/commit/a6497649be2786b3f6e410c8aa98c4247a599258) Thanks [@dqn](https://github.com/dqn)! - Reduce false-positive v2 codemod warnings and LLM-review prompts from source comments, string literals, and identifier substring matches. + + + +- [#1476](https://github.com/tailor-platform/sdk/pull/1476) [`fa83075`](https://github.com/tailor-platform/sdk/commit/fa83075f5e0e91085c0ef0cb44b7058a28a79ec3) Thanks [@toiroakr](https://github.com/toiroakr)! - `executeScript` now takes its `arg` as a JSON-serializable value instead of a pre-serialized JSON string. Pass the value directly (e.g. `arg: { a: 1 }`) instead of `arg: JSON.stringify({ a: 1 })`. + + Add the `v2/execute-script-arg` codemod, which unwraps `JSON.stringify(...)` passed as the `executeScript` `arg` option. Indirect forms (a stringified value held in a variable, etc.) cannot be rewritten automatically and are surfaced as an LLM-assisted review task with a migration prompt. + + +- [#1518](https://github.com/tailor-platform/sdk/pull/1518) [`ab10b1f`](https://github.com/tailor-platform/sdk/commit/ab10b1fea309ec5496e09bdca394d46d58603f5f) Thanks [@dqn](https://github.com/dqn)! - Reduce noisy `executeScript` LLM-review prompts by flagging files only when unresolved `arg` stringification remains likely. + + + +- [#1509](https://github.com/tailor-platform/sdk/pull/1509) [`7cadaa7`](https://github.com/tailor-platform/sdk/commit/7cadaa7c4987b81130ca80ba80bc5d5b26276394) Thanks [@dqn](https://github.com/dqn)! - Rename resolver, executor, workflow trigger, and typed workflow start machine-user options from `authInvoker` to `invoker`. + + Update create-sdk templates and the v2 auth invoker codemod to generate the new `invoker` option. + + +- [#1519](https://github.com/tailor-platform/sdk/pull/1519) [`4e3fa47`](https://github.com/tailor-platform/sdk/commit/4e3fa47d24e6bb1145eac13c355e976f2d594851) Thanks [@dqn](https://github.com/dqn)! - Limit the `openDownloadStream` migration review prompt to files that reference deprecated download stream APIs. + + + +- [#1521](https://github.com/tailor-platform/sdk/pull/1521) [`2d0689e`](https://github.com/tailor-platform/sdk/commit/2d0689e8ac0079473294fab367799a5431c130f4) Thanks [@dqn](https://github.com/dqn)! - Flag files that need project-specific review after the v2 principal migration, including resolver helper adapters and nullable `caller` follow-ups. + + + +- [#1525](https://github.com/tailor-platform/sdk/pull/1525) [`425a19d`](https://github.com/tailor-platform/sdk/commit/425a19dd58da6e373b739d3b3e838c2ff3d1736a) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency semver to v7.8.5 + + + +- [#1520](https://github.com/tailor-platform/sdk/pull/1520) [`ed3d338`](https://github.com/tailor-platform/sdk/commit/ed3d338ce71d68904ef1fb83afbbd06a7e5f6973) Thanks [@dqn](https://github.com/dqn)! - Flag files that still reference ambient Tailor runtime globals so the v2 migration can opt them into `@tailor-platform/sdk/runtime/globals`. + + + +- [#1439](https://github.com/tailor-platform/sdk/pull/1439) [`c5b10d2`](https://github.com/tailor-platform/sdk/commit/c5b10d2841ded08927285bce538c05220cde5e4c) Thanks [@dqn](https://github.com/dqn)! - Unify function principal context around `TailorPrincipal`. + + Resolver contexts now use `caller` and `invoker` as `TailorPrincipal | null`, workflow and executor invokers also use `TailorPrincipal | null`, and event executor `actor` uses `TailorPrincipal | null` with `id`/`type` fields. The legacy `TailorUser`, `TailorInvoker`, `TailorActor`, `TailorActorType`, and `unauthenticatedTailorUser` exports are removed. + +## 0.3.0-next.1 +### Patch Changes + + + +- [#1460](https://github.com/tailor-platform/sdk/pull/1460) [`f49c6d1`](https://github.com/tailor-platform/sdk/commit/f49c6d1b5a856969cb4e04ae7d3a87ed34aa020f) Thanks [@dqn](https://github.com/dqn)! - Remove the v1 runtime globals compatibility layer. Importing from `@tailor-platform/sdk` no longer activates the ambient `tailor.*` / `tailordb.*` declarations; opt into globals with `@tailor-platform/sdk/runtime/globals` or use the typed wrappers from `@tailor-platform/sdk/runtime`. + + The capital-cased `Tailordb.*` namespace is removed. If your project still references `Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, or `typeof Tailordb.Client`, migrate before upgrading: run `pnpm dlx @tailor-platform/sdk-codemod v2/tailordb-namespace` to rewrite them to lowercase `tailordb.*`, then add `import "@tailor-platform/sdk/runtime/globals"` so the rewritten references resolve. + + +- [#1457](https://github.com/tailor-platform/sdk/pull/1457) [`84325f8`](https://github.com/tailor-platform/sdk/commit/84325f8602a5631b7c323c997b1425235509920e) Thanks [@dqn](https://github.com/dqn)! - Remove deprecated CLI aliases for the v2 command surface. Use `tailor-sdk deploy` instead of `tailor-sdk apply`, `tailor-sdk crashreport` instead of `tailor-sdk crash-report`, and the hyphenated `--machine-user` option instead of the hidden `--machineuser` alias. + + Fix the v2 CLI rename codemod to migrate the hidden `--machineuser` option to `--machine-user`. + +## 0.3.0-next.0 +### Minor Changes + + + +- [#1435](https://github.com/tailor-platform/sdk/pull/1435) [`49c0cc9`](https://github.com/tailor-platform/sdk/commit/49c0cc99171d7e317a50a18804a21067d89f9493) Thanks [@dqn](https://github.com/dqn)! - Add the `v2/plugin-cli-import` codemod so `tailor-sdk upgrade` rewrites deprecated plugin imports from `@tailor-platform/sdk/cli` (`kyselyTypePlugin`, `enumConstantsPlugin`, `fileUtilsPlugin`, `seedPlugin`) to their dedicated `@tailor-platform/sdk/plugin/*` subpaths, splitting any non-plugin specifiers onto a separate import. + +## 0.3.0 +### Minor Changes + + + +- [#1435](https://github.com/tailor-platform/sdk/pull/1435) [`49c0cc9`](https://github.com/tailor-platform/sdk/commit/49c0cc99171d7e317a50a18804a21067d89f9493) Thanks [@dqn](https://github.com/dqn)! - Add the `v2/plugin-cli-import` codemod so `tailor-sdk upgrade` rewrites deprecated plugin imports from `@tailor-platform/sdk/cli` (`kyselyTypePlugin`, `enumConstantsPlugin`, `fileUtilsPlugin`, `seedPlugin`) to their dedicated `@tailor-platform/sdk/plugin/*` subpaths, splitting any non-plugin specifiers onto a separate import. + ## 0.3.4 ### Patch Changes diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/codemod.yaml b/packages/sdk-codemod/codemods/v2/apply-to-deploy/codemod.yaml index f9a072de50..23c63a08cf 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/codemod.yaml +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/codemod.yaml @@ -1,6 +1,6 @@ name: "@tailor-platform/apply-to-deploy" version: "1.0.0" -description: "Rewrite `tailor-sdk apply` invocations to `tailor-sdk deploy` (the v2 recommended name)" +description: "Rewrite `tailor-sdk apply` invocations to the canonical v2 `tailor-sdk deploy` command" engine: jssg language: text since: "1.0.0" diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/apply-to-deploy/scripts/transform.ts index bf50fec76c..a262d71c2e 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/scripts/transform.ts +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/scripts/transform.ts @@ -2,17 +2,20 @@ import * as path from "pathe"; // Match `tailor-sdk apply` plus the optional `@version` suffix that // package-manager run commands can add (`npx tailor-sdk@latest apply`, -// `pnpm dlx tailor-sdk@1.45.2 apply`). The version pin is preserved because -// `apply` and `deploy` are the same subcommand on the same binary. +// `pnpm dlx tailor-sdk@1.45.2 apply`). The version pin is preserved; only the +// command spelling changes. // `(?![-\w])` excludes both word continuation (`applyConfig`) and dash-suffixed // names (`apply-foo`) so a hypothetical sibling subcommand is not rewritten. -const APPLY_PATTERN = /\btailor-sdk(@[^\s'"`]+)?(\s+)apply(?![-\w])/g; +const ARG_VALUE = `(?:[^\\s'"\`;&|]+|'[^']*'|"(?:(?:\\\\.)|[^"\\\\])*")`; +const BOOLEAN_GLOBAL_ARG = "(?:--verbose|--json|-j)"; +const VALUE_GLOBAL_ARG = "(?:--env-file|--env-file-if-exists|-e)"; +const GLOBAL_ARG_PATTERN = `(?:(?:\\s+${BOOLEAN_GLOBAL_ARG})|(?:\\s+${VALUE_GLOBAL_ARG}(?:=${ARG_VALUE}|\\s+${ARG_VALUE})))*`; +const TAILOR_BINARY = `(? `tailor-sdk${ver ?? ""}${sep}deploy`, - ); + return value.replace(APPLY_PATTERN, (match) => `${match.slice(0, -"apply".length)}deploy`); } function transformText(source: string): string | null { @@ -22,6 +25,14 @@ function transformText(source: string): string | null { return updated === source ? null : updated; } +function transformSourceText(source: string): string | null { + const updated = source + .split(/(\r\n|\n|\r)/) + .map((part, index) => (index % 2 === 0 ? replaceApply(part) : part)) + .join(""); + return updated === source ? null : updated; +} + function transformPackageJson(source: string): string | null { let parsed: Record; try { @@ -52,7 +63,7 @@ function transformPackageJson(source: string): string | null { /** * Replace `tailor-sdk apply` invocations with `tailor-sdk deploy`. * - * `deploy` is a v1 alias of `apply` and the recommended name going forward. + * `deploy` is the canonical v2 command name. * @param source - File contents * @param filePath - Absolute path to the file (used to dispatch package.json vs text) * @returns Transformed source or null when nothing matched. @@ -62,5 +73,6 @@ export default function transform(source: string, filePath: string): string | nu const ext = path.extname(filePath).toLowerCase(); if (ext === ".json") return transformPackageJson(source); + if (SOURCE_EXTENSIONS.has(ext)) return transformSourceText(source); return transformText(source); } diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/expected.json b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/expected.json index cb68f1c59a..9cbc21b33f 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/expected.json +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/expected.json @@ -4,6 +4,8 @@ "scripts": { "deploy:dev": "tailor-sdk deploy --profile dev", "deploy:prod": "tailor-sdk deploy --profile prod -y", + "deploy:json": "tailor-sdk --json deploy --dry-run", + "deploy:env": "tailor-sdk --env-file=.env deploy --yes", "build": "tsc" } } diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/input.json b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/input.json index 26cdfa98a7..2c2ad65587 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/input.json +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/input.json @@ -4,6 +4,8 @@ "scripts": { "deploy:dev": "tailor-sdk apply --profile dev", "deploy:prod": "tailor-sdk apply --profile prod -y", + "deploy:json": "tailor-sdk --json apply --dry-run", + "deploy:env": "tailor-sdk --env-file=.env apply --yes", "build": "tsc" } } diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/expected.sh b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/expected.sh index c3705af3f9..64f8adfff6 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/expected.sh +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/expected.sh @@ -4,3 +4,5 @@ set -euo pipefail pnpm exec tailor-sdk deploy --dry-run npx tailor-sdk deploy -y --profile prod bunx tailor-sdk deploy --no-cache +tailor-sdk --env-file .env deploy --yes +tailor-sdk --json deploy --dry-run diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/input.sh b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/input.sh index f197add5e9..2ccbe800cc 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/input.sh +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/input.sh @@ -4,3 +4,5 @@ set -euo pipefail pnpm exec tailor-sdk apply --dry-run npx tailor-sdk apply -y --profile prod bunx tailor-sdk apply --no-cache +tailor-sdk --env-file .env apply --yes +tailor-sdk --json apply --dry-run diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/no-match/input.sh b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/no-match/input.sh index 004f8c9d8a..9e883b8f57 100644 --- a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/no-match/input.sh +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/no-match/input.sh @@ -7,3 +7,6 @@ echo "How to apply this configuration" pnpm exec tailor-sdk apply-foo # Should not match: word continuation pnpm exec tailor-sdk applyConfig +# Should not match: wrapper or unrelated binary names +tailor-sdk-wrapper apply --yes +my-tailor-sdk apply --yes diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-comment-no-match/input.ts b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-comment-no-match/input.ts new file mode 100644 index 0000000000..b5bd015405 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-comment-no-match/input.ts @@ -0,0 +1,2 @@ +// tailor-sdk +apply(config); diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-fixture/expected.ts b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-fixture/expected.ts new file mode 100644 index 0000000000..7da5667d78 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-fixture/expected.ts @@ -0,0 +1,7 @@ +import { expect, test } from "vitest"; + +test("keeps collected scripts", () => { + expect(JSON.stringify({ scripts: { deploy: "tailor-sdk deploy", seed: "node seed.mjs" } })).toBe( + '{"scripts":{"deploy":"tailor-sdk deploy","seed":"node seed.mjs"}}', + ); +}); diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-fixture/input.ts b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-fixture/input.ts new file mode 100644 index 0000000000..d9b3dd8c06 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-fixture/input.ts @@ -0,0 +1,7 @@ +import { expect, test } from "vitest"; + +test("keeps collected scripts", () => { + expect(JSON.stringify({ scripts: { deploy: "tailor-sdk apply", seed: "node seed.mjs" } })).toBe( + '{"scripts":{"deploy":"tailor-sdk apply","seed":"node seed.mjs"}}', + ); +}); diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-jsx-comment-no-match/input.jsx b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-jsx-comment-no-match/input.jsx new file mode 100644 index 0000000000..b5bd015405 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/source-jsx-comment-no-match/input.jsx @@ -0,0 +1,2 @@ +// tailor-sdk +apply(config); diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/codemod.yaml b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/codemod.yaml new file mode 100644 index 0000000000..4913a398f8 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/auth-attributes-rename" +version: "1.0.0" +description: "Rename auth attribute type names from AttributeMap to Attributes" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/scripts/transform.ts new file mode 100644 index 0000000000..96c5db5d4f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/scripts/transform.ts @@ -0,0 +1,307 @@ +import { parse, Lang } from "@ast-grep/napi"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const SDK_MODULE = "@tailor-platform/sdk"; + +const TYPE_RENAME_MAP: Record = { + AttributeMap: "Attributes", + UserAttributeMap: "UserAttributes", + InferredAttributeMap: "InferredAttributes", +}; + +function quickFilter(source: string): boolean { + return ( + source.includes(SDK_MODULE) && + Object.keys(TYPE_RENAME_MAP).some((name) => source.includes(name)) + ); +} + +function isSdkModuleLiteral(node: SgNode): boolean { + return node.kind() === "string" && /^["']@tailor-platform\/sdk["']$/.test(node.text()); +} + +function hasSdkModuleLiteral(node: SgNode): boolean { + return node.findAll({ rule: { kind: "string" } }).some(isSdkModuleLiteral); +} + +function moduleSpecifierLiteral(node: SgNode): SgNode | undefined { + const directLiteral = node.children().find((child: SgNode) => child.kind() === "string"); + if (directLiteral) return directLiteral; + const moduleNode = node.children().find((child: SgNode) => child.kind() === "module"); + return moduleNode?.children().find((child: SgNode) => child.kind() === "string"); +} + +function hasSdkModuleSpecifier(node: SgNode): boolean { + const literal = moduleSpecifierLiteral(node); + return literal ? isSdkModuleLiteral(literal) : false; +} + +function identifierChildren(node: SgNode): SgNode[] { + return node.children().filter((child: SgNode) => child.kind() === "identifier"); +} + +function typeIdentifierChildren(node: SgNode): SgNode[] { + return node.children().filter((child: SgNode) => child.kind() === "type_identifier"); +} + +function sameRange(a: SgNode, b: SgNode): boolean { + const ar = a.range(); + const br = b.range(); + return ar.start.index === br.start.index && ar.end.index === br.end.index; +} + +function addReplacement( + edits: Edit[], + editedRanges: Set, + node: SgNode, + replacement: string, +): void { + if (node.text() === replacement) return; + const r = node.range(); + const key = `${r.start.index}:${r.end.index}`; + if (editedRanges.has(key)) return; + editedRanges.add(key); + edits.push(node.replace(replacement)); +} + +function renamedType(name: string): string | undefined { + return TYPE_RENAME_MAP[name]; +} + +function isDeclarationName(node: SgNode): boolean { + const parent = node.parent(); + if ( + !parent || + ![ + "class_declaration", + "enum_declaration", + "interface_declaration", + "type_alias_declaration", + "type_parameter", + ].includes(parent.kind()) + ) { + return false; + } + const name = parent?.field("name"); + return !!name && sameRange(name, node); +} + +function isNestedTypeName(node: SgNode): boolean { + return node.parent()?.kind() === "nested_type_identifier"; +} + +function declarationName(node: SgNode): SgNode | undefined { + if ( + [ + "class_declaration", + "enum_declaration", + "interface_declaration", + "type_alias_declaration", + ].includes(node.kind()) + ) { + return node.field("name") ?? undefined; + } + if (node.kind() !== "export_statement") return undefined; + const declaration = node + .children() + .find((child: SgNode) => + [ + "class_declaration", + "enum_declaration", + "interface_declaration", + "type_alias_declaration", + ].includes(child.kind()), + ); + return declaration?.field("name") ?? undefined; +} + +function scopeDeclaresType(scope: SgNode, name: string, reference: SgNode): boolean { + return scope.children().some((child: SgNode) => { + const declaredName = declarationName(child); + return !!declaredName && declaredName.text() === name && !sameRange(declaredName, reference); + }); +} + +function hasTypeParameterShadow(node: SgNode, name: string): boolean { + const parameters = node.findAll({ rule: { kind: "type_parameter" } }); + return parameters.some((parameter) => typeIdentifierChildren(parameter)[0]?.text() === name); +} + +function isShadowedTypeReference(node: SgNode, name: string): boolean { + let current = node.parent(); + while (current) { + if (current.kind() === "statement_block" && scopeDeclaresType(current, name, node)) { + return true; + } + if ( + [ + "class_declaration", + "function_declaration", + "interface_declaration", + "method_definition", + "type_alias_declaration", + ].includes(current.kind()) && + hasTypeParameterShadow(current, name) + ) { + return true; + } + current = current.parent(); + } + return false; +} + +function collectSdkImports( + root: SgNode, + edits: Edit[], + editedRanges: Set, +): { + localTypeRenames: Map; + namespaceNames: Set; +} { + const localTypeRenames = new Map(); + const namespaceNames = new Set(); + const importStmts = root.findAll({ rule: { kind: "import_statement" } }); + + for (const importStmt of importStmts) { + if (!hasSdkModuleSpecifier(importStmt)) continue; + + const namespaceImports = importStmt.findAll({ rule: { kind: "namespace_import" } }); + for (const namespaceImport of namespaceImports) { + const localName = identifierChildren(namespaceImport).at(-1)?.text(); + if (localName) namespaceNames.add(localName); + } + + const specs = importStmt.findAll({ rule: { kind: "import_specifier" } }); + for (const spec of specs) { + const identifiers = identifierChildren(spec); + const imported = identifiers[0]; + if (!imported) continue; + const replacement = renamedType(imported.text()); + if (!replacement) continue; + + addReplacement(edits, editedRanges, imported, replacement); + if (identifiers.length === 1) { + localTypeRenames.set(imported.text(), replacement); + } + } + } + + return { localTypeRenames, namespaceNames }; +} + +function rewriteSdkExports(root: SgNode, edits: Edit[], editedRanges: Set): void { + const exportStmts = root.findAll({ rule: { kind: "export_statement" } }); + for (const exportStmt of exportStmts) { + if (!hasSdkModuleSpecifier(exportStmt)) continue; + + const specs = exportStmt.findAll({ rule: { kind: "export_specifier" } }); + for (const spec of specs) { + const exported = identifierChildren(spec)[0]; + if (!exported) continue; + const replacement = renamedType(exported.text()); + if (replacement) addReplacement(edits, editedRanges, exported, replacement); + } + } +} + +function rewriteModuleAugmentations(root: SgNode, edits: Edit[], editedRanges: Set): void { + const declarations = root.findAll({ rule: { kind: "ambient_declaration" } }); + for (const declaration of declarations) { + if (!hasSdkModuleSpecifier(declaration)) continue; + + const interfaces = declaration.findAll({ rule: { kind: "interface_declaration" } }); + for (const iface of interfaces) { + const name = typeIdentifierChildren(iface)[0]; + if (name?.text() === "AttributeMap") { + addReplacement(edits, editedRanges, name, "Attributes"); + } + } + } +} + +function rewriteLocalTypeReferences( + root: SgNode, + edits: Edit[], + editedRanges: Set, + localTypeRenames: Map, +): void { + if (localTypeRenames.size === 0) return; + + const typeIdentifiers = root.findAll({ rule: { kind: "type_identifier" } }); + for (const typeIdentifier of typeIdentifiers) { + if (isDeclarationName(typeIdentifier) || isNestedTypeName(typeIdentifier)) continue; + const replacement = localTypeRenames.get(typeIdentifier.text()); + if (replacement && isShadowedTypeReference(typeIdentifier, typeIdentifier.text())) continue; + if (replacement) addReplacement(edits, editedRanges, typeIdentifier, replacement); + } +} + +function rewriteNamespaceTypeReferences( + root: SgNode, + edits: Edit[], + editedRanges: Set, + namespaceNames: Set, +): void { + if (namespaceNames.size === 0) return; + + const nestedTypes = root.findAll({ rule: { kind: "nested_type_identifier" } }); + for (const nestedType of nestedTypes) { + const namespaceName = identifierChildren(nestedType)[0]?.text(); + if (!namespaceName || !namespaceNames.has(namespaceName)) continue; + const typeName = typeIdentifierChildren(nestedType).at(-1); + if (!typeName) continue; + const replacement = renamedType(typeName.text()); + if (replacement) addReplacement(edits, editedRanges, typeName, replacement); + } +} + +function isSdkImportCall(node: SgNode): boolean { + return node.kind() === "call_expression" && hasSdkModuleLiteral(node); +} + +function rewriteImportTypeReferences(root: SgNode, edits: Edit[], editedRanges: Set): void { + const members = root.findAll({ rule: { kind: "member_expression" } }); + for (const member of members) { + const object = member.field("object"); + if (!object || !isSdkImportCall(object)) continue; + const property = member.field("property"); + if (!property || property.kind() !== "property_identifier") continue; + const replacement = renamedType(property.text()); + if (replacement) addReplacement(edits, editedRanges, property, replacement); + } +} + +/** + * Rename the v1 auth attribute type API to its v2 names only when a reference + * can be tied to `@tailor-platform/sdk`. + * @param source - File contents + * @param _filePath - Absolute path to the file (kept for the runner signature) + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string, _filePath?: string): string | null { + if (!quickFilter(source)) return null; + + const filePath = _filePath?.toLowerCase(); + const lang = + filePath?.endsWith(".tsx") || filePath?.endsWith(".jsx") + ? Lang.Tsx + : filePath + ? Lang.TypeScript + : source.includes("") + ? Lang.Tsx + : Lang.TypeScript; + const root = parse(lang, source).root(); + + const edits: Edit[] = []; + const editedRanges = new Set(); + + const { localTypeRenames, namespaceNames } = collectSdkImports(root, edits, editedRanges); + rewriteSdkExports(root, edits, editedRanges); + rewriteModuleAugmentations(root, edits, editedRanges); + rewriteLocalTypeReferences(root, edits, editedRanges, localTypeRenames); + rewriteNamespaceTypeReferences(root, edits, editedRanges, namespaceNames); + rewriteImportTypeReferences(root, edits, editedRanges); + + if (edits.length === 0) return null; + return root.commitEdits(edits); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/aliased-type-import/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/aliased-type-import/expected.ts new file mode 100644 index 0000000000..2a0d7226b9 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/aliased-type-import/expected.ts @@ -0,0 +1,7 @@ +import type { + Attributes as AuthAttributes, + UserAttributes as AuthUserAttributes, +} from "@tailor-platform/sdk"; + +type Auth = AuthAttributes; +type User = AuthUserAttributes; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/aliased-type-import/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/aliased-type-import/input.ts new file mode 100644 index 0000000000..17a9483089 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/aliased-type-import/input.ts @@ -0,0 +1,7 @@ +import type { + AttributeMap as AuthAttributes, + UserAttributeMap as AuthUserAttributes, +} from "@tailor-platform/sdk"; + +type Auth = AuthAttributes; +type User = AuthUserAttributes; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/import-type-reference/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/import-type-reference/expected.ts new file mode 100644 index 0000000000..5aebaba690 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/import-type-reference/expected.ts @@ -0,0 +1,2 @@ +type Auth = import("@tailor-platform/sdk").Attributes; +type User = import("@tailor-platform/sdk").UserAttributes; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/import-type-reference/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/import-type-reference/input.ts new file mode 100644 index 0000000000..5dfb882399 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/import-type-reference/input.ts @@ -0,0 +1,2 @@ +type Auth = import("@tailor-platform/sdk").AttributeMap; +type User = import("@tailor-platform/sdk").UserAttributeMap; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/local-interface-untouched/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/local-interface-untouched/input.ts new file mode 100644 index 0000000000..9d197530c2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/local-interface-untouched/input.ts @@ -0,0 +1,5 @@ +interface AttributeMap { + role: string; +} + +type UserAttributeMap = AttributeMap; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-augmentation/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-augmentation/expected.ts new file mode 100644 index 0000000000..cabd68b47a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-augmentation/expected.ts @@ -0,0 +1,11 @@ +declare module "@tailor-platform/sdk" { + interface Attributes { + role: string; + } +} + +declare module "other" { + interface AttributeMap { + role: string; + } +} diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-augmentation/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-augmentation/input.ts new file mode 100644 index 0000000000..38db5e5b6c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-augmentation/input.ts @@ -0,0 +1,11 @@ +declare module "@tailor-platform/sdk" { + interface AttributeMap { + role: string; + } +} + +declare module "other" { + interface AttributeMap { + role: string; + } +} diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-string-untouched/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-string-untouched/input.ts new file mode 100644 index 0000000000..2cbcab0671 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/module-string-untouched/input.ts @@ -0,0 +1,7 @@ +declare module "other" { + type SdkModule = "@tailor-platform/sdk"; + + interface AttributeMap { + role: string; + } +} diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-import/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-import/expected.ts new file mode 100644 index 0000000000..79b7a127f7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-import/expected.ts @@ -0,0 +1,5 @@ +import type * as sdk from "@tailor-platform/sdk"; + +type Auth = sdk.Attributes; +type User = sdk.UserAttributes; +type Inferred = sdk.InferredAttributes<"auth">; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-import/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-import/input.ts new file mode 100644 index 0000000000..c794349239 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-import/input.ts @@ -0,0 +1,5 @@ +import type * as sdk from "@tailor-platform/sdk"; + +type Auth = sdk.AttributeMap; +type User = sdk.UserAttributeMap; +type Inferred = sdk.InferredAttributeMap<"auth">; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-local-interface/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-local-interface/expected.ts new file mode 100644 index 0000000000..9537630fb3 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-local-interface/expected.ts @@ -0,0 +1,11 @@ +import type { Attributes } from "@tailor-platform/sdk"; + +namespace Local { + export interface AttributeMap { + local: string; + } + + export type LocalAttrs = AttributeMap; +} + +type SdkAttrs = Attributes; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-local-interface/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-local-interface/input.ts new file mode 100644 index 0000000000..77b1d1a0f6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/namespace-local-interface/input.ts @@ -0,0 +1,11 @@ +import type { AttributeMap } from "@tailor-platform/sdk"; + +namespace Local { + export interface AttributeMap { + local: string; + } + + export type LocalAttrs = AttributeMap; +} + +type SdkAttrs = AttributeMap; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/re-export/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/re-export/expected.ts new file mode 100644 index 0000000000..5f55784851 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/re-export/expected.ts @@ -0,0 +1,2 @@ +export type { Attributes, InferredAttributes, UserAttributes as AuthUserAttributeMap } from "@tailor-platform/sdk"; +export type { AttributeMap as OtherAttributeMap } from "other"; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/re-export/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/re-export/input.ts new file mode 100644 index 0000000000..98ce0945a8 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/re-export/input.ts @@ -0,0 +1,2 @@ +export type { AttributeMap, InferredAttributeMap, UserAttributeMap as AuthUserAttributeMap } from "@tailor-platform/sdk"; +export type { AttributeMap as OtherAttributeMap } from "other"; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/runtime-attribute-map-untouched/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/runtime-attribute-map-untouched/input.ts new file mode 100644 index 0000000000..5f9ef5eda6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/runtime-attribute-map-untouched/input.ts @@ -0,0 +1,5 @@ +import { protoMachineUserAttributeMap } from "@tailor-platform/sdk/internal"; + +const attributeMap = { role: "admin" }; + +protoMachineUserAttributeMap(attributeMap); diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/type-imports/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/type-imports/expected.ts new file mode 100644 index 0000000000..d3cbf9e464 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/type-imports/expected.ts @@ -0,0 +1,5 @@ +import type { Attributes, InferredAttributes, UserAttributes } from "@tailor-platform/sdk"; + +type AuthAttributes = Attributes; +type User = UserAttributes; +type Inferred = InferredAttributes<"auth">; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/type-imports/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/type-imports/input.ts new file mode 100644 index 0000000000..a5665ee211 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/type-imports/input.ts @@ -0,0 +1,5 @@ +import type { AttributeMap, InferredAttributeMap, UserAttributeMap } from "@tailor-platform/sdk"; + +type AuthAttributes = AttributeMap; +type User = UserAttributeMap; +type Inferred = InferredAttributeMap<"auth">; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/typescript-type-assertion/expected.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/typescript-type-assertion/expected.ts new file mode 100644 index 0000000000..85b45d4f52 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/typescript-type-assertion/expected.ts @@ -0,0 +1,4 @@ +import type { Attributes } from "@tailor-platform/sdk"; + +const marker = "/>"; +const attrs = {}; diff --git a/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/typescript-type-assertion/input.ts b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/typescript-type-assertion/input.ts new file mode 100644 index 0000000000..ae0af56853 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-attributes-rename/tests/typescript-type-assertion/input.ts @@ -0,0 +1,4 @@ +import type { AttributeMap } from "@tailor-platform/sdk"; + +const marker = "/>"; +const attrs = {}; diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/codemod.yaml b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/codemod.yaml new file mode 100644 index 0000000000..b6cf264477 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/auth-connection-token-helper" +version: "1.0.0" +description: "Replace defineAuth auth.getConnectionToken() calls with the authconnection runtime wrapper where the auth binding is imported from tailor.config" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/scripts/transform.ts new file mode 100644 index 0000000000..d67776af5b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/scripts/transform.ts @@ -0,0 +1,345 @@ +import { parse, Lang } from "@ast-grep/napi"; +import { + buildAddNamedImportEdit, + findImportStatements, + importBindings, + importSource, + importSpecNames, + isTypeOnlyImport, + localDeclarationNames, +} from "../../../../src/ast-grep-helpers"; +import type { LlmReviewFinding } from "../../../../src/types"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const RUNTIME_MODULE = "@tailor-platform/sdk/runtime"; +const AUTHCONNECTION = "authconnection"; +const GET_CONNECTION_TOKEN = "getConnectionToken"; +const JSX_FILE_EXTENSIONS = new Set([".tsx", ".jsx"]); +const JS_FILE_EXTENSIONS = new Set([".js", ".mjs", ".cjs"]); + +interface AuthImport { + importStmt: SgNode; + localName: string; + spec: SgNode; +} + +interface TokenCall { + objectNode: SgNode; + localName: string; + range: [number, number]; +} + +function quickFilter(source: string): boolean { + return source.includes(GET_CONNECTION_TOKEN); +} + +function sourceLang(filePath: string, source: string): Lang { + const lower = filePath.toLowerCase(); + const extension = lower.slice(lower.lastIndexOf(".")); + if (JSX_FILE_EXTENSIONS.has(extension)) return Lang.Tsx; + if (JS_FILE_EXTENSIONS.has(extension) && /<>|<\/>|<[A-Za-z][\w.$:-]/.test(source)) { + return Lang.Tsx; + } + return Lang.TypeScript; +} + +function parseRoot(source: string, filePath: string): SgNode | null { + if (!quickFilter(source)) return null; + try { + return parse(sourceLang(filePath, source), source).root(); + } catch { + return null; + } +} + +function isTailorConfigSource(source: string): boolean { + return /(^|\/)tailor\.config(?:\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs))?$/.test(source); +} + +function findAuthImports(imports: SgNode[]): AuthImport[] { + const authImports: AuthImport[] = []; + for (const importStmt of imports) { + const source = importSource(importStmt); + if (!source || !isTailorConfigSource(source) || isTypeOnlyImport(importStmt)) continue; + + for (const spec of importStmt.findAll({ rule: { kind: "import_specifier" } })) { + const names = importSpecNames(spec); + if (names?.importedName !== "auth" || names.typeOnly) continue; + authImports.push({ importStmt, localName: names.localName, spec }); + } + } + return authImports; +} + +function runtimeAuthconnectionReference(imports: SgNode[]): string | null { + for (const importStmt of imports) { + if (importSource(importStmt) !== RUNTIME_MODULE || isTypeOnlyImport(importStmt)) continue; + for (const binding of importBindings(importStmt)) { + if (binding.importedName === AUTHCONNECTION && !binding.typeOnly) { + return binding.localName; + } + } + + const clause = importStmt.children().find((child) => child.kind() === "import_clause"); + const namespace = clause?.children().find((child) => child.kind() === "namespace_import"); + const local = namespace?.children().find((child) => child.kind() === "identifier"); + if (local) return `${local.text()}.${AUTHCONNECTION}`; + } + return null; +} + +function hasRuntimeImportCollision(root: SgNode, imports: SgNode[]): boolean { + if (localDeclarationNames(root).has(AUTHCONNECTION)) return true; + return imports.some( + (importStmt) => + importSource(importStmt) !== RUNTIME_MODULE && + importBindings(importStmt).some( + (binding) => binding.localName === AUTHCONNECTION && !binding.typeOnly, + ), + ); +} + +function hasAuthLocalCollision(root: SgNode, authLocalNames: Set): boolean { + const localNames = localDeclarationNames(root); + return Array.from(authLocalNames).some((name) => localNames.has(name)); +} + +function findDirectAuthCalls(root: SgNode, authLocalNames: Set): TokenCall[] { + const calls: TokenCall[] = []; + for (const call of root.findAll({ rule: { kind: "call_expression" } })) { + const callee = call.field("function"); + if (callee?.kind() !== "member_expression") continue; + + const object = callee.field("object"); + const property = callee.field("property"); + if ( + object?.kind() !== "identifier" || + property?.text() !== GET_CONNECTION_TOKEN || + !authLocalNames.has(object.text()) + ) { + continue; + } + + const range = object.range(); + calls.push({ + objectNode: object, + localName: object.text(), + range: [range.start.index, range.end.index], + }); + } + return calls; +} + +function isInsideImportStatement(node: SgNode): boolean { + let current = node.parent(); + while (current) { + if (current.kind() === "import_statement") return true; + current = current.parent(); + } + return false; +} + +function isInsideScheduledRange(node: SgNode, ranges: Array<[number, number]>): boolean { + const start = node.range().start.index; + return ranges.some(([rangeStart, rangeEnd]) => start >= rangeStart && start < rangeEnd); +} + +function countRemainingRefs( + root: SgNode, + localName: string, + scheduledRanges: Array<[number, number]>, +): number { + return root + .findAll({ rule: { any: [{ kind: "identifier" }, { kind: "shorthand_property_identifier" }] } }) + .filter((node) => node.text() === localName) + .filter( + (node) => !isInsideImportStatement(node) && !isInsideScheduledRange(node, scheduledRanges), + ).length; +} + +function importInsertionIndex(root: SgNode, imports: SgNode[], source: string): number { + const lastImport = imports.at(-1); + if (lastImport) return lastImport.range().end.index; + + if (source.startsWith("#!")) { + const newlineIndex = source.indexOf("\n"); + return newlineIndex === -1 ? source.length : newlineIndex + 1; + } + return ( + root + .children() + .find((child) => child.kind() !== "comment") + ?.range().start.index ?? 0 + ); +} + +function buildAddRuntimeImportEdit(root: SgNode, source: string, imports: SgNode[]): Edit { + return buildAddNamedImportEdit({ + importName: AUTHCONNECTION, + imports, + insertionIndex: importInsertionIndex, + moduleName: RUNTIME_MODULE, + root, + source, + }); +} + +function lineStartIndex(source: string, index: number): number { + let pos = index; + while (pos > 0 && source[pos - 1] !== "\n" && source[pos - 1] !== "\r") pos--; + return pos; +} + +function consumeLineBreak(source: string, index: number): number { + if (source[index] === "\r") return source[index + 1] === "\n" ? index + 2 : index + 1; + if (source[index] === "\n") return index + 1; + return index; +} + +function isHorizontalWhitespace(source: string, start: number, end: number): boolean { + for (let index = start; index < end; index++) { + if (source[index] !== " " && source[index] !== "\t") return false; + } + return true; +} + +function buildImportRemovalEdit(source: string, binding: AuthImport): Edit | null { + const allSpecs = binding.importStmt.findAll({ rule: { kind: "import_specifier" } }); + if (allSpecs.length === 1) { + const range = binding.importStmt.range(); + let end = range.end.index; + if (source[end] === "\n") end++; + return { startPos: range.start.index, endPos: end, insertedText: "" }; + } + + const range = binding.spec.range(); + let start = range.start.index; + let end = range.end.index; + const specEnd = end; + while (end < source.length && /[ \t]/.test(source[end]!)) end++; + if (source[end] === ",") { + end++; + while (end < source.length && /[ \t]/.test(source[end]!)) end++; + const nextLine = consumeLineBreak(source, end); + const lineStart = lineStartIndex(source, start); + if (nextLine !== end && isHorizontalWhitespace(source, lineStart, start)) { + return { startPos: lineStart, endPos: nextLine, insertedText: "" }; + } + return { startPos: start, endPos: end, insertedText: "" }; + } + + const nextLine = consumeLineBreak(source, end); + const lineStart = lineStartIndex(source, start); + if (nextLine !== end && isHorizontalWhitespace(source, lineStart, start)) { + return { startPos: lineStart, endPos: nextLine, insertedText: "" }; + } + + while (start > 0 && /[ \t]/.test(source[start - 1]!)) start--; + if (source[start - 1] === ",") start--; + return { startPos: start, endPos: specEnd, insertedText: "" }; +} + +function applyEdits(source: string, edits: Edit[]): string { + return edits + .toSorted((a, b) => b.startPos - a.startPos || b.endPos - a.endPos) + .reduce( + (current, edit) => + `${current.slice(0, edit.startPos)}${edit.insertedText}${current.slice(edit.endPos)}`, + source, + ) + .replace(/^\n+/, ""); +} + +function transformParsed(source: string, root: SgNode): string | null { + const imports = findImportStatements(root); + const authImports = findAuthImports(imports); + if (authImports.length === 0) return null; + + const authLocalNames = new Set(authImports.map((binding) => binding.localName)); + if (hasAuthLocalCollision(root, authLocalNames)) return null; + + const calls = findDirectAuthCalls(root, authLocalNames); + if (calls.length === 0) return null; + + const existingRuntimeRef = runtimeAuthconnectionReference(imports); + if (!existingRuntimeRef && hasRuntimeImportCollision(root, imports)) return null; + + const runtimeRef = existingRuntimeRef ?? AUTHCONNECTION; + const edits: Edit[] = calls.map((call) => call.objectNode.replace(runtimeRef)); + if (!existingRuntimeRef) edits.push(buildAddRuntimeImportEdit(root, source, imports)); + + const scheduledRangesByLocalName = new Map>(); + for (const call of calls) { + const ranges = scheduledRangesByLocalName.get(call.localName) ?? []; + ranges.push(call.range); + scheduledRangesByLocalName.set(call.localName, ranges); + } + + for (const binding of authImports) { + if (!scheduledRangesByLocalName.has(binding.localName)) continue; + const remainingRefs = countRemainingRefs( + root, + binding.localName, + scheduledRangesByLocalName.get(binding.localName) ?? [], + ); + if (remainingRefs > 0) continue; + const edit = buildImportRemovalEdit(source, binding); + if (edit) edits.push(edit); + } + + const result = applyEdits(source, edits); + return result === source ? null : result; +} + +export default function transform(source: string, filePath: string): string | null { + const root = parseRoot(source, filePath); + return root ? transformParsed(source, root) : null; +} + +function lineForIndex(source: string, index: number): number { + return source.slice(0, index).split(/\r\n|\r|\n/).length; +} + +function excerptForLine(line: string): string { + return line.trim(); +} + +function isReviewLine(excerpt: string): boolean { + if (!excerpt.includes(GET_CONNECTION_TOKEN)) return false; + if ( + excerpt.includes(`${AUTHCONNECTION}.${GET_CONNECTION_TOKEN}`) || + excerpt.includes(`tailor.${AUTHCONNECTION}.${GET_CONNECTION_TOKEN}`) + ) { + return false; + } + return ( + excerpt.includes(`.${GET_CONNECTION_TOKEN}`) || + excerpt.includes(`["${GET_CONNECTION_TOKEN}"]`) || + excerpt.includes(`['${GET_CONNECTION_TOKEN}']`) || + new RegExp(`[,{]\\s*${GET_CONNECTION_TOKEN}\\s*[:}=,]`).test(excerpt) + ); +} + +export function reviewFindings( + source: string, + _filePath: string, + relativePath: string, +): LlmReviewFinding[] { + if (!quickFilter(source)) return []; + + const findings: LlmReviewFinding[] = []; + let offset = 0; + for (const line of source.split(/\n/)) { + const excerpt = excerptForLine(line); + if (isReviewLine(excerpt)) { + findings.push({ + file: relativePath, + line: lineForIndex(source, offset), + message: "Replace defineAuth auth.getConnectionToken() with runtime authconnection.", + excerpt, + }); + } + offset += line.length + 1; + } + return findings; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/add-to-existing-runtime-import/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/add-to-existing-runtime-import/expected.ts new file mode 100644 index 0000000000..67f3c4f276 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/add-to-existing-runtime-import/expected.ts @@ -0,0 +1,6 @@ +import { workflow, authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run() { + await workflow.wait("ready"); + return authconnection.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/add-to-existing-runtime-import/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/add-to-existing-runtime-import/input.ts new file mode 100644 index 0000000000..2c9a8d975b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/add-to-existing-runtime-import/input.ts @@ -0,0 +1,7 @@ +import { workflow } from "@tailor-platform/sdk/runtime"; +import { auth } from "../tailor.config"; + +export async function run() { + await workflow.wait("ready"); + return auth.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/auth-shadowed/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/auth-shadowed/input.ts new file mode 100644 index 0000000000..b177f29cd2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/auth-shadowed/input.ts @@ -0,0 +1,5 @@ +import { auth } from "../tailor.config"; + +export async function run(auth: { getConnectionToken(name: string): Promise }) { + return auth.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/bare-arrow-shadowed/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/bare-arrow-shadowed/input.ts new file mode 100644 index 0000000000..54de5e380d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/bare-arrow-shadowed/input.ts @@ -0,0 +1,3 @@ +import { auth } from "../tailor.config"; + +export const tokens = ["google"].map(auth => auth.getConnectionToken("google")); diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/basic/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/basic/expected.ts new file mode 100644 index 0000000000..e12e2d9644 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/basic/expected.ts @@ -0,0 +1,6 @@ +import { authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const token = await authconnection.getConnectionToken("google"); + return token; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/basic/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/basic/input.ts new file mode 100644 index 0000000000..b5c86587c6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/basic/input.ts @@ -0,0 +1,6 @@ +import { auth } from "../tailor.config"; + +export async function run() { + const token = await auth.getConnectionToken("google"); + return token; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/catch-shadowed/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/catch-shadowed/input.ts new file mode 100644 index 0000000000..21e6d2f997 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/catch-shadowed/input.ts @@ -0,0 +1,9 @@ +import { auth } from "../tailor.config"; + +export async function run() { + try { + return "ok"; + } catch (auth) { + return auth.getConnectionToken("google"); + } +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/default-value-runtime-reference/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/default-value-runtime-reference/expected.ts new file mode 100644 index 0000000000..ef6b7212dd --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/default-value-runtime-reference/expected.ts @@ -0,0 +1,5 @@ +import { authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run(token = authconnection) { + return authconnection.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/default-value-runtime-reference/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/default-value-runtime-reference/input.ts new file mode 100644 index 0000000000..6cb415f9b5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/default-value-runtime-reference/input.ts @@ -0,0 +1,5 @@ +import { auth } from "../tailor.config"; + +export async function run(token = authconnection) { + return auth.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/keep-auth-import/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/keep-auth-import/expected.ts new file mode 100644 index 0000000000..2fd9d3f155 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/keep-auth-import/expected.ts @@ -0,0 +1,7 @@ +import { auth } from "../tailor.config"; +import { authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const token = await authconnection.getConnectionToken("google"); + return { token, invoker: auth.invoker("manager") }; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/keep-auth-import/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/keep-auth-import/input.ts new file mode 100644 index 0000000000..d702215b02 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/keep-auth-import/input.ts @@ -0,0 +1,6 @@ +import { auth } from "../tailor.config"; + +export async function run() { + const token = await auth.getConnectionToken("google"); + return { token, invoker: auth.invoker("manager") }; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/local-authconnection-collision/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/local-authconnection-collision/input.ts new file mode 100644 index 0000000000..e29367f3d8 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/local-authconnection-collision/input.ts @@ -0,0 +1,7 @@ +import { auth } from "../tailor.config"; + +const authconnection = createClient(); + +export async function run() { + return auth.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/multiline-import-leading-specifier/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/multiline-import-leading-specifier/expected.ts new file mode 100644 index 0000000000..5898d3f2d1 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/multiline-import-leading-specifier/expected.ts @@ -0,0 +1,11 @@ +import { + other, +} from "../tailor.config"; +import { authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run() { + return { + token: await authconnection.getConnectionToken("google"), + other, + }; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/multiline-import-leading-specifier/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/multiline-import-leading-specifier/input.ts new file mode 100644 index 0000000000..c3a496bc27 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/multiline-import-leading-specifier/input.ts @@ -0,0 +1,11 @@ +import { + auth, + other, +} from "../tailor.config"; + +export async function run() { + return { + token: await auth.getConnectionToken("google"), + other, + }; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/remove-last-import-specifier/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/remove-last-import-specifier/expected.ts new file mode 100644 index 0000000000..78370bddae --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/remove-last-import-specifier/expected.ts @@ -0,0 +1,9 @@ +import { other } from "../tailor.config"; +import { authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run() { + return { + token: await authconnection.getConnectionToken("google"), + other, + }; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/remove-last-import-specifier/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/remove-last-import-specifier/input.ts new file mode 100644 index 0000000000..7d6a23893d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/remove-last-import-specifier/input.ts @@ -0,0 +1,8 @@ +import { other, auth } from "../tailor.config"; + +export async function run() { + return { + token: await auth.getConnectionToken("google"), + other, + }; +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/type-only-runtime-import/expected.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/type-only-runtime-import/expected.ts new file mode 100644 index 0000000000..ad4c2010ec --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/type-only-runtime-import/expected.ts @@ -0,0 +1,5 @@ +import { authconnection } from "@tailor-platform/sdk/runtime"; + +export async function run() { + return authconnection.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/type-only-runtime-import/input.ts b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/type-only-runtime-import/input.ts new file mode 100644 index 0000000000..f2b951056f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-connection-token-helper/tests/type-only-runtime-import/input.ts @@ -0,0 +1,6 @@ +import { type authconnection } from "@tailor-platform/sdk/runtime"; +import { auth } from "../tailor.config"; + +export async function run() { + return auth.getConnectionToken("google"); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/codemod.yaml b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/codemod.yaml new file mode 100644 index 0000000000..412acdd9b8 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/auth-invoker-call-unwrap" +version: "1.0.0" +description: 'Replace auth.invoker("name") with the bare string "name" while preserving the authInvoker option key' +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/scripts/transform.ts new file mode 100644 index 0000000000..236865ec54 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/scripts/transform.ts @@ -0,0 +1,5 @@ +import { transformAuthInvoker } from "../../auth-invoker-unwrap/scripts/transform"; + +export default function transform(source: string, filePath: string): string | null { + return transformAuthInvoker(source, filePath, { renameOptionKeys: false }); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/helper-call/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/helper-call/expected.ts new file mode 100644 index 0000000000..7d62ccf473 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/helper-call/expected.ts @@ -0,0 +1,8 @@ +import { db } from "../tailor.config"; + +createResolver({ + name: "orders", + operation: "query", + authInvoker: "kiosk", + body: () => db.type("Order"), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/helper-call/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/helper-call/input.ts new file mode 100644 index 0000000000..6a340cdae2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/helper-call/input.ts @@ -0,0 +1,8 @@ +import { auth, db } from "../tailor.config"; + +createResolver({ + name: "orders", + operation: "query", + authInvoker: auth.invoker("kiosk"), + body: () => db.type("Order"), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/key-only/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/key-only/input.ts new file mode 100644 index 0000000000..10df309cf6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-call-unwrap/tests/key-only/input.ts @@ -0,0 +1,4 @@ +startWorkflow({ + workflow, + authInvoker: "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/codemod.yaml b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/codemod.yaml index fae6d759f0..dfbf7f58f6 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/codemod.yaml +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/codemod.yaml @@ -1,6 +1,6 @@ name: "@tailor-platform/auth-invoker-unwrap" version: "1.0.0" -description: 'Replace `auth.invoker("name")` with the bare string `"name"` and drop the no-longer-needed `auth` import' +description: 'Rename `authInvoker` options to `invoker`, replace `auth.invoker("name")` with the bare string `"name"`, and drop the no-longer-needed `auth` import' engine: jssg language: typescript since: "1.0.0" diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/scripts/transform.ts index 63bbd413b0..cabe0e8eb8 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/scripts/transform.ts +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/scripts/transform.ts @@ -1,10 +1,10 @@ import { parse, Lang } from "@ast-grep/napi"; import type { Edit, SgNode } from "@ast-grep/napi"; -const QUICK_FILTER_NEEDLE = "auth.invoker"; +const QUICK_FILTER_NEEDLES = ["auth.invoker", "authInvoker"]; function quickFilter(source: string): boolean { - return source.includes(QUICK_FILTER_NEEDLE); + return QUICK_FILTER_NEEDLES.some((needle) => source.includes(needle)); } function isInsideImportStatement(node: SgNode): boolean { @@ -139,43 +139,179 @@ function findAuthImports(root: SgNode): SgNode[] { }); } +function sameRange(a: SgNode, b: SgNode): boolean { + const ar = a.range(); + const br = b.range(); + return ar.start.index === br.start.index && ar.end.index === br.end.index; +} + +function keyText(node: SgNode | null): string | null { + if (!node) return null; + return node.text().replace(/^['"]|['"]$/g, ""); +} + +function expressionArguments(args: SgNode): SgNode[] { + return args.children().filter((child) => !["(", ")", ","].includes(child.kind())); +} + +function argumentCallForObject(objectNode: SgNode): { call: SgNode; index: number } | null { + const args = objectNode.parent(); + const call = args?.parent(); + if (args?.kind() !== "arguments" || call?.kind() !== "call_expression") return null; + const index = expressionArguments(args).findIndex((arg) => sameRange(arg, objectNode)); + return index === -1 ? null : { call, index }; +} + +function calleeText(call: SgNode): string { + return call.field("function")?.text() ?? ""; +} + +function isCreateCallOptionObject(objectNode: SgNode, functionName: string): boolean { + const callInfo = argumentCallForObject(objectNode); + return ( + callInfo?.index === 0 && + callInfo.call.field("function")?.kind() === "identifier" && + calleeText(callInfo.call) === functionName + ); +} + +function isExecutorOperationObject(objectNode: SgNode): boolean { + const operationPair = objectNode.parent(); + if (operationPair?.kind() !== "pair" || keyText(operationPair.field("key")) !== "operation") { + return false; + } + const configObject = operationPair.parent(); + return ( + configObject?.kind() === "object" && isCreateCallOptionObject(configObject, "createExecutor") + ); +} + +function isSupportedInvokerOptionObject(objectNode: SgNode): boolean { + return ( + isCreateCallOptionObject(objectNode, "createResolver") || + isCreateCallOptionObject(objectNode, "startWorkflow") || + isExecutorOperationObject(objectNode) + ); +} + +function optionObjectForPairKey(node: SgNode): SgNode | null { + const parent = node.parent(); + if (!parent || parent.kind() !== "pair") return null; + const key = parent.field("key"); + if (!key || !sameRange(key, node)) return null; + const objectNode = parent.parent(); + return objectNode?.kind() === "object" ? objectNode : null; +} + +function isSupportedInvokerOptionKey(node: SgNode): boolean { + const objectNode = optionObjectForPairKey(node) ?? node.parent(); + return objectNode?.kind() === "object" && isSupportedInvokerOptionObject(objectNode); +} + +function isSupportedInvokerValueCall(node: SgNode): boolean { + const pair = node.parent(); + if (pair?.kind() !== "pair") return false; + const value = pair.field("value"); + if (!value || !sameRange(value, node)) return false; + const key = keyText(pair.field("key")); + if (key !== "authInvoker" && key !== "invoker") return false; + const objectNode = pair.parent(); + return objectNode?.kind() === "object" && isSupportedInvokerOptionObject(objectNode); +} + +function findAuthInvokerShorthands(root: SgNode): SgNode[] { + return root + .findAll({ + rule: { + kind: "shorthand_property_identifier", + regex: "^authInvoker$", + }, + }) + .filter(isSupportedInvokerOptionKey); +} + +function findAuthInvokerPropertyKeys(root: SgNode): SgNode[] { + return root + .findAll({ + rule: { + kind: "property_identifier", + regex: "^authInvoker$", + }, + }) + .filter(isSupportedInvokerOptionKey); +} + +function findQuotedAuthInvokerPropertyKeys(root: SgNode): SgNode[] { + return root + .findAll({ + rule: { + kind: "string", + regex: "^['\"]authInvoker['\"]$", + }, + }) + .filter(isSupportedInvokerOptionKey); +} + +function renameQuotedKey(node: SgNode): string { + const quote = node.text().startsWith("'") ? "'" : '"'; + return `${quote}invoker${quote}`; +} + +export interface AuthInvokerTransformOptions { + renameOptionKeys?: boolean; +} + /** - * Replace `auth.invoker("name")` calls with the bare `"name"` string literal. + * Replace `auth.invoker("name")` calls with the bare `"name"` string literal + * and optionally rename `authInvoker:` option keys to `invoker:`. * If no other `auth` references remain after the rewrite, drop the `auth` * specifier (or the entire import line when `auth` was its sole specifier). * - * `auth.invoker()` was deprecated in favor of passing the machine user name - * directly; carrying the `auth` import only for `.invoker()` would otherwise - * pull config-layer (Node-only) modules into runtime bundles. + * `auth.invoker()` was removed in favor of passing the machine user name + * directly to `invoker`; carrying the `auth` import only for `.invoker()` + * would otherwise pull config-layer modules into runtime bundles. * @param source - File contents * @param filePath - Absolute path to the file (kept for the runner signature) + * @param options - Transform behavior flags * @returns Transformed source or null when nothing matched. */ -export default function transform(source: string, _filePath: string): string | null { +export function transformAuthInvoker( + source: string, + _filePath: string, + options: AuthInvokerTransformOptions = {}, +): string | null { if (!quickFilter(source)) return null; + const renameOptionKeys = options.renameOptionKeys ?? true; const lang = source.includes("") ? Lang.Tsx : Lang.TypeScript; const root = parse(lang, source).root(); - const calls = findInvokerCalls(root); - if (calls.length === 0) return null; - + const calls = findInvokerCalls(root).filter((c) => isSupportedInvokerValueCall(c.callNode)); const edits: Edit[] = calls.map((c) => c.callNode.replace(c.argText)); + if (renameOptionKeys) { + edits.push(...findAuthInvokerPropertyKeys(root).map((node) => node.replace("invoker"))); + edits.push( + ...findQuotedAuthInvokerPropertyKeys(root).map((node) => node.replace(renameQuotedKey(node))), + ); + edits.push( + ...findAuthInvokerShorthands(root).map((node) => node.replace("invoker: authInvoker")), + ); + } - const remaining = countRemainingAuthRefs( - root, - calls.map((c) => c.range), - ); - if (remaining === 0) { + if ( + calls.length > 0 && + countRemainingAuthRefs( + root, + calls.map((c) => c.range), + ) === 0 + ) { for (const importStmt of findAuthImports(root)) { const edit = buildAuthImportRemovalEdit(source, importStmt); if (edit) edits.push(edit); } } - if (edits.length === 0) return null; - - let result = root.commitEdits(edits); + let result = edits.length === 0 ? source : root.commitEdits(edits); // Normalize: drop the leading blank line that an import removal at the top // of the file leaves behind, and collapse runs of 3+ newlines. @@ -183,3 +319,7 @@ export default function transform(source: string, _filePath: string): string | n return result === source ? null : result; } + +export default function transform(source: string, filePath: string): string | null { + return transformAuthInvoker(source, filePath); +} diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/basic-resolver/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/basic-resolver/expected.ts deleted file mode 100644 index 70ea71c4bd..0000000000 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/basic-resolver/expected.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createResolver, t } from "@tailor-platform/sdk"; -import orderProcessingWorkflow from "../workflows/order-processing"; - -export default createResolver({ - name: "triggerWorkflow", - type: "Mutation", - input: { - orderId: t.string().description("Order ID"), - customerId: t.string().description("Customer ID for the order"), - }, - body: async ({ input }) => { - const workflowRunId = await orderProcessingWorkflow.trigger( - { - orderId: input.orderId, - customerId: input.customerId, - }, - { authInvoker: "manager-machine-user" }, - ); - return workflowRunId; - }, -}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/comments-and-types/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/comments-and-types/expected.ts new file mode 100644 index 0000000000..4222394844 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/comments-and-types/expected.ts @@ -0,0 +1,10 @@ +export interface Options { + authInvoker?: string; +} + +startWorkflow({ + workflow, + message: "authInvoker: keep this string", + // authInvoker: keep this comment + invoker: "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/comments-and-types/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/comments-and-types/input.ts new file mode 100644 index 0000000000..1254b3554d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/comments-and-types/input.ts @@ -0,0 +1,10 @@ +export interface Options { + authInvoker?: string; +} + +startWorkflow({ + workflow, + message: "authInvoker: keep this string", + // authInvoker: keep this comment + authInvoker: "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/expected.ts index be4be98b8c..8d7d81e0f4 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/expected.ts +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/expected.ts @@ -1,6 +1,8 @@ import { db } from "../tailor.config"; -export const cfg = { - authInvoker: "kiosk", - table: db.type("Order"), -}; +createResolver({ + name: "orders", + operation: "query", + invoker: "kiosk", + body: () => db.type("Order"), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/input.ts index b9f79afd2b..6a340cdae2 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/input.ts +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/input.ts @@ -1,6 +1,8 @@ import { auth, db } from "../tailor.config"; -export const cfg = { +createResolver({ + name: "orders", + operation: "query", authInvoker: auth.invoker("kiosk"), - table: db.type("Order"), -}; + body: () => db.type("Order"), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/expected.ts index 9db289de75..be541bbe7b 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/expected.ts +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/expected.ts @@ -1,8 +1,10 @@ import { auth, db } from "../tailor.config"; -export const cfg = { - authInvoker: "kiosk", +createResolver({ + name: "orders", + operation: "query", + invoker: "kiosk", // `auth` is still referenced below, so the import must be preserved. ownerType: auth.machineUser, - table: db.type("Order"), -}; + body: () => db.type("Order"), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/input.ts index 2381e1fd6d..5be04d7f48 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/input.ts +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/input.ts @@ -1,8 +1,10 @@ import { auth, db } from "../tailor.config"; -export const cfg = { +createResolver({ + name: "orders", + operation: "query", authInvoker: auth.invoker("kiosk"), // `auth` is still referenced below, so the import must be preserved. ownerType: auth.machineUser, - table: db.type("Order"), -}; + body: () => db.type("Order"), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/expected.ts new file mode 100644 index 0000000000..0e98f3a861 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/expected.ts @@ -0,0 +1,10 @@ +import { auth } from "../tailor.config"; + +const machineUserName = "kiosk"; + +startWorkflow({ + workflow, + // The argument is not a literal string, so the call is left intact and the + // `auth` import stays. + invoker: auth.invoker(machineUserName), +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/input.ts index feee54fe23..bb14e53785 100644 --- a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/input.ts +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/input.ts @@ -2,8 +2,9 @@ import { auth } from "../tailor.config"; const machineUserName = "kiosk"; -export const cfg = { +startWorkflow({ + workflow, // The argument is not a literal string, so the call is left intact and the // `auth` import stays. authInvoker: auth.invoker(machineUserName), -}; +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/plain-string-option/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/plain-string-option/expected.ts new file mode 100644 index 0000000000..0cbc9b7168 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/plain-string-option/expected.ts @@ -0,0 +1,4 @@ +startWorkflow({ + workflow, + invoker: "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/plain-string-option/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/plain-string-option/input.ts new file mode 100644 index 0000000000..10df309cf6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/plain-string-option/input.ts @@ -0,0 +1,4 @@ +startWorkflow({ + workflow, + authInvoker: "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/platform-payload-untouched/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/platform-payload-untouched/input.ts new file mode 100644 index 0000000000..50dafc3f01 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/platform-payload-untouched/input.ts @@ -0,0 +1,17 @@ +import { auth } from "../tailor.config"; + +tailor.workflow.triggerWorkflow("daily", {}, { + authInvoker: { namespace, machineUserName }, +}); + +tailor.workflow.triggerWorkflow("daily", {}, { + authInvoker: auth.invoker("kiosk"), +}); + +paymentGateway.trigger("charge", { + authInvoker: "secret", +}); + +paymentGateway.trigger("charge", { + authInvoker: "secret", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/quoted-option/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/quoted-option/expected.ts new file mode 100644 index 0000000000..a9b8b9ca9c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/quoted-option/expected.ts @@ -0,0 +1,4 @@ +startWorkflow({ + workflow, + "invoker": "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/quoted-option/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/quoted-option/input.ts new file mode 100644 index 0000000000..3be9882731 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/quoted-option/input.ts @@ -0,0 +1,4 @@ +startWorkflow({ + workflow, + "authInvoker": "kiosk", +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/shorthand-option/expected.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/shorthand-option/expected.ts new file mode 100644 index 0000000000..b91bf847da --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/shorthand-option/expected.ts @@ -0,0 +1,6 @@ +const authInvoker = "kiosk"; + +startWorkflow({ + workflow, + invoker: authInvoker, +}); diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/shorthand-option/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/shorthand-option/input.ts new file mode 100644 index 0000000000..39b2e41f8b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/shorthand-option/input.ts @@ -0,0 +1,6 @@ +const authInvoker = "kiosk"; + +startWorkflow({ + workflow, + authInvoker, +}); diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/cli-rename/scripts/transform.ts index fbe72152ca..7a498fef49 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/scripts/transform.ts +++ b/packages/sdk-codemod/codemods/v2/cli-rename/scripts/transform.ts @@ -2,26 +2,503 @@ import * as path from "pathe"; // Map of v1 multi-word command names to their v2 single-word replacements. const COMMAND_RENAMES: ReadonlyArray = [["crash-report", "crashreport"]]; +const OPTION_RENAMES: ReadonlyArray = [ + ["--machineuser", "--machine-user"], +]; +const ARG_VALUE = `(?:[^\\s'"\`;&|]+|'[^']*'|"(?:(?:\\\\.)|[^"\\\\])*")`; +const BOOLEAN_GLOBAL_ARG = "(?:--verbose|--json|-j)"; +const VALUE_GLOBAL_ARG = "(?:--env-file|--env-file-if-exists|-e)"; +const GLOBAL_ARG_PATTERN = `(?:(?:\\s+${BOOLEAN_GLOBAL_ARG})|(?:\\s+${VALUE_GLOBAL_ARG}(?:=${ARG_VALUE}|\\s+${ARG_VALUE})))*`; +const TAILOR_BINARY = `(? from).join("|")})\\b`, + `${TAILOR_BINARY}(${GLOBAL_ARG_PATTERN}\\s+)(${COMMAND_RENAMES.map(([from]) => from).join("|")})\\b`, "g", ); +const TAILOR_BINARY_PATTERN = new RegExp(TAILOR_BINARY, "g"); const COMMAND_MAP = new Map(COMMAND_RENAMES); -function replaceAll(value: string): string { - return value.replace( +interface TextRange { + start: number; + end: number; +} + +interface ActiveQuote { + char: "'" | '"'; + escaped: boolean; +} + +function isOptionBoundaryChar(value: string | undefined): boolean { + return value === undefined || !/[\w-]/.test(value); +} + +function findInlineCodeSpanEnd(source: string, start: number): number | undefined { + const lineStart = source.lastIndexOf("\n", start - 1) + 1; + const ticksBefore = [...source.slice(lineStart, start).matchAll(/`/g)].length; + if (ticksBefore % 2 === 0) return undefined; + + const codeSpanEnd = source.indexOf("`", start); + return codeSpanEnd === -1 ? undefined : codeSpanEnd; +} + +function findEnclosingLineQuoteEnd(source: string, start: number): number | undefined { + const lineStart = source.lastIndexOf("\n", start - 1) + 1; + const lineEnd = source.indexOf("\n", start); + const limit = lineEnd === -1 ? source.length : lineEnd; + let quote: "'" | '"' | null = null; + + for (let index = lineStart; index < start; index += 1) { + const ch = source[index]; + if (quote !== null) { + if (ch === "\\" && quote === '"' && index + 1 < start) { + index += 1; + continue; + } + if (ch === quote) { + quote = null; + } + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + } + } + + if (quote === null) return undefined; + + for (let index = start; index < limit; index += 1) { + const ch = source[index]; + if (ch === "\\" && quote === '"' && index + 1 < limit) { + index += 1; + continue; + } + if (ch === quote) { + return index; + } + } + + return undefined; +} + +function lineIndent(line: string): number { + const match = line.match(/^ */); + return match?.[0].length ?? 0; +} + +function isFoldedScalarHeader(line: string): boolean { + return /^\s*(?:-\s*)?[^#\n]*:\s*>[+-]?(?:\s*(?:#.*)?)?$/.test(line); +} + +function findFoldedYamlRanges(source: string): TextRange[] { + const ranges: TextRange[] = []; + const lines = source.match(/^.*(?:\n|$)/gm) ?? []; + let offset = 0; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const body = line.replace(/\r?\n$/, ""); + offset += line.length; + + if (!isFoldedScalarHeader(body)) continue; + + const baseIndent = lineIndent(body); + let rangeStart: number | undefined; + let rangeEnd: number | undefined; + let cursor = offset; + + for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) { + const nextLine = lines[nextIndex]; + const nextBody = nextLine.replace(/\r?\n$/, ""); + const trimmed = nextBody.trim(); + const indent = lineIndent(nextBody); + + if (trimmed !== "" && indent <= baseIndent) break; + + if (trimmed === "") { + if (rangeStart !== undefined && rangeEnd !== undefined) { + ranges.push({ start: rangeStart, end: rangeEnd }); + rangeStart = undefined; + rangeEnd = undefined; + } + } else { + rangeStart ??= cursor; + rangeEnd = cursor + nextLine.length; + } + cursor += nextLine.length; + } + + if (rangeStart !== undefined && rangeEnd !== undefined) { + ranges.push({ start: rangeStart, end: rangeEnd }); + } + } + + return ranges; +} + +function findMarkdownFencedCodeRanges(source: string): TextRange[] { + const ranges: TextRange[] = []; + const lines = source.match(/^.*(?:\n|$)/gm) ?? []; + let offset = 0; + let open: { char: string; length: number; start: number } | undefined; + + for (const line of lines) { + const body = line.replace(/\r?\n$/, ""); + + if (open) { + const close = body.match(/^ {0,3}(`{3,}|~{3,})\s*$/); + const marker = close?.[1]; + if (marker && marker[0] === open.char && marker.length >= open.length) { + ranges.push({ start: open.start, end: offset }); + open = undefined; + } + } else { + const start = body.match(/^ {0,3}(`{3,}|~{3,}).*$/); + const marker = start?.[1]; + if (marker) { + open = { char: marker[0], length: marker.length, start: offset + line.length }; + } + } + + offset += line.length; + } + + if (open) { + ranges.push({ start: open.start, end: source.length }); + } + + return ranges; +} + +function isCommandSeparator(source: string, index: number): boolean { + const ch = source[index]; + const prev = source[index - 1]; + if (prev === "\\") return false; + + if (ch === "&") { + const next = source[index + 1]; + if (prev === ">" || prev === "<" || next === ">") return false; + } + + return ch === ";" || ch === "&" || ch === "|"; +} + +function startsCommandSubstitution(source: string, index: number): boolean { + return source[index] === "$" && source[index + 1] === "(" && source[index - 1] !== "\\"; +} + +function findCommandSubstitutionEnd(source: string, start: number): number | undefined { + let depth = 1; + let index = start + 2; + let quote: ActiveQuote | null = null; + + while (index < source.length) { + const ch = source[index]; + + if (quote !== null) { + if (quote.escaped) { + if (ch === "\\" && source[index + 1] === quote.char) { + quote = null; + index += 2; + continue; + } + index += 1; + continue; + } + if (quote.char === '"' && startsCommandSubstitution(source, index)) { + depth += 1; + index += 2; + continue; + } + if (ch === "\\" && quote.char === '"' && index + 1 < source.length) { + index += 2; + continue; + } + if (ch === quote.char) { + quote = null; + } + index += 1; + continue; + } + + if (ch === "\\" && source[index + 1] === '"') { + quote = { char: '"', escaped: true }; + index += 2; + continue; + } + + if (ch === "'" || ch === '"') { + quote = { char: ch, escaped: false }; + index += 1; + continue; + } + + if (startsCommandSubstitution(source, index)) { + depth += 1; + index += 2; + continue; + } + + if (ch === ")") { + depth -= 1; + if (depth === 0) return index; + } + + index += 1; + } + + return undefined; +} + +function findContainingRange( + ranges: TextRange[] | undefined, + index: number, +): TextRange | undefined { + return ranges?.find((range) => range.start <= index && index < range.end); +} + +function findTailorCommandEnd( + source: string, + start: number, + foldedYamlRanges?: TextRange[], + markdownFencedCodeRanges?: TextRange[], +): number { + const inlineCodeSpanEnd = findInlineCodeSpanEnd(source, start); + const enclosingLineQuoteEnd = findEnclosingLineQuoteEnd(source, start); + const limit = Math.min( + inlineCodeSpanEnd ?? source.length, + enclosingLineQuoteEnd ?? source.length, + ); + const foldedYamlRange = findContainingRange(foldedYamlRanges, start); + const markdownFencedCodeRange = findContainingRange(markdownFencedCodeRanges, start); + const delimitedRange = foldedYamlRange ?? markdownFencedCodeRange; + const commandLimit = delimitedRange ? Math.min(limit, delimitedRange.end) : limit; + let quote: ActiveQuote | null = null; + let end = start; + + while (end < commandLimit) { + const ch = source[end]; + + if (quote !== null) { + if (quote.escaped) { + if (ch === "\\" && source[end + 1] === quote.char) { + quote = null; + end += 2; + continue; + } + end += 1; + continue; + } + if (quote.char === '"' && startsCommandSubstitution(source, end)) { + const substitutionEnd = findCommandSubstitutionEnd(source, end); + if (substitutionEnd !== undefined) { + end = substitutionEnd + 1; + continue; + } + } + if (ch === "\\" && quote.char === '"' && end + 1 < commandLimit) { + end += 2; + continue; + } + if (ch === quote.char) { + quote = null; + } + end += 1; + continue; + } + + if (ch === "\\" && source[end + 1] === '"') { + quote = { char: '"', escaped: true }; + end += 2; + continue; + } + + if (ch === "'" || ch === '"') { + quote = { char: ch, escaped: false }; + end += 1; + continue; + } + + if (startsCommandSubstitution(source, end)) { + const substitutionEnd = findCommandSubstitutionEnd(source, end); + if (substitutionEnd !== undefined) { + end = substitutionEnd + 1; + continue; + } + } + + const prev = source[end - 1]; + if (ch === ")") break; + if (isCommandSeparator(source, end)) break; + if (ch === "\n" && prev !== "\\" && !foldedYamlRange) break; + end += 1; + } + return end; +} + +function isDelimitedCommandContext( + source: string, + start: number, + foldedYamlRanges?: TextRange[], + markdownFencedCodeRanges?: TextRange[], +): boolean { + return ( + findInlineCodeSpanEnd(source, start) !== undefined || + findEnclosingLineQuoteEnd(source, start) !== undefined || + findContainingRange(foldedYamlRanges, start) !== undefined || + findContainingRange(markdownFencedCodeRanges, start) !== undefined + ); +} + +function findOptionRename(command: string, index: number): readonly [string, string] | undefined { + return OPTION_RENAMES.find( + ([from]) => + command.startsWith(from, index) && + isOptionBoundaryChar(command[index - 1]) && + isOptionBoundaryChar(command[index + from.length]), + ); +} + +function replaceOptionsInCommand(command: string): string { + let updated = ""; + let index = 0; + let quote: ActiveQuote | null = null; + + while (index < command.length) { + const ch = command[index]; + + if (quote !== null) { + if (quote.char === '"' && startsCommandSubstitution(command, index)) { + const substitutionEnd = findCommandSubstitutionEnd(command, index); + if (substitutionEnd !== undefined) { + updated += "$("; + updated += replaceAll(command.slice(index + 2, substitutionEnd)); + updated += ")"; + index = substitutionEnd + 1; + continue; + } + } + updated += ch; + if (quote.escaped) { + if (ch === "\\" && command[index + 1] === quote.char) { + index += 1; + updated += command[index]; + quote = null; + } + } else if (ch === "\\" && quote.char === '"' && index + 1 < command.length) { + index += 1; + updated += command[index]; + } else if (ch === quote.char) { + quote = null; + } + index += 1; + continue; + } + + if (ch === "\\" && command[index + 1] === '"') { + quote = { char: '"', escaped: true }; + updated += ch; + index += 1; + updated += command[index]; + index += 1; + continue; + } + + if (ch === "'" || ch === '"') { + quote = { char: ch, escaped: false }; + updated += ch; + index += 1; + continue; + } + + if (startsCommandSubstitution(command, index)) { + const substitutionEnd = findCommandSubstitutionEnd(command, index); + if (substitutionEnd !== undefined) { + updated += "$("; + updated += replaceAll(command.slice(index + 2, substitutionEnd)); + updated += ")"; + index = substitutionEnd + 1; + continue; + } + } + + const rename = findOptionRename(command, index); + if (rename) { + updated += rename[1]; + index += rename[0].length; + continue; + } + + updated += ch; + index += 1; + } + + return updated; +} + +function replaceOptionsInTailorCommands( + source: string, + foldedYamlRanges?: TextRange[], + requireDelimitedContext = false, + markdownFencedCodeRanges?: TextRange[], +): string { + let updated = ""; + let cursor = 0; + TAILOR_BINARY_PATTERN.lastIndex = 0; + + for (;;) { + const match = TAILOR_BINARY_PATTERN.exec(source); + if (!match) break; + + const start = match.index; + if (start < cursor) continue; + + if ( + requireDelimitedContext && + !isDelimitedCommandContext(source, start, foldedYamlRanges, markdownFencedCodeRanges) + ) { + TAILOR_BINARY_PATTERN.lastIndex = start + match[0].length; + continue; + } + + const end = findTailorCommandEnd(source, start, foldedYamlRanges, markdownFencedCodeRanges); + updated += source.slice(cursor, start); + updated += replaceOptionsInCommand(source.slice(start, end)); + cursor = end; + TAILOR_BINARY_PATTERN.lastIndex = end; + } + + return updated + source.slice(cursor); +} + +function replaceAll( + value: string, + parseFoldedYaml = false, + requireDelimitedContext = false, + parseMarkdownFencedCode = false, +): string { + const updated = value.replace( COMMAND_PATTERN, - (_match, ver: string | undefined, sep: string, cmd: string) => - `tailor-sdk${ver ?? ""}${sep}${COMMAND_MAP.get(cmd) ?? cmd}`, + (match, _prefix: string, cmd: string) => + `${match.slice(0, -cmd.length)}${COMMAND_MAP.get(cmd) ?? cmd}`, + ); + const foldedYamlRanges = parseFoldedYaml ? findFoldedYamlRanges(updated) : undefined; + const markdownFencedCodeRanges = parseMarkdownFencedCode + ? findMarkdownFencedCodeRanges(updated) + : undefined; + return replaceOptionsInTailorCommands( + updated, + foldedYamlRanges, + requireDelimitedContext, + markdownFencedCodeRanges, ); } -function transformText(source: string): string | null { - if (!COMMAND_PATTERN.test(source)) return null; - COMMAND_PATTERN.lastIndex = 0; - const updated = replaceAll(source); +function transformText(source: string, filePath: string): string | null { + const ext = path.extname(filePath).toLowerCase(); + const isYaml = ext === ".yml" || ext === ".yaml"; + const isMarkdown = ext === ".md"; + const updated = replaceAll(source, isYaml, isMarkdown, isMarkdown); return updated === source ? null : updated; } @@ -53,13 +530,10 @@ function transformPackageJson(source: string): string | null { /** * Apply v2 CLI naming conventions: multi-word commands collapse into a single - * word (`crash-report` → `crashreport`). Optional `@version` pins on the binary - * (`tailor-sdk@latest`) are preserved. + * word (`crash-report` → `crashreport`), and legacy option spellings are + * rewritten to kebab-case (`--machineuser` → `--machine-user`). Optional + * `@version` pins on the binary (`tailor-sdk@latest`) are preserved. * - * Long options (`--executionId`, `--executorName`, `--jobId`) and the - * positional argument keys with the same names are intentionally not rewritten: - * those tokens are positional in the SDK CLI and never appear as long flags in - * user scripts, so a transform here would have no real-world target. * @param source - File contents * @param filePath - Absolute path to the file (used to dispatch package.json vs text) * @returns Transformed source or null when nothing matched. @@ -67,5 +541,5 @@ function transformPackageJson(source: string): string | null { export default function transform(source: string, filePath: string): string | null { const ext = path.extname(filePath).toLowerCase(); if (ext === ".json") return transformPackageJson(source); - return transformText(source); + return transformText(source, filePath); } diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-markdown/expected.md b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-markdown/expected.md new file mode 100644 index 0000000000..9419f992ee --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-markdown/expected.md @@ -0,0 +1,16 @@ +# CLI migration + +Use `tailor-sdk login --machine-user` before running `tailor-sdk query --machine-user=ci`. + +Use `tailor-sdk --json crashreport list` but leave `other-cli --machineuser=ci` alone. + +Use `pnpm exec tailor-sdk login --machine-user` but leave `other-cli --machineuser=ci` alone. + +```sh +tailor-sdk login --machine-user +tailor-sdk query --machine-user=ci +``` + +Use tailor-sdk login --machineuser before running other-cli --machineuser=ci. + +Do not rewrite unrelated commands such as `other-cli --machineuser=ci`. diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-markdown/input.md b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-markdown/input.md new file mode 100644 index 0000000000..e68df44b3c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-markdown/input.md @@ -0,0 +1,16 @@ +# CLI migration + +Use `tailor-sdk login --machineuser` before running `tailor-sdk query --machineuser=ci`. + +Use `tailor-sdk --json crash-report list` but leave `other-cli --machineuser=ci` alone. + +Use `pnpm exec tailor-sdk login --machineuser` but leave `other-cli --machineuser=ci` alone. + +```sh +tailor-sdk login --machineuser +tailor-sdk query --machineuser=ci +``` + +Use tailor-sdk login --machineuser before running other-cli --machineuser=ci. + +Do not rewrite unrelated commands such as `other-cli --machineuser=ci`. diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/expected.json b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/expected.json index c8a36fedac..0958528e79 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/expected.json +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/expected.json @@ -4,6 +4,9 @@ "scripts": { "report:tail": "tailor-sdk crashreport list", "report:send": "tailor-sdk crashreport send --file ./latest.crash.log", + "report:json": "tailor-sdk --json crashreport list", + "login": "tailor-sdk login --machine-user ci", + "query": "tailor-sdk query --machine-user=ci --json", "build": "tsc" } } diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/input.json b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/input.json index fe43c4b444..92525985ae 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/input.json +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/input.json @@ -4,6 +4,9 @@ "scripts": { "report:tail": "tailor-sdk crash-report list", "report:send": "tailor-sdk crash-report send --file ./latest.crash.log", + "report:json": "tailor-sdk --json crash-report list", + "login": "tailor-sdk login --machineuser ci", + "query": "tailor-sdk query --machineuser=ci --json", "build": "tsc" } } diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/expected.sh b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/expected.sh index 274b449905..faba9713c1 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/expected.sh +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/expected.sh @@ -3,3 +3,13 @@ set -euo pipefail pnpm exec tailor-sdk crashreport list pnpm exec tailor-sdk crashreport send --file ./latest.crash.log +pnpm exec tailor-sdk workflow start approval --machine-user ci +tailor-sdk query --query 'select 1' --machine-user ci +tailor-sdk query 2>&1 --machine-user ci +tailor-sdk query $(build-query --machineuser=ci) --machine-user ci +tailor-sdk query --query 'select 1;' --machine-user ci +tailor-sdk query --query "select 1 | 2" --machine-user ci +tailor-sdk workflow start approval --arg '{"ok":true}' \ + --machine-user ci +tailor-sdk --json crashreport list +TOKEN=$(tailor-sdk query --machine-user ci) other-cli --machineuser=ci diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/input.sh b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/input.sh index 6202aa2aac..5b1573060a 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/input.sh +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/input.sh @@ -3,3 +3,13 @@ set -euo pipefail pnpm exec tailor-sdk crash-report list pnpm exec tailor-sdk crash-report send --file ./latest.crash.log +pnpm exec tailor-sdk workflow start approval --machineuser ci +tailor-sdk query --query 'select 1' --machineuser ci +tailor-sdk query 2>&1 --machineuser ci +tailor-sdk query $(build-query --machineuser=ci) --machineuser ci +tailor-sdk query --query 'select 1;' --machineuser ci +tailor-sdk query --query "select 1 | 2" --machineuser ci +tailor-sdk workflow start approval --arg '{"ok":true}' \ + --machineuser ci +tailor-sdk --json crash-report list +TOKEN=$(tailor-sdk query --machineuser ci) other-cli --machineuser=ci diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/expected.yml b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/expected.yml index 0cddd575f3..af33e2c9ba 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/expected.yml +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/expected.yml @@ -8,3 +8,10 @@ jobs: - run: pnpm install - run: pnpm exec tailor-sdk crashreport list - run: pnpm exec tailor-sdk crashreport send --file latest.crash.log + - run: pnpm exec tailor-sdk login --machine-user ci + - run: > + pnpm exec tailor-sdk login + --machine-user ci + - run: "pnpm exec tailor-sdk query --machine-user ci" + - run: "tailor-sdk query --query \"select 1\" --machine-user ci" + - run: pnpm exec tailor-sdk workflow start approval --machine-user ci diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/input.yml b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/input.yml index 83ab2b5f55..eebb22e0b3 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/input.yml +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/input.yml @@ -8,3 +8,10 @@ jobs: - run: pnpm install - run: pnpm exec tailor-sdk crash-report list - run: pnpm exec tailor-sdk crash-report send --file latest.crash.log + - run: pnpm exec tailor-sdk login --machineuser ci + - run: > + pnpm exec tailor-sdk login + --machineuser ci + - run: "pnpm exec tailor-sdk query --machineuser ci" + - run: "tailor-sdk query --query \"select 1\" --machineuser ci" + - run: pnpm exec tailor-sdk workflow start approval --machineuser ci diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/no-match/input.sh b/packages/sdk-codemod/codemods/v2/cli-rename/tests/no-match/input.sh index a17ea544af..4ad7faf25a 100644 --- a/packages/sdk-codemod/codemods/v2/cli-rename/tests/no-match/input.sh +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/no-match/input.sh @@ -3,8 +3,20 @@ set -euo pipefail # Should not match: command name is part of a longer word pnpm exec tailor-sdk crash-reporter list +tailor-sdk-wrapper crash-report list +my-tailor-sdk crash-report list # Should not match: bare crash-report not preceded by tailor-sdk echo "Generated crash-report uploaded" # Should not match: positional/long-form camelCase identifiers are out of scope pnpm exec tailor-sdk function logs --executionId abc pnpm exec tailor-sdk executor jobs my-executor --jobId xyz +# Should not match: longer option names +pnpm exec tailor-sdk login --machineusername ci +# Should not match: same option spelling for another command +other-cli --machineuser=ci +tailor-sdk-wrapper --machineuser ci +my-tailor-sdk --machineuser ci +# Should not match: same option spelling after a Tailor command substitution +TOKEN=$(tailor-sdk machineuser token ci) other-cli --machineuser=ci +# Should not match: option spelling inside quoted arguments +tailor-sdk query --query 'select --machineuser' diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/codemod.yaml b/packages/sdk-codemod/codemods/v2/env-var-rename/codemod.yaml new file mode 100644 index 0000000000..33ec202996 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/env-var-rename" +version: "1.0.0" +description: "Rewrite removed SDK environment variable names to their v2 `TAILOR_*` names" +engine: jssg +language: text +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/env-var-rename/scripts/transform.ts new file mode 100644 index 0000000000..8ad057c0b4 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/scripts/transform.ts @@ -0,0 +1,100 @@ +import { parse, Lang } from "@ast-grep/napi"; +import * as path from "pathe"; +import type { SgNode } from "@ast-grep/napi"; + +const ENV_RENAMES: ReadonlyArray = [ + ["TAILOR_PLATFORM_SDK_CONFIG_PATH", "TAILOR_CONFIG_PATH"], + ["TAILOR_PLATFORM_SDK_DTS_PATH", "TAILOR_DTS_PATH"], + ["TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION", "TAILOR_CI_ALLOW_ID_INJECTION"], + ["TAILOR_PLATFORM_SDK_BUILD_ONLY", "TAILOR_DEPLOY_BUILD_ONLY"], + ["TAILOR_SDK_OUTPUT_DIR", "TAILOR_BUILD_OUTPUT_DIR"], + ["TAILOR_SDK_SKILLS_SOURCE", "TAILOR_SKILLS_SOURCE"], + ["TAILOR_SDK_VERSION", "TAILOR_TEMPLATE_SDK_VERSION"], + ["TAILOR_ENABLE_INLINE_SOURCEMAP", "TAILOR_INLINE_SOURCEMAP"], + ["TAILOR_PLATFORM_QUERY_NEWLINE_ON_ENTER", "TAILOR_QUERY_NEWLINE_ON_ENTER"], + ["TAILOR_TOKEN", "TAILOR_PLATFORM_TOKEN"], +]; + +const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]); +const ENV_BOUNDARY = "[A-Za-z0-9_]"; +const RENAME_PATTERNS = ENV_RENAMES.map(([from, to]) => ({ + pattern: new RegExp(`(? { + const edits: Array<[number, number, string]> = []; + const visit = (node: SgNode): void => { + if (node.kind() === "string_fragment") { + const range = node.range(); + const text = source.slice(range.start.index, range.end.index); + const replacement = replaceTextTokens(text); + if (replacement !== text) edits.push([range.start.index, range.end.index, replacement]); + return; + } + for (const child of node.children()) { + visit(child); + } + }; + visit(root); + return edits; +} + +function replaceSourceStringFragments(source: string, filePath: string): string { + let root: SgNode; + try { + root = parse(sourceLang(filePath), source).root(); + } catch { + return source; + } + + let updated = source; + const edits = collectStringFragmentEdits(root, source).toSorted(([a], [b]) => b - a); + for (const [start, end, replacement] of edits) { + updated = `${updated.slice(0, start)}${replacement}${updated.slice(end)}`; + } + return updated; +} + +function replaceSourceTokens(source: string, filePath: string): string { + let updated = source; + for (const [from, to] of ENV_RENAMES) { + const escaped = escapeRegExp(from); + updated = updated + .replace( + new RegExp(`\\bprocess\\.env\\.${escaped}(?![A-Za-z0-9_$])`, "g"), + `process.env.${to}`, + ) + .replace( + new RegExp(`\\bprocess\\.env\\[(["'\`])${escaped}\\1\\]`, "g"), + `process.env[$1${to}$1]`, + ) + .replace(new RegExp(`([,{]\\s*)${escaped}(?=\\s*:)`, "g"), `$1${to}`); + } + return replaceSourceStringFragments(updated, filePath); +} + +export default function transform(source: string, filePath: string): string | null { + const ext = path.extname(filePath).toLowerCase(); + const updated = SOURCE_EXTENSIONS.has(ext) + ? replaceSourceTokens(source, filePath) + : replaceTextTokens(source); + return updated === source ? null : updated; +} diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-env/expected.env b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-env/expected.env new file mode 100644 index 0000000000..40665a69a2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-env/expected.env @@ -0,0 +1,13 @@ +TAILOR_CONFIG_PATH=tailor.config.ts +TAILOR_DTS_PATH=types/tailor.d.ts +TAILOR_CI_ALLOW_ID_INJECTION=true +TAILOR_DEPLOY_BUILD_ONLY=true +TAILOR_BUILD_OUTPUT_DIR=.tailor-sdk +TAILOR_SKILLS_SOURCE=./skills +TAILOR_TEMPLATE_SDK_VERSION=2.0.0-next.2 +PLATFORM_URL=https://api.staging.tailor.tech +PLATFORM_OAUTH2_CLIENT_ID=client-id +TAILOR_INLINE_SOURCEMAP=false +TAILOR_QUERY_NEWLINE_ON_ENTER=false +LOG_LEVEL=DEBUG +TAILOR_PLATFORM_TOKEN=token diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-env/input.env b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-env/input.env new file mode 100644 index 0000000000..3dd9234803 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-env/input.env @@ -0,0 +1,13 @@ +TAILOR_PLATFORM_SDK_CONFIG_PATH=tailor.config.ts +TAILOR_PLATFORM_SDK_DTS_PATH=types/tailor.d.ts +TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION=true +TAILOR_PLATFORM_SDK_BUILD_ONLY=true +TAILOR_SDK_OUTPUT_DIR=.tailor-sdk +TAILOR_SDK_SKILLS_SOURCE=./skills +TAILOR_SDK_VERSION=2.0.0-next.2 +PLATFORM_URL=https://api.staging.tailor.tech +PLATFORM_OAUTH2_CLIENT_ID=client-id +TAILOR_ENABLE_INLINE_SOURCEMAP=false +TAILOR_PLATFORM_QUERY_NEWLINE_ON_ENTER=false +LOG_LEVEL=DEBUG +TAILOR_TOKEN=token diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-markdown/expected.md b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-markdown/expected.md new file mode 100644 index 0000000000..8a66a45f55 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-markdown/expected.md @@ -0,0 +1,5 @@ +Configure the SDK with `TAILOR_CONFIG_PATH`. + +```sh +TAILOR_BUILD_OUTPUT_DIR=.tailor-sdk LOG_LEVEL=DEBUG tailor-sdk generate +``` diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-markdown/input.md b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-markdown/input.md new file mode 100644 index 0000000000..6e7b9e5535 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-markdown/input.md @@ -0,0 +1,5 @@ +Configure the SDK with `TAILOR_PLATFORM_SDK_CONFIG_PATH`. + +```sh +TAILOR_SDK_OUTPUT_DIR=.tailor-sdk LOG_LEVEL=DEBUG tailor-sdk generate +``` diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-package-json/expected.json b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-package-json/expected.json new file mode 100644 index 0000000000..f921b7ad72 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-package-json/expected.json @@ -0,0 +1,10 @@ +{ + "scripts": { + "deploy": "TAILOR_DEPLOY_BUILD_ONLY=true tailor-sdk deploy", + "generate": "TAILOR_INLINE_SOURCEMAP=false TAILOR_BUILD_OUTPUT_DIR=.tailor-sdk tailor-sdk generate" + }, + "config": { + "TAILOR_TEMPLATE_SDK_VERSION": "workspace:*", + "PLATFORM_URL": "https://api.tailor.test" + } +} diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-package-json/input.json b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-package-json/input.json new file mode 100644 index 0000000000..36379a1dde --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-package-json/input.json @@ -0,0 +1,10 @@ +{ + "scripts": { + "deploy": "TAILOR_PLATFORM_SDK_BUILD_ONLY=true tailor-sdk deploy", + "generate": "TAILOR_ENABLE_INLINE_SOURCEMAP=false TAILOR_SDK_OUTPUT_DIR=.tailor-sdk tailor-sdk generate" + }, + "config": { + "TAILOR_SDK_VERSION": "workspace:*", + "PLATFORM_URL": "https://api.tailor.test" + } +} diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-source/expected.ts b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-source/expected.ts new file mode 100644 index 0000000000..10e19de4ad --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-source/expected.ts @@ -0,0 +1,11 @@ +const configPath = process.env.TAILOR_CONFIG_PATH; +const dtsPath = process.env["TAILOR_DTS_PATH"]; +const baseUrl = process.env.PLATFORM_URL ?? "https://api.tailor.tech"; +const logLevel = process.env.LOG_LEVEL ?? "DEBUG"; +const token = process.env.TAILOR_PLATFORM_TOKEN; +const env = { LOG_LEVEL: "DEBUG", TAILOR_PLATFORM_TOKEN: token }; +const command = "TAILOR_DEPLOY_BUILD_ONLY=true tailor-sdk deploy"; +const templateCommand = `TAILOR_DEPLOY_BUILD_ONLY=${buildOnly}`; +const localInterpolation = `${TAILOR_TOKEN}`; +const LOG_LEVEL = "local"; +const unchanged = process.env.MY_LOG_LEVEL ?? process.env.TAILOR_TOKEN_BACKUP; diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-source/input.ts b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-source/input.ts new file mode 100644 index 0000000000..c518f83018 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-source/input.ts @@ -0,0 +1,11 @@ +const configPath = process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH; +const dtsPath = process.env["TAILOR_PLATFORM_SDK_DTS_PATH"]; +const baseUrl = process.env.PLATFORM_URL ?? "https://api.tailor.tech"; +const logLevel = process.env.LOG_LEVEL ?? "DEBUG"; +const token = process.env.TAILOR_TOKEN; +const env = { LOG_LEVEL: "DEBUG", TAILOR_TOKEN: token }; +const command = "TAILOR_PLATFORM_SDK_BUILD_ONLY=true tailor-sdk deploy"; +const templateCommand = `TAILOR_PLATFORM_SDK_BUILD_ONLY=${buildOnly}`; +const localInterpolation = `${TAILOR_TOKEN}`; +const LOG_LEVEL = "local"; +const unchanged = process.env.MY_LOG_LEVEL ?? process.env.TAILOR_TOKEN_BACKUP; diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-yaml/expected.yml b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-yaml/expected.yml new file mode 100644 index 0000000000..e8611b3988 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-yaml/expected.yml @@ -0,0 +1,8 @@ +name: deploy +jobs: + deploy: + env: + TAILOR_DEPLOY_BUILD_ONLY: "true" + TAILOR_CI_ALLOW_ID_INJECTION: "true" + steps: + - run: PLATFORM_OAUTH2_CLIENT_ID=client PLATFORM_URL=https://api.tailor.test tailor-sdk login diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-yaml/input.yml b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-yaml/input.yml new file mode 100644 index 0000000000..f18c1f0603 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/basic-yaml/input.yml @@ -0,0 +1,8 @@ +name: deploy +jobs: + deploy: + env: + TAILOR_PLATFORM_SDK_BUILD_ONLY: "true" + TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION: "true" + steps: + - run: PLATFORM_OAUTH2_CLIENT_ID=client PLATFORM_URL=https://api.tailor.test tailor-sdk login diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/no-match/input.sh b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/no-match/input.sh new file mode 100644 index 0000000000..84839d1361 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/no-match/input.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +MY_LOG_LEVEL=debug +myLOG_LEVEL=debug +fooPLATFORM_URL=https://api.example.test +TAILOR_TOKEN_BACKUP=token +echo "$MY_LOG_LEVEL:$myLOG_LEVEL:$fooPLATFORM_URL:$TAILOR_TOKEN_BACKUP" diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/shell-expansion/expected.sh b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/shell-expansion/expected.sh new file mode 100644 index 0000000000..eff8531315 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/shell-expansion/expected.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +echo "$LOG_LEVEL" +echo "${PLATFORM_URL}" +echo "${TAILOR_PLATFORM_TOKEN:-missing}" diff --git a/packages/sdk-codemod/codemods/v2/env-var-rename/tests/shell-expansion/input.sh b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/shell-expansion/input.sh new file mode 100644 index 0000000000..8cf1128c29 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/env-var-rename/tests/shell-expansion/input.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +echo "$LOG_LEVEL" +echo "${PLATFORM_URL}" +echo "${TAILOR_TOKEN:-missing}" diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/codemod.yaml b/packages/sdk-codemod/codemods/v2/execute-script-arg/codemod.yaml new file mode 100644 index 0000000000..68eb43f1ce --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/execute-script-arg" +version: "1.0.0" +description: "Unwrap JSON.stringify(...) passed as the executeScript `arg` option; v2 takes a JSON-serializable value directly" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/scripts/transform.ts new file mode 100644 index 0000000000..84d9bc0338 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/scripts/transform.ts @@ -0,0 +1,72 @@ +import { parse, Lang } from "@ast-grep/napi"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const NEEDLE = "executeScript"; + +function quickFilter(source: string): boolean { + return source.includes(NEEDLE) && source.includes("JSON.stringify"); +} + +function pairKeyText(pair: SgNode): string | null { + const key = pair.children()[0]; + if (!key) return null; + return key.text().replace(/^['"]|['"]$/g, ""); +} + +/** + * True when `stringifyCall` is the value of a top-level `arg:` property in the + * object literal passed directly to `executeScript(...)`. The chain checked is + * `JSON.stringify(...)` → pair (`arg:`) → object → arguments → `executeScript` + * call, so a nested `arg:` (e.g. `executeScript({ opts: { arg: ... } })`) or an + * unrelated `JSON.stringify` is left untouched. + */ +function isExecuteScriptArg(stringifyCall: SgNode): boolean { + const pair = stringifyCall.parent(); + if (!pair || pair.kind() !== "pair") return false; + if (pairKeyText(pair) !== "arg") return false; + + const obj = pair.parent(); + if (!obj || obj.kind() !== "object") return false; + + const args = obj.parent(); + if (!args || args.kind() !== "arguments") return false; + + const call = args.parent(); + if (!call || call.kind() !== "call_expression") return false; + + const callee = call.children()[0]; + return !!callee && callee.text() === NEEDLE; +} + +/** + * Rewrite `executeScript({ ..., arg: JSON.stringify(X), ... })` to + * `executeScript({ ..., arg: X, ... })`. + * + * In v2 the `executeScript` `arg` option takes a JSON-serializable value and + * serializes it internally, so a pre-stringified argument double-encodes. Only + * the literal `arg: JSON.stringify()` form is rewritten; indirect + * forms (a stringified value held in a variable, `JSON.stringify(x, null, 2)`, + * etc.) are left for manual migration. + * @param source - File contents + * @param _filePath - Absolute path to the file (kept for the runner signature) + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string, _filePath: string): string | null { + if (!quickFilter(source)) return null; + + const lang = source.includes("") ? Lang.Tsx : Lang.TypeScript; + const root = parse(lang, source).root(); + + const edits: Edit[] = []; + for (const match of root.findAll({ rule: { pattern: "JSON.stringify($X)" } })) { + if (!isExecuteScriptArg(match)) continue; + const inner = match.getMatch("X"); + if (!inner) continue; + edits.push(match.replace(inner.text())); + } + + if (edits.length === 0) return null; + + const result = root.commitEdits(edits); + return result === source ? null : result; +} diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/basic/expected.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/basic/expected.ts new file mode 100644 index 0000000000..800d5103f6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/basic/expected.ts @@ -0,0 +1,8 @@ +const result = await executeScript({ + client, + workspaceId, + name: "seed.ts", + code: bundledCode, + arg: { users: rows }, + invoker, +}); diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/basic/input.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/basic/input.ts new file mode 100644 index 0000000000..7d02ccb764 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/basic/input.ts @@ -0,0 +1,8 @@ +const result = await executeScript({ + client, + workspaceId, + name: "seed.ts", + code: bundledCode, + arg: JSON.stringify({ users: rows }), + invoker, +}); diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/identifier-arg/expected.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/identifier-arg/expected.ts new file mode 100644 index 0000000000..03a3618170 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/identifier-arg/expected.ts @@ -0,0 +1 @@ +await executeScript({ client, workspaceId, code, arg: payload, invoker }); diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/identifier-arg/input.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/identifier-arg/input.ts new file mode 100644 index 0000000000..665cfdccf5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/identifier-arg/input.ts @@ -0,0 +1 @@ +await executeScript({ client, workspaceId, code, arg: JSON.stringify(payload), invoker }); diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/indirect-variable/input.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/indirect-variable/input.ts new file mode 100644 index 0000000000..40efb78e59 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/indirect-variable/input.ts @@ -0,0 +1,2 @@ +const serialized = JSON.stringify({ users: rows }); +await executeScript({ client, workspaceId, code, arg: serialized, invoker }); diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/multi-arg-stringify/input.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/multi-arg-stringify/input.ts new file mode 100644 index 0000000000..b049d1c7d4 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/multi-arg-stringify/input.ts @@ -0,0 +1 @@ +await executeScript({ client, workspaceId, code, arg: JSON.stringify(payload, null, 2), invoker }); diff --git a/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/non-arg-key/input.ts b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/non-arg-key/input.ts new file mode 100644 index 0000000000..e85e7287f0 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/execute-script-arg/tests/non-arg-key/input.ts @@ -0,0 +1 @@ +await executeScript({ client, workspaceId, code: JSON.stringify(meta), invoker }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/codemod.yaml b/packages/sdk-codemod/codemods/v2/principal-unify/codemod.yaml index 1dd94b1ac3..b6d95370ed 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/codemod.yaml +++ b/packages/sdk-codemod/codemods/v2/principal-unify/codemod.yaml @@ -1,6 +1,6 @@ name: "@tailor-platform/principal-unify" version: "1.0.0" -description: "Unify TailorUser/TailorActor/TailorInvoker into TailorPrincipal and rename resolver body `user` → `caller`" +description: "Unify TailorUser/TailorActor/TailorActorType/TailorInvoker into TailorPrincipal and rename resolver body `user` → `caller`" engine: jssg language: typescript since: "1.0.0" diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/principal-unify/scripts/transform.ts index af16cd7f09..0d40592099 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/scripts/transform.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/scripts/transform.ts @@ -1,15 +1,36 @@ import { parse, Lang } from "@ast-grep/napi"; +import type { LlmReviewFinding } from "../../../../src/types"; import type { Edit, SgNode } from "@ast-grep/napi"; const TYPE_RENAME_MAP: Record = { TailorUser: "TailorPrincipal", TailorActor: "TailorPrincipal", + TailorActorType: "TailorPrincipal", TailorInvoker: "TailorPrincipal", }; const UNAUTHENTICATED = "unauthenticatedTailorUser"; -const QUICK_FILTER_NEEDLES = [...Object.keys(TYPE_RENAME_MAP), UNAUTHENTICATED, "createResolver"]; +const QUICK_FILTER_NEEDLES = [ + ...Object.keys(TYPE_RENAME_MAP), + UNAUTHENTICATED, + "userId", + "userType", + "createResolver", + ".hooks", + ".validate", + ".parse", +]; + +const ACTOR_PROPERTY_RENAME_MAP: Record = { + userId: "id", + userType: "type", +}; + +const ACTOR_TYPE_LITERAL_RENAME_MAP: Record = { + USER_TYPE_USER: "user", + USER_TYPE_MACHINE_USER: "machine_user", +}; function quickFilter(source: string): boolean { if (!source.includes("@tailor-platform/sdk")) return false; @@ -25,14 +46,496 @@ function isInsideImportStatement(node: SgNode): boolean { return false; } -function isMemberExpressionObject(node: SgNode): boolean { +function memberObjectParent(node: SgNode): SgNode | null { const parent = node.parent(); - if (!parent || parent.kind() !== "member_expression") return false; + if ( + !parent || + (parent.kind() !== "member_expression" && parent.kind() !== "subscript_expression") + ) { + return null; + } const obj = parent.field("object"); - if (!obj) return false; + if (!obj) return null; const r = node.range(); const or = obj.range(); - return r.start.index === or.start.index && r.end.index === or.end.index; + if (r.start.index !== or.start.index || r.end.index !== or.end.index) return null; + return parent; +} + +function isMemberExpressionObject(node: SgNode): boolean { + return memberObjectParent(node) !== null; +} + +function optionalPrincipalReadKind(node: SgNode): "property" | "computed" | null { + const parent = memberObjectParent(node); + if (!parent) return null; + if (parent.text().startsWith(`${node.text()}?.`)) return null; + if (isAssignmentTargetReference(node)) return null; + if (parent.kind() === "subscript_expression") return "computed"; + return parent.field("property")?.kind() === "property_identifier" ? "property" : null; +} + +function isOptionalizableMemberObject(node: SgNode): boolean { + return optionalPrincipalReadKind(node) !== null; +} + +function principalIdentifierReplacement(node: SgNode, name: string): string { + const readKind = optionalPrincipalReadKind(node); + if (readKind === "computed") return `${name}?.`; + if (readKind === "property") return `${name}?`; + return name; +} + +function principalPropertyReplacement(node: SgNode, name: string): string { + const parent = node.parent(); + return parent ? principalIdentifierReplacement(parent, name) : name; +} + +function isObjectDestructureInitializer(node: SgNode): boolean { + const parent = node.parent(); + if (!parent || parent.kind() !== "variable_declarator") return false; + const value = parent.field("value"); + if (!value) return false; + const valueRange = value.range(); + const nodeRange = node.range(); + if ( + valueRange.start.index !== nodeRange.start.index || + valueRange.end.index !== nodeRange.end.index + ) { + return false; + } + return parent.field("name")?.kind() === "object_pattern"; +} + +function principalReadReplacement(node: SgNode, name: string): string { + return isObjectDestructureInitializer(node) + ? `${name} ?? {}` + : principalIdentifierReplacement(node, name); +} + +function nodeRangeContains(outer: SgNode, inner: SgNode): boolean { + const outerRange = outer.range(); + const innerRange = inner.range(); + return ( + innerRange.start.index >= outerRange.start.index && innerRange.end.index <= outerRange.end.index + ); +} + +function isAssignmentTargetReference(node: SgNode): boolean { + let current = node; + let parent = current.parent(); + while ( + parent && + (parent.kind() === "member_expression" || parent.kind() === "subscript_expression") + ) { + const object = parent.field("object"); + if (!object || !nodeRangeContains(object, current)) break; + current = parent; + parent = current.parent(); + } + + if (!parent) return false; + if (parent.kind() === "update_expression") return true; + if ( + parent.kind() !== "assignment_expression" && + parent.kind() !== "augmented_assignment_expression" + ) { + return false; + } + const left = parent.field("left"); + return !!left && nodeRangeContains(left, current); +} + +function parseArgumentCall(node: SgNode): SgNode | null { + if (node.kind() !== "shorthand_property_identifier") return null; + const object = node.parent(); + if (!object || object.kind() !== "object") return null; + const args = object.parent(); + if (!args || args.kind() !== "arguments") return null; + const call = args.parent(); + return call && call.kind() === "call_expression" && findMemberCallName(call) === "parse" + ? call + : null; +} + +interface SdkFieldParseContext { + sdkFieldRootNames: Set; + sdkFieldLocalBindings: SdkFieldLocalBinding[]; + root: SgNode; +} + +function isSdkFieldParseArgumentShorthand( + node: SgNode, + parseContext: SdkFieldParseContext, +): boolean { + const call = parseArgumentCall(node); + return call + ? isSdkFieldMemberCall( + call, + parseContext.sdkFieldRootNames, + parseContext.sdkFieldLocalBindings, + parseContext.root, + ) + : false; +} + +function addActorPropertyReplacement( + property: SgNode, + edits: Edit[], + transformedActorPropertyStarts: Set, +): void { + const newName = ACTOR_PROPERTY_RENAME_MAP[property.text()]; + if (!newName) return; + const start = property.range().start.index; + if (transformedActorPropertyStarts.has(start)) return; + transformedActorPropertyStarts.add(start); + edits.push(property.replace(newName)); +} + +function actorTypeLiteralReplacement(literal: SgNode): string | null { + const match = literal + .text() + .match(/^(['"])(USER_TYPE_USER|USER_TYPE_MACHINE_USER|USER_TYPE_UNSPECIFIED)\1$/); + if (!match) return null; + const [, quote, value] = match; + if (value === "USER_TYPE_UNSPECIFIED") return "undefined"; + return `${quote}${ACTOR_TYPE_LITERAL_RENAME_MAP[value]!}${quote}`; +} + +function addActorTypeLiteralReplacement( + literal: SgNode, + edits: Edit[], + transformedLiteralStarts: Set, +): void { + if (literal.kind() !== "string") return; + const replacement = actorTypeLiteralReplacement(literal); + if (!replacement) return; + const start = literal.range().start.index; + if (transformedLiteralStarts.has(start)) return; + transformedLiteralStarts.add(start); + edits.push(literal.replace(replacement)); +} + +function transformActorTypeLiteralsInNode( + node: SgNode, + edits: Edit[], + transformedLiteralStarts: Set, +): void { + if (node.kind() === "string") { + addActorTypeLiteralReplacement(node, edits, transformedLiteralStarts); + } + const literals = node.findAll({ rule: { kind: "string" } }); + for (const literal of literals) { + addActorTypeLiteralReplacement(literal, edits, transformedLiteralStarts); + } +} + +function isTransformedActorTypeMember( + node: SgNode, + transformedActorPropertyStarts: Set, +): boolean { + if (node.kind() !== "member_expression") return false; + const property = node.field("property"); + return ( + property?.text() === "userType" && + transformedActorPropertyStarts.has(property.range().start.index) + ); +} + +function nodeContainsTransformedActorTypeMember( + node: SgNode, + transformedActorPropertyStarts: Set, +): boolean { + if (isTransformedActorTypeMember(node, transformedActorPropertyStarts)) return true; + const members = node.findAll({ rule: { kind: "member_expression" } }); + return members.some((member) => + isTransformedActorTypeMember(member, transformedActorPropertyStarts), + ); +} + +function switchDiscriminant(node: SgNode): SgNode | null { + return node.children().find((child) => child.kind() === "parenthesized_expression") ?? null; +} + +function switchBody(node: SgNode): SgNode | null { + return node.children().find((child) => child.kind() === "switch_body") ?? null; +} + +function transformActorTypeComparisonLiterals( + root: SgNode, + edits: Edit[], + transformedActorPropertyStarts: Set, +): void { + if (transformedActorPropertyStarts.size === 0) return; + const transformedLiteralStarts = new Set(); + const binaries = root.findAll({ rule: { kind: "binary_expression" } }); + for (const binary of binaries) { + if ( + !binary + .children() + .some((child) => isTransformedActorTypeMember(child, transformedActorPropertyStarts)) + ) { + continue; + } + for (const child of binary.children()) { + addActorTypeLiteralReplacement(child, edits, transformedLiteralStarts); + } + } + + const switches = root.findAll({ rule: { kind: "switch_statement" } }); + for (const switchNode of switches) { + const discriminant = switchDiscriminant(switchNode); + if ( + !discriminant || + !nodeContainsTransformedActorTypeMember(discriminant, transformedActorPropertyStarts) + ) { + continue; + } + const body = switchBody(switchNode); + if (!body) continue; + const cases = body.findAll({ rule: { kind: "switch_case" } }); + for (const caseNode of cases) { + for (const child of caseNode.children()) { + addActorTypeLiteralReplacement(child, edits, transformedLiteralStarts); + } + } + } +} + +function transformTailorActorTypeInitializerLiterals( + root: SgNode, + actorTypeLocalNames: Set, + sdkNamespaceNames: Set, + edits: Edit[], +): void { + if (actorTypeLocalNames.size === 0 && sdkNamespaceNames.size === 0) return; + const transformedLiteralStarts = new Set(); + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + if ( + !isSdkTypeReference(decl, actorTypeLocalNames, "TailorActorType", sdkNamespaceNames, root) + ) { + continue; + } + const value = decl.field("value"); + if (!value) continue; + transformActorTypeLiteralsInNode(value, edits, transformedLiteralStarts); + } +} + +function transformActorTypeBindingComparisons( + root: SgNode, + binding: PrincipalLocalBinding, + edits: Edit[], +): void { + const refs = root.findAll({ + rule: { kind: "identifier", regex: `^${escapeRegex(binding.name)}$` }, + }); + const transformedLiteralStarts = new Set(); + for (const ref of refs) { + const binary = ref.parent(); + if (!binary || binary.kind() !== "binary_expression") continue; + if ( + isShadowedLocalReference(root, binding.name, ref.range().start.index, binding.bindingStart) + ) { + continue; + } + for (const child of binary.children()) { + addActorTypeLiteralReplacement(child, edits, transformedLiteralStarts); + } + } + + const switches = root.findAll({ rule: { kind: "switch_statement" } }); + for (const switchNode of switches) { + const discriminant = switchDiscriminant(switchNode); + if (!discriminant) continue; + const refs = discriminant.findAll({ + rule: { kind: "identifier", regex: `^${escapeRegex(binding.name)}$` }, + }); + const matchesBinding = refs.some( + (ref) => + !isShadowedLocalReference( + root, + binding.name, + ref.range().start.index, + binding.bindingStart, + ), + ); + if (!matchesBinding) continue; + const body = switchBody(switchNode); + if (!body) continue; + const cases = body.findAll({ rule: { kind: "switch_case" } }); + for (const caseNode of cases) { + for (const child of caseNode.children()) { + addActorTypeLiteralReplacement(child, edits, transformedLiteralStarts); + } + } + } +} + +function transformTailorActorTypeBindingComparisons( + root: SgNode, + actorTypeLocalNames: Set, + sdkNamespaceNames: Set, + edits: Edit[], +): void { + if (actorTypeLocalNames.size === 0 && sdkNamespaceNames.size === 0) return; + + for (const kind of NESTED_FN_KINDS) { + const fns = root.findAll({ rule: { kind } }); + for (const fn of fns) { + const param = getFirstFunctionParam(fn); + if ( + !param || + !isSdkTypeReference(param, actorTypeLocalNames, "TailorActorType", sdkNamespaceNames, root) + ) { + continue; + } + const pattern = getFunctionParamPattern(param); + const body = fn.field("body"); + if (!pattern || pattern.kind() !== "identifier" || !body) continue; + transformActorTypeBindingComparisons( + body, + { name: pattern.text(), bindingStart: pattern.range().start.index }, + edits, + ); + } + } + + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + if ( + !isSdkTypeReference(decl, actorTypeLocalNames, "TailorActorType", sdkNamespaceNames, root) + ) { + continue; + } + const name = decl.field("name"); + if (!name || name.kind() !== "identifier") continue; + transformActorTypeBindingComparisons( + root, + { name: name.text(), bindingStart: name.range().start.index }, + edits, + ); + } +} + +function transformActorBindingMemberAccesses( + root: SgNode, + binding: PrincipalLocalBinding, + edits: Edit[], + transformedActorPropertyStarts: Set, +): void { + const refs = root.findAll({ + rule: { kind: "identifier", regex: `^${escapeRegex(binding.name)}$` }, + }); + for (const ref of refs) { + const parent = ref.parent(); + if (!parent || parent.kind() !== "member_expression") continue; + const object = parent.field("object"); + if (!object || object.range().start.index !== ref.range().start.index) continue; + const property = parent.field("property"); + if (!property || property.kind() !== "property_identifier") continue; + if (!ACTOR_PROPERTY_RENAME_MAP[property.text()]) continue; + const pos = ref.range().start.index; + if (isShadowedLocalReference(root, binding.name, pos, binding.bindingStart)) continue; + addActorPropertyReplacement(property, edits, transformedActorPropertyStarts); + } +} + +function isSdkTypeReference( + node: SgNode, + localNames: Set, + sdkTypeName: string, + sdkNamespaceNames: Set, + root: SgNode, +): boolean { + const typeAnnotation = node.field("type"); + if (!typeAnnotation) return false; + const typeIds = typeAnnotation.findAll({ rule: { kind: "type_identifier" } }); + return typeIds.some( + (id) => + localNames.has(id.text()) || + (id.text() === sdkTypeName && + isSdkNamespaceQualifiedTypeIdentifier(id, sdkNamespaceNames, root)), + ); +} + +function transformTailorActorTypedMemberAccesses( + root: SgNode, + actorTypeLocalNames: Set, + sdkNamespaceNames: Set, + edits: Edit[], + transformedActorPropertyStarts: Set, +): void { + if (actorTypeLocalNames.size === 0 && sdkNamespaceNames.size === 0) return; + + for (const kind of NESTED_FN_KINDS) { + const fns = root.findAll({ rule: { kind } }); + for (const fn of fns) { + const param = getFirstFunctionParam(fn); + if ( + !param || + !isSdkTypeReference(param, actorTypeLocalNames, "TailorActor", sdkNamespaceNames, root) + ) { + continue; + } + const pattern = getFunctionParamPattern(param); + const body = fn.field("body"); + if (!pattern || pattern.kind() !== "identifier" || !body) continue; + transformActorBindingMemberAccesses( + body, + { name: pattern.text(), bindingStart: pattern.range().start.index }, + edits, + transformedActorPropertyStarts, + ); + } + } + + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + if (!isSdkTypeReference(decl, actorTypeLocalNames, "TailorActor", sdkNamespaceNames, root)) { + continue; + } + const name = decl.field("name"); + if (!name || name.kind() !== "identifier") continue; + transformActorBindingMemberAccesses( + root, + { name: name.text(), bindingStart: name.range().start.index }, + edits, + transformedActorPropertyStarts, + ); + } +} + +function transformExecutorCtxActorAccesses( + body: SgNode, + ctxName: string, + ctxShadowRanges: Array<[number, number]>, + edits: Edit[], + transformedActorPropertyStarts: Set, +): void { + const properties = body.findAll({ + rule: { kind: "property_identifier", regex: "^(userId|userType)$" }, + }); + for (const property of properties) { + const parent = property.parent(); + if (!parent || parent.kind() !== "member_expression") continue; + const object = parent.field("object"); + if (!object || object.kind() !== "member_expression") continue; + const actorProperty = object.field("property"); + if (actorProperty?.text() !== "actor") continue; + const ctxObject = object.field("object"); + if (!ctxObject || ctxObject.kind() !== "identifier" || ctxObject.text() !== ctxName) continue; + const pos = ctxObject.range().start.index; + if (isInsideAnyRange(pos, ctxShadowRanges)) continue; + addActorPropertyReplacement(property, edits, transformedActorPropertyStarts); + } +} + +function renamedTypeIdentifierText(name: string): string | null { + if (name === "TailorInvoker") return "(TailorPrincipal | null)"; + if (name === "TailorActorType") return '(TailorPrincipal["type"] | undefined)'; + return TYPE_RENAME_MAP[name] ?? null; } interface ImportRewriteResult { @@ -82,6 +585,8 @@ function rebuildImportStatement( importStmt: SgNode, globalEmittedRenamed: Set, unauthenticatedLocalNames: Set, + nullableInvokerAliasLocalNames: Set, + actorTypeAliasLocalNames: Set, ): ImportRewriteResult { const importText = importStmt.text(); const isImportType = /^\s*import\s+type\b/.test(importText); @@ -99,16 +604,23 @@ function rebuildImportStatement( const renamed = TYPE_RENAME_MAP[importedName]; if (renamed) { touched = true; - const finalLocal = aliasNode?.text() ?? renamed; + const dropsAliasForNullableInvoker = importedName === "TailorInvoker" && !!aliasNode; + const dropsAliasForActorType = importedName === "TailorActorType" && !!aliasNode; + const dropsAliasForExpandedType = dropsAliasForNullableInvoker || dropsAliasForActorType; + if (dropsAliasForNullableInvoker) nullableInvokerAliasLocalNames.add(localName); + if (dropsAliasForActorType) actorTypeAliasLocalNames.add(localName); + const finalLocal = dropsAliasForExpandedType ? renamed : (aliasNode?.text() ?? renamed); if (seenLocal.has(finalLocal)) continue; // Cross-statement dedupe for non-aliased renames so a file with // `import { TailorUser } from "@tailor-platform/sdk"` and // `import { TailorActor } from "@tailor-platform/sdk"` does not collapse to // two duplicate `import { TailorPrincipal } ...` lines. - if (!aliasNode && globalEmittedRenamed.has(renamed)) continue; + if ((!aliasNode || dropsAliasForExpandedType) && globalEmittedRenamed.has(renamed)) { + continue; + } seenLocal.add(finalLocal); - if (!aliasNode) globalEmittedRenamed.add(renamed); - const asPart = aliasNode ? ` as ${aliasNode.text()}` : ""; + if (!aliasNode || dropsAliasForExpandedType) globalEmittedRenamed.add(renamed); + const asPart = aliasNode && !dropsAliasForExpandedType ? ` as ${aliasNode.text()}` : ""; newSpecTexts.push(`${isTypeOnly ? "type " : ""}${renamed}${asPart}`); } else if (importedName === UNAUTHENTICATED) { touched = true; @@ -201,6 +713,17 @@ function patternBindsName(pat: SgNode, name: string): boolean { return false; } +function hasNestedPropertyPattern(pat: SgNode, name: string): boolean { + if (pat.kind() !== "object_pattern") return false; + for (const child of pat.children()) { + if (child.kind() !== "pair_pattern") continue; + const key = child.field("key"); + if (key?.text() !== name) continue; + if (child.field("value")?.kind() !== "identifier") return true; + } + return false; +} + function functionRebindsName(fn: SgNode, name: string): boolean { const single = fn.field("parameter"); if (single && patternBindsName(single, name)) return true; @@ -299,6 +822,80 @@ function collectAllShadowRanges(root: SgNode, name: string): Array<[number, numb return ranges; } +function hasUnshadowedIdentifierReference(root: SgNode, name: string): boolean { + const shadowRanges = collectAllShadowRanges(root, name); + const refs = root.findAll({ + rule: { kind: "identifier", regex: `^${escapeRegex(name)}$` }, + }); + for (const ref of refs) { + if (!isInsideAnyRange(ref.range().start.index, shadowRanges)) return true; + } + return false; +} + +function hasPrincipalAssignmentTarget(root: SgNode, name: string): boolean { + const shadowRanges = collectAllShadowRanges(root, name); + const refs = root.findAll({ + rule: { kind: "identifier", regex: `^${escapeRegex(name)}$` }, + }); + for (const ref of refs) { + const pos = ref.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + if (isAssignmentTargetReference(ref)) return true; + } + return false; +} + +function rewriteParseArgumentShorthands( + root: SgNode, + localName: string, + propertyName: string, + parseContext: SdkFieldParseContext, + edits: Edit[], +): void { + const shadowRanges = collectAllShadowRanges(root, localName); + const shortRefs = root.findAll({ + rule: { kind: "shorthand_property_identifier", regex: `^${escapeRegex(localName)}$` }, + }); + for (const ref of shortRefs) { + const pos = ref.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + if (!isSdkFieldParseArgumentShorthand(ref, parseContext)) continue; + edits.push(ref.replace(`${propertyName}: ${localName}`)); + } +} + +interface PrincipalLocalBinding { + name: string; + bindingStart: number; +} + +function guardPrincipalMemberAccesses( + root: SgNode, + binding: PrincipalLocalBinding, + edits: Edit[], +): void { + const refs = root.findAll({ + rule: { kind: "identifier", regex: `^${escapeRegex(binding.name)}$` }, + }); + for (const ref of refs) { + if (!isOptionalizableMemberObject(ref)) continue; + const pos = ref.range().start.index; + if (isShadowedLocalReference(root, binding.name, pos, binding.bindingStart)) continue; + edits.push(ref.replace(`${binding.name}?`)); + } + + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const value = decl.field("value"); + if (!value || value.kind() !== "identifier" || value.text() !== binding.name) continue; + if (!isObjectDestructureInitializer(value)) continue; + const pos = value.range().start.index; + if (isShadowedLocalReference(root, binding.name, pos, binding.bindingStart)) continue; + edits.push(value.replace(`${binding.name} ?? {}`)); + } +} + function findResolverBodyArrow(call: SgNode): SgNode | null { const args = call.field("arguments"); if (!args) return null; @@ -318,6 +915,59 @@ function findResolverBodyArrow(call: SgNode): SgNode | null { return null; } +function* iterateNamespaceImportLocalNames(importStmt: SgNode): Generator { + const namespaceImports = importStmt.findAll({ rule: { kind: "namespace_import" } }); + for (const namespaceImport of namespaceImports) { + const localName = namespaceImport.children().find((c: SgNode) => c.kind() === "identifier"); + if (localName) yield localName.text(); + } +} + +function isUnshadowedNamespaceObject( + node: SgNode, + namespaceNames: Set, + root: SgNode, +): boolean { + if (node.kind() !== "identifier" || !namespaceNames.has(node.text())) return false; + return !isInsideAnyRange(node.range().start.index, collectAllShadowRanges(root, node.text())); +} + +function isSdkNamespaceQualifiedTypeIdentifier( + typeId: SgNode, + namespaceNames: Set, + root: SgNode, +): boolean { + if (typeId.kind() !== "type_identifier") return false; + const parent = typeId.parent(); + if (!parent || parent.kind() !== "nested_type_identifier") return false; + const namespaceObject = parent.children().find((c: SgNode) => c.kind() === "identifier"); + return !!namespaceObject && isUnshadowedNamespaceObject(namespaceObject, namespaceNames, root); +} + +function namespaceQualifiedTypeReplacement( + typeId: SgNode, + namespaceNames: Set, + root: SgNode, +): { target: SgNode; text: string } | null { + if (typeId.kind() !== "type_identifier") return null; + const parent = typeId.parent(); + if (!parent || parent.kind() !== "nested_type_identifier") return null; + const namespaceObject = parent.children().find((c: SgNode) => c.kind() === "identifier"); + if (!namespaceObject || !isUnshadowedNamespaceObject(namespaceObject, namespaceNames, root)) { + return null; + } + + const namespace = namespaceObject.text(); + if (typeId.text() === "TailorInvoker") { + return { target: parent, text: `(${namespace}.TailorPrincipal | null)` }; + } + if (typeId.text() === "TailorActorType") { + return { target: parent, text: `(${namespace}.TailorPrincipal["type"] | undefined)` }; + } + const renamed = TYPE_RENAME_MAP[typeId.text()]; + return renamed ? { target: typeId, text: renamed } : null; +} + /** * Look for any binding named `caller` in the resolver body or pattern. When * one exists, renaming `user` → `caller` would either shadow it, collide with @@ -343,22 +993,15 @@ function hasCallerBindingConflict(pattern: SgNode, body: SgNode): boolean { if (inner && inner.text() === "caller") return true; } } - const decls = body.findAll({ - rule: { - kind: "identifier", - regex: "^caller$", - inside: { kind: "variable_declarator" }, - }, - }); - if (decls.length > 0) return true; - const shortDecls = body.findAll({ - rule: { - kind: "shorthand_property_identifier_pattern", - regex: "^caller$", - inside: { kind: "variable_declarator" }, - }, - }); - if (shortDecls.length > 0) return true; + const decls = body.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of decls) { + const nameNode = decl.field("name"); + if (nameNode && patternBindsName(nameNode, "caller")) return true; + } + const functionDecls = body.findAll({ rule: { kind: "function_declaration" } }); + for (const fn of functionDecls) { + if (fn.field("name")?.text() === "caller") return true; + } for (const k of NESTED_FN_KINDS) { const fns = body.findAll({ rule: { kind: k } }); for (const fn of fns) { @@ -368,7 +1011,11 @@ function hasCallerBindingConflict(pattern: SgNode, body: SgNode): boolean { return false; } -function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { +function transformResolverBody( + arrowNode: SgNode, + edits: Edit[], + parseContext: SdkFieldParseContext, +): void { const params = arrowNode.field("parameters") ?? arrowNode.field("parameter") ?? @@ -397,20 +1044,41 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { if (!pattern) return; if (pattern.kind() === "object_pattern") { + if (hasNestedPropertyPattern(pattern, "user")) return; + if (hasPrincipalAssignmentTarget(body, "user")) return; if (hasCallerBindingConflict(pattern, body)) return; + const aliasRenamedUser = hasUnshadowedIdentifierReference(body, "caller"); + let aliasedShorthandUser = false; let renamedShorthandUser = false; + const principalAliasBindings: PrincipalLocalBinding[] = []; // Only iterate top-level pattern children so nested destructures like // `({ input: { user } })` are not mistaken for the resolver context user. for (const child of pattern.children()) { const kind = child.kind(); if (kind === "shorthand_property_identifier_pattern" && child.text() === "user") { - edits.push(child.replace("caller")); - renamedShorthandUser = true; + if (aliasRenamedUser) { + edits.push(child.replace("caller: user")); + aliasedShorthandUser = true; + principalAliasBindings.push({ + name: "user", + bindingStart: child.range().start.index, + }); + } else { + edits.push(child.replace("caller")); + renamedShorthandUser = true; + } } else if (kind === "pair_pattern") { const key = child.field("key"); if (key && key.text() === "user") { edits.push(key.replace("caller")); + const value = child.field("value"); + if (value?.kind() === "identifier") { + principalAliasBindings.push({ + name: value.text(), + bindingStart: value.range().start.index, + }); + } } } else if (kind === "object_assignment_pattern") { // `{ user = fallback }` — the inner shorthand is the binding; default @@ -419,11 +1087,23 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { .children() .find((c: SgNode) => c.kind() === "shorthand_property_identifier_pattern"); if (inner && inner.text() === "user") { - edits.push(inner.replace("caller")); - renamedShorthandUser = true; + if (aliasRenamedUser) { + edits.push(inner.replace("caller: user")); + aliasedShorthandUser = true; + principalAliasBindings.push({ + name: "user", + bindingStart: inner.range().start.index, + }); + } else { + edits.push(inner.replace("caller")); + renamedShorthandUser = true; + } } } } + if (aliasedShorthandUser) { + rewriteParseArgumentShorthands(body, "user", "invoker", parseContext, edits); + } if (renamedShorthandUser) { // Use the broader shadow-range collector here so a nested arrow that // re-binds `user` as a parameter (e.g. `items.map((user) => user.id)`) @@ -434,7 +1114,7 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { for (const ref of refs) { const pos = ref.range().start.index; if (isInsideAnyRange(pos, shadowRanges)) continue; - edits.push(ref.replace("caller")); + edits.push(ref.replace(principalIdentifierReplacement(ref, "caller"))); } // Object literal shorthand (kind: `shorthand_property_identifier`, no // `_pattern` suffix) is both the key and the value. Rewriting it to @@ -448,9 +1128,18 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { for (const ref of shortRefs) { const pos = ref.range().start.index; if (isInsideAnyRange(pos, shadowRanges)) continue; - edits.push(ref.replace("user: caller")); + edits.push( + ref.replace( + isSdkFieldParseArgumentShorthand(ref, parseContext) + ? "invoker: caller" + : "user: caller", + ), + ); } } + for (const binding of principalAliasBindings) { + guardPrincipalMemberAccesses(body, binding, edits); + } return; } @@ -468,7 +1157,7 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { if (!(obj && obj.kind() === "identifier" && obj.text() === ctxName)) continue; const pos = obj.range().start.index; if (isInsideAnyRange(pos, ctxShadowRanges)) continue; - edits.push(propId.replace("caller")); + edits.push(propId.replace(principalPropertyReplacement(propId, "caller"))); } // Also rewrite destructures of the context, e.g. `const { user } = ctx;` → @@ -484,6 +1173,7 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { }, }, }); + const principalAliasBindings: PrincipalLocalBinding[] = []; for (const decl of ctxDestructures) { const pos = decl.range().start.index; if (isInsideAnyRange(pos, ctxShadowRanges)) continue; @@ -493,71 +1183,1765 @@ function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { const k = child.kind(); if (k === "shorthand_property_identifier_pattern" && child.text() === "user") { edits.push(child.replace("caller: user")); + principalAliasBindings.push({ + name: "user", + bindingStart: child.range().start.index, + }); } else if (k === "pair_pattern") { const key = child.field("key"); - if (key && key.text() === "user") { + const value = child.field("value"); + if (key && key.text() === "user" && value?.kind() === "identifier") { edits.push(key.replace("caller")); + principalAliasBindings.push({ + name: value.text(), + bindingStart: value.range().start.index, + }); } } } } + for (const binding of principalAliasBindings) { + guardPrincipalMemberAccesses(body, binding, edits); + } } -/** - * Migrate user/actor/invoker types and identifiers to the unified TailorPrincipal. - * - * - Renames `TailorUser` / `TailorActor` / `TailorInvoker` type references to `TailorPrincipal`. - * - Rewrites SDK imports (including the `/test` subpath) to use `TailorPrincipal` (deduped - * across statements) and drops `unauthenticatedTailorUser`. - * - Replaces standalone references to `unauthenticatedTailorUser` with `null`. Member-access - * forms like `unauthenticatedTailorUser.id` are left alone on purpose so the resulting TS - * error after the import is removed points the author at the broken access. - * - Renames `user` to `caller` for top-level destructured resolver bodies (`{ input, user }`), - * handles aliased pairs (`{ user: currentUser }`) by rewriting only the property name, and - * rewrites `.user` for non-destructured single-param bodies — respecting variable - * shadowing in both directions. - * @param source - TypeScript source text. - * @returns Transformed source or null when nothing matched. - */ -export default function transform(source: string): string | null { - if (!quickFilter(source)) return null; +function findMemberCallName(call: SgNode): string | null { + const fn = call.field("function"); + if (!fn || fn.kind() !== "member_expression") return null; + const property = fn.field("property"); + return property?.text() ?? null; +} - const tree = parse(Lang.TypeScript, source).root(); - const edits: Edit[] = []; +function findMemberCallObject(call: SgNode): SgNode | null { + const fn = call.field("function"); + if (!fn || fn.kind() !== "member_expression") return null; + return fn.field("object"); +} - const sdkImports = tree.findAll({ - rule: { - kind: "import_statement", - has: { kind: "string", regex: "^[\"']@tailor-platform/sdk(/test)?[\"']$" }, - }, - }); +function isFunctionNode(node: SgNode): boolean { + return ( + node.kind() === "arrow_function" || + node.kind() === "function_declaration" || + node.kind() === "function_expression" || + node.kind() === "method_definition" + ); +} - // Only rewrite type identifiers that are imported from the SDK without an - // alias. A local `import type { TailorUser } from './domain'` must stay alone - // even when the file also imports something else from the SDK. - const sdkRenameSourceNames = new Set(); - for (const importStmt of sdkImports) { - for (const { importedName, aliasNode } of iterateImportSpecs(importStmt)) { - if (TYPE_RENAME_MAP[importedName] && !aliasNode) { - sdkRenameSourceNames.add(importedName); +function propertyName(node: SgNode): string | null { + return node.field("key")?.text() ?? node.field("name")?.text() ?? null; +} + +function getFirstFunctionParam(fn: SgNode): SgNode | null { + const params = + fn.field("parameters") ?? + fn.field("parameter") ?? + fn.children().find((c: SgNode) => c.kind() === "formal_parameters"); + if (!params) return null; + if (params.kind() === "object_pattern" || params.kind() === "identifier") { + return params; + } + + return ( + params + .children() + .find( + (c: SgNode) => + c.kind() === "required_parameter" || + c.kind() === "optional_parameter" || + c.kind() === "identifier" || + c.kind() === "object_pattern", + ) ?? null + ); +} + +function getFunctionParamPattern(param: SgNode): SgNode | null { + if (param.kind() === "object_pattern" || param.kind() === "identifier") return param; + return param.field("pattern"); +} + +function findExecutorBodyFunctions(call: SgNode): SgNode[] { + const args = call.field("arguments"); + const objArg = args?.children().find((c: SgNode) => c.kind() === "object"); + if (!objArg) return []; + + const functions: SgNode[] = []; + const visitObject = (object: SgNode): void => { + for (const child of object.children()) { + if (child.kind() === "method_definition") { + if (propertyName(child) === "body") functions.push(child); + continue; + } + if (child.kind() !== "pair") continue; + + const value = child.field("value"); + if (!value) continue; + if (child.field("key")?.text() === "body" && isFunctionNode(value)) { + functions.push(value); + } else if (value.kind() === "object") { + visitObject(value); } } - } + }; - const typeIdents = tree.findAll({ - rule: { - kind: "type_identifier", - not: { inside: { kind: "import_statement" } }, - }, - }); - for (const id of typeIdents) { - if (!sdkRenameSourceNames.has(id.text())) continue; - const newName = TYPE_RENAME_MAP[id.text()]!; - edits.push(id.replace(newName)); + visitObject(objArg); + return functions; +} + +function transformExecutorBodyActorAccesses( + fn: SgNode, + edits: Edit[], + transformedActorPropertyStarts: Set, +): void { + const param = getFirstFunctionParam(fn); + const pattern = param ? getFunctionParamPattern(param) : null; + const body = fn.field("body"); + if (!pattern || !body) return; + + if (pattern.kind() === "identifier") { + const ctxName = pattern.text(); + const ctxShadowRanges = collectCtxShadowRanges(body, ctxName, fn); + transformExecutorCtxActorAccesses( + body, + ctxName, + ctxShadowRanges, + edits, + transformedActorPropertyStarts, + ); + return; } - let importRemoved = false; - const globalEmittedRenamed = new Set(); + if (pattern.kind() !== "object_pattern") return; + + for (const child of pattern.children()) { + const kind = child.kind(); + if (kind === "shorthand_property_identifier_pattern" && child.text() === "actor") { + transformActorBindingMemberAccesses( + body, + { name: "actor", bindingStart: child.range().start.index }, + edits, + transformedActorPropertyStarts, + ); + } else if (kind === "pair_pattern") { + const key = child.field("key"); + const value = child.field("value"); + if (key?.text() === "actor" && value?.kind() === "identifier") { + transformActorBindingMemberAccesses( + body, + { name: value.text(), bindingStart: value.range().start.index }, + edits, + transformedActorPropertyStarts, + ); + } + } + } +} + +interface LocalCallbackTypeBinding { + name: string; + declaration: SgNode; + bindingStart: number; + scope: [number, number]; +} + +interface CallbackTypeContext { + bindings: LocalCallbackTypeBinding[]; + transformedTypeStarts: Set; + transformedPrincipalTypeStarts: Set; + tailorUserTypeLocalNames: Set; + sdkNamespaceNames: Set; + sdkFieldRootNames: Set; + sdkFieldLocalBindings: SdkFieldLocalBinding[]; + root: SgNode; +} + +function nullableTailorUserTypeReplacement( + typeId: SgNode, + typeContext: CallbackTypeContext, +): string | null { + if (typeContext.tailorUserTypeLocalNames.has(typeId.text())) { + return typeId.text() === "TailorUser" ? "TailorPrincipal | null" : `${typeId.text()} | null`; + } + return isSdkNamespaceQualifiedTypeIdentifier( + typeId, + typeContext.sdkNamespaceNames, + typeContext.root, + ) + ? "TailorPrincipal | null" + : null; +} + +function transformObjectTypeUserProperty( + objectType: SgNode, + edits: Edit[], + typeContext: CallbackTypeContext, +): void { + for (const child of objectType.children()) { + if (child.kind() !== "property_signature") continue; + const name = child.field("name"); + if (name?.text() !== "user") continue; + edits.push(name.replace("invoker")); + + const typeAnnotation = child.field("type"); + if (!typeAnnotation || /\bnull\b/.test(typeAnnotation.text())) continue; + const typeIds = typeAnnotation.findAll({ rule: { kind: "type_identifier" } }); + for (const typeId of typeIds) { + const replacement = nullableTailorUserTypeReplacement(typeId, typeContext); + if (!replacement) continue; + typeContext.transformedPrincipalTypeStarts.add(typeId.range().start.index); + edits.push(typeId.replace(replacement)); + } + } +} + +function localCallbackTypeObject(declaration: SgNode): SgNode | null { + return ( + declaration + .children() + .find((c: SgNode) => c.kind() === "object_type" || c.kind() === "interface_body") ?? null + ); +} + +function collectLocalCallbackTypeBindings(root: SgNode): LocalCallbackTypeBinding[] { + const rootScope = localBindingRootScope(root); + const bindings: LocalCallbackTypeBinding[] = []; + + for (const kind of ["type_alias_declaration", "interface_declaration"]) { + const declarations = root.findAll({ rule: { kind } }); + for (const declaration of declarations) { + if (!localCallbackTypeObject(declaration)) continue; + const name = declaration.field("name"); + if (!name || (name.kind() !== "type_identifier" && name.kind() !== "identifier")) continue; + bindings.push({ + name: name.text(), + declaration, + bindingStart: name.range().start.index, + scope: enclosingScopeRange(declaration) ?? rootScope, + }); + } + } + + return bindings; +} + +function isShadowedLocalTypeReference( + root: SgNode, + name: string, + pos: number, + bindingStart: number, +): boolean { + for (const kind of ["type_alias_declaration", "interface_declaration"]) { + const declarations = root.findAll({ rule: { kind } }); + for (const declaration of declarations) { + const nameNode = declaration.field("name"); + if (!nameNode || nameNode.text() !== name) continue; + if (nameNode.range().start.index === bindingStart) continue; + const scope = enclosingScopeRange(declaration); + if (scope && rangeContains(scope, pos)) return true; + } + } + return false; +} + +function resolveLocalCallbackTypeBinding( + node: SgNode, + context: CallbackTypeContext, +): LocalCallbackTypeBinding | null { + const pos = node.range().start.index; + return ( + context.bindings.find( + (binding) => + binding.name === node.text() && + rangeContains(binding.scope, pos) && + !isShadowedLocalTypeReference(context.root, binding.name, pos, binding.bindingStart), + ) ?? null + ); +} + +function transformNamedPrincipalCallbackType( + typeName: SgNode, + edits: Edit[], + context: CallbackTypeContext, +): void { + const binding = resolveLocalCallbackTypeBinding(typeName, context); + if (!binding) return; + + const start = binding.declaration.range().start.index; + if (context.transformedTypeStarts.has(start)) return; + + const objectType = localCallbackTypeObject(binding.declaration); + if (!objectType) return; + context.transformedTypeStarts.add(start); + transformObjectTypeUserProperty(objectType, edits, context); +} + +function transformPrincipalCallbackParamType( + param: SgNode, + edits: Edit[], + typeContext?: CallbackTypeContext, +): void { + const typeAnnotation = param.field("type"); + const objectType = typeAnnotation?.children().find((c: SgNode) => c.kind() === "object_type"); + if (objectType) { + if (typeContext) { + transformObjectTypeUserProperty(objectType, edits, typeContext); + } + return; + } + + const typeName = typeAnnotation?.children().find((c: SgNode) => c.kind() === "type_identifier"); + if (typeName && typeContext) transformNamedPrincipalCallbackType(typeName, edits, typeContext); +} + +function transformPrincipalCallbackParam( + fn: SgNode, + edits: Edit[], + typeContext?: CallbackTypeContext, +): void { + const param = getFirstFunctionParam(fn); + if (!param) return; + const pattern = getFunctionParamPattern(param); + const body = fn.field("body"); + if (!pattern || !body) return; + + if (pattern.kind() === "identifier") { + const ctxName = pattern.text(); + const ctxShadowRanges = collectCtxShadowRanges(body, ctxName, fn); + const propertyAccesses = body.findAll({ + rule: { kind: "property_identifier", regex: "^user$" }, + }); + for (const propId of propertyAccesses) { + const parent = propId.parent(); + if (!parent || parent.kind() !== "member_expression") continue; + const obj = parent.field("object"); + if (!(obj && obj.kind() === "identifier" && obj.text() === ctxName)) continue; + const pos = obj.range().start.index; + if (isInsideAnyRange(pos, ctxShadowRanges)) continue; + edits.push(propId.replace(principalPropertyReplacement(propId, "invoker"))); + } + + const ctxDestructures = body.findAll({ + rule: { + kind: "variable_declarator", + has: { + field: "value", + kind: "identifier", + regex: `^${escapeRegex(ctxName)}$`, + }, + }, + }); + const principalAliasBindings: PrincipalLocalBinding[] = []; + for (const decl of ctxDestructures) { + const pos = decl.range().start.index; + if (isInsideAnyRange(pos, ctxShadowRanges)) continue; + const pat = decl.field("name"); + if (!pat || pat.kind() !== "object_pattern") continue; + for (const child of pat.children()) { + const kind = child.kind(); + if (kind === "shorthand_property_identifier_pattern" && child.text() === "user") { + edits.push(child.replace("invoker: user")); + principalAliasBindings.push({ + name: "user", + bindingStart: child.range().start.index, + }); + } else if (kind === "pair_pattern") { + const key = child.field("key"); + const value = child.field("value"); + if (key?.text() === "user" && value?.kind() === "identifier") { + edits.push(key.replace("invoker")); + principalAliasBindings.push({ + name: value.text(), + bindingStart: value.range().start.index, + }); + } + } + } + } + for (const binding of principalAliasBindings) { + guardPrincipalMemberAccesses(body, binding, edits); + } + transformPrincipalCallbackParamType(param, edits, typeContext); + return; + } + + if (pattern.kind() !== "object_pattern") return; + if (hasNestedPropertyPattern(pattern, "user")) return; + + let hasUserParamProperty = false; + let renamesBinding = false; + const principalAliasBindings: PrincipalLocalBinding[] = []; + for (const child of pattern.children()) { + const kind = child.kind(); + if (kind === "shorthand_property_identifier_pattern" && child.text() === "user") { + hasUserParamProperty = true; + renamesBinding = true; + } else if (kind === "pair_pattern") { + const key = child.field("key"); + if (key?.text() === "user") { + hasUserParamProperty = true; + const value = child.field("value"); + if (value?.kind() === "identifier") { + principalAliasBindings.push({ + name: value.text(), + bindingStart: value.range().start.index, + }); + } + } + } else if (kind === "object_assignment_pattern") { + const inner = child + .children() + .find((c: SgNode) => c.kind() === "shorthand_property_identifier_pattern"); + if (inner?.text() === "user") { + hasUserParamProperty = true; + renamesBinding = true; + } + } + } + + if (!hasUserParamProperty) return; + if (renamesBinding && hasPrincipalAssignmentTarget(body, "user")) return; + if (renamesBinding && patternBindsName(pattern, "invoker")) return; + transformPrincipalCallbackParamType(param, edits, typeContext); + + const aliasRenamedUser = + renamesBinding && + (collectAllShadowRanges(body, "invoker").length > 0 || + hasUnshadowedIdentifierReference(body, "invoker")); + + let aliasedShorthandUser = false; + let renamedShorthandUser = false; + for (const child of pattern.children()) { + const kind = child.kind(); + if (kind === "shorthand_property_identifier_pattern" && child.text() === "user") { + if (aliasRenamedUser) { + edits.push(child.replace("invoker: user")); + aliasedShorthandUser = true; + principalAliasBindings.push({ + name: "user", + bindingStart: child.range().start.index, + }); + } else { + edits.push(child.replace("invoker")); + renamedShorthandUser = true; + } + } else if (kind === "pair_pattern") { + const key = child.field("key"); + if (key?.text() === "user") { + edits.push(key.replace("invoker")); + } + } else if (kind === "object_assignment_pattern") { + const inner = child + .children() + .find((c: SgNode) => c.kind() === "shorthand_property_identifier_pattern"); + if (inner?.text() === "user") { + if (aliasRenamedUser) { + edits.push(inner.replace("invoker: user")); + aliasedShorthandUser = true; + principalAliasBindings.push({ + name: "user", + bindingStart: inner.range().start.index, + }); + } else { + edits.push(inner.replace("invoker")); + renamedShorthandUser = true; + } + } + } + } + + if (!renamedShorthandUser) { + if (aliasedShorthandUser) { + rewriteParseArgumentShorthands(body, "user", "invoker", typeContext, edits); + } + for (const binding of principalAliasBindings) { + guardPrincipalMemberAccesses(body, binding, edits); + } + return; + } + + const shadowRanges = collectAllShadowRanges(body, "user"); + const refs = body.findAll({ rule: { kind: "identifier", regex: "^user$" } }); + for (const ref of refs) { + const pos = ref.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + edits.push(ref.replace(principalReadReplacement(ref, "invoker"))); + } + + const shortRefs = body.findAll({ + rule: { kind: "shorthand_property_identifier", regex: "^user$" }, + }); + for (const ref of shortRefs) { + const pos = ref.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + edits.push( + ref.replace(isSdkFieldParseArgumentShorthand(ref, typeContext) ? "invoker" : "user: invoker"), + ); + } + + for (const binding of principalAliasBindings) { + guardPrincipalMemberAccesses(body, binding, edits); + } +} + +interface SdkFieldLocalBinding { + name: string; + bindingStart: number; + scope: [number, number]; +} + +function rangeContains([start, end]: [number, number], pos: number): boolean { + return pos >= start && pos < end; +} + +function isShadowedLocalReference( + root: SgNode, + name: string, + pos: number, + bindingStart: number, +): boolean { + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const nameNode = decl.field("name"); + if (!nameNode || !patternBindsName(nameNode, name)) continue; + const nameRange = nameNode.range(); + if (bindingStart >= nameRange.start.index && bindingStart < nameRange.end.index) continue; + const scope = enclosingScopeRange(decl); + if (scope && rangeContains(scope, pos)) return true; + } + + const functionDecls = root.findAll({ rule: { kind: "function_declaration" } }); + for (const fn of functionDecls) { + const nameNode = fn.field("name"); + if (!nameNode || nameNode.text() !== name) continue; + if (nameNode.range().start.index === bindingStart) continue; + const scope = enclosingScopeRange(fn); + if (scope && rangeContains(scope, pos)) return true; + } + + for (const kind of NESTED_FN_KINDS) { + const fns = root.findAll({ rule: { kind } }); + for (const fn of fns) { + if (!functionRebindsName(fn, name)) continue; + const range = fn.range(); + if (pos >= range.start.index && pos < range.end.index) return true; + } + } + + return false; +} + +function resolvesToSdkFieldLocal( + node: SgNode, + bindings: SdkFieldLocalBinding[], + root: SgNode, +): boolean { + if (node.kind() !== "identifier") return false; + const pos = node.range().start.index; + return bindings.some( + (binding) => + binding.name === node.text() && + rangeContains(binding.scope, pos) && + !isShadowedLocalReference(root, binding.name, pos, binding.bindingStart), + ); +} + +function isSdkFieldExpression( + node: SgNode, + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + root: SgNode, +): boolean { + if (node.kind() === "identifier") { + const pos = node.range().start.index; + const isSdkRoot = + sdkFieldRootNames.has(node.text()) && + !isInsideAnyRange(pos, collectAllShadowRanges(root, node.text())); + return isSdkRoot || resolvesToSdkFieldLocal(node, sdkFieldLocalBindings, root); + } + if (node.kind() === "member_expression") { + const object = node.field("object"); + return object + ? isSdkFieldExpression(object, sdkFieldRootNames, sdkFieldLocalBindings, root) + : false; + } + if (node.kind() === "call_expression") { + const object = findMemberCallObject(node); + return object + ? isSdkFieldExpression(object, sdkFieldRootNames, sdkFieldLocalBindings, root) + : false; + } + return false; +} + +function collectSdkFieldLocalBindings( + root: SgNode, + sdkFieldRootNames: Set, +): SdkFieldLocalBinding[] { + const bindings: SdkFieldLocalBinding[] = []; + let changed = true; + while (changed) { + changed = false; + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const name = decl.field("name"); + const value = decl.field("value"); + if (!name || name.kind() !== "identifier" || !value) continue; + const bindingStart = name.range().start.index; + if (bindings.some((binding) => binding.bindingStart === bindingStart)) continue; + if (!isSdkFieldExpression(value, sdkFieldRootNames, bindings, root)) continue; + const rootRange = root.range(); + const scope = enclosingScopeRange(decl) ?? [rootRange.start.index, rootRange.end.index]; + bindings.push({ name: name.text(), bindingStart, scope }); + changed = true; + } + } + return bindings; +} + +function isSdkFieldMemberCall( + call: SgNode, + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + root: SgNode, +): boolean { + const object = findMemberCallObject(call); + return object + ? isSdkFieldExpression(object, sdkFieldRootNames, sdkFieldLocalBindings, root) + : false; +} + +function collectSdkFieldRootNames(root: SgNode): Set { + const sdkFieldRootNames = new Set(); + const sdkImports = root.findAll({ + rule: { + kind: "import_statement", + has: { kind: "string", regex: "^[\"']@tailor-platform/sdk(/test)?[\"']$" }, + }, + }); + for (const importStmt of sdkImports) { + for (const namespaceName of iterateNamespaceImportLocalNames(importStmt)) { + sdkFieldRootNames.add(namespaceName); + } + for (const { importedName, localName } of iterateImportSpecs(importStmt)) { + if (importedName === "t" || importedName === "db") { + sdkFieldRootNames.add(localName); + } + } + } + return sdkFieldRootNames; +} + +interface LocalCallbackBinding { + name: string; + fn: SgNode; + bindingStart: number; + scope: [number, number]; +} + +interface LocalObjectBinding { + name: string; + object: SgNode; + bindingStart: number; + scope: [number, number]; +} + +function localBindingRootScope(root: SgNode): [number, number] { + const rootRange = root.range(); + return [rootRange.start.index, rootRange.end.index]; +} + +function collectLocalCallbackBindings(root: SgNode): LocalCallbackBinding[] { + const rootScope = localBindingRootScope(root); + const bindings: LocalCallbackBinding[] = []; + + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const name = decl.field("name"); + const value = decl.field("value"); + if (!name || name.kind() !== "identifier" || !value || !isFunctionNode(value)) continue; + bindings.push({ + name: name.text(), + fn: value, + bindingStart: name.range().start.index, + scope: enclosingScopeRange(decl) ?? rootScope, + }); + } + + const functionDecls = root.findAll({ rule: { kind: "function_declaration" } }); + for (const fn of functionDecls) { + const name = fn.field("name"); + if (!name || name.kind() !== "identifier") continue; + bindings.push({ + name: name.text(), + fn, + bindingStart: name.range().start.index, + scope: enclosingScopeRange(fn) ?? rootScope, + }); + } + + return bindings; +} + +function collectLocalObjectBindings(root: SgNode): LocalObjectBinding[] { + const rootScope = localBindingRootScope(root); + const bindings: LocalObjectBinding[] = []; + + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const name = decl.field("name"); + const value = decl.field("value"); + if (!name || name.kind() !== "identifier" || value?.kind() !== "object") continue; + bindings.push({ + name: name.text(), + object: value, + bindingStart: name.range().start.index, + scope: enclosingScopeRange(decl) ?? rootScope, + }); + } + + return bindings; +} + +function resolveLocalCallbackBinding( + node: SgNode, + bindings: LocalCallbackBinding[], + root: SgNode, +): LocalCallbackBinding | null { + if (node.kind() !== "identifier") return null; + const pos = node.range().start.index; + return ( + bindings.find( + (binding) => + binding.name === node.text() && + rangeContains(binding.scope, pos) && + !isShadowedLocalReference(root, binding.name, pos, binding.bindingStart), + ) ?? null + ); +} + +function resolveLocalObjectBinding( + node: SgNode, + bindings: LocalObjectBinding[], + root: SgNode, +): LocalObjectBinding | null { + if (node.kind() !== "identifier") return null; + const pos = node.range().start.index; + return ( + bindings.find( + (binding) => + binding.name === node.text() && + rangeContains(binding.scope, pos) && + !isShadowedLocalReference(root, binding.name, pos, binding.bindingStart), + ) ?? null + ); +} + +function transformPrincipalCallbackNode( + node: SgNode, + edits: Edit[], + callbackBindings: LocalCallbackBinding[], + transformedCallbackStarts: Set, + typeContext: CallbackTypeContext, + root: SgNode, +): boolean { + if (isFunctionNode(node)) { + transformPrincipalCallbackParam(node, edits, typeContext); + return true; + } + + const binding = resolveLocalCallbackBinding(node, callbackBindings, root); + if (!binding) return false; + + const start = binding.fn.range().start.index; + if (!transformedCallbackStarts.has(start)) { + transformedCallbackStarts.add(start); + transformPrincipalCallbackParam(binding.fn, edits, typeContext); + } + return true; +} + +function transformHookCallbackObject( + node: SgNode, + edits: Edit[], + callbackBindings: LocalCallbackBinding[], + transformedCallbackStarts: Set, + typeContext: CallbackTypeContext, + root: SgNode, +): void { + if (node.kind() !== "object") return; + for (const child of node.children()) { + if (child.kind() === "method_definition") { + const key = propertyName(child); + if (key === "create" || key === "update") { + transformPrincipalCallbackParam(child, edits, typeContext); + } + continue; + } + if (child.kind() !== "pair") continue; + const key = child.field("key")?.text(); + const value = child.field("value"); + if (!value) continue; + if (key === "create" || key === "update") { + const transformed = transformPrincipalCallbackNode( + value, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + if (!transformed && value.kind() === "object") { + transformHookCallbackObject( + value, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + } + } else if (value.kind() === "object") { + transformHookCallbackObject( + value, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + } + } +} + +function transformHookCallbackConfigNode( + node: SgNode, + edits: Edit[], + callbackBindings: LocalCallbackBinding[], + objectBindings: LocalObjectBinding[], + transformedCallbackStarts: Set, + transformedObjectStarts: Set, + typeContext: CallbackTypeContext, + root: SgNode, +): boolean { + if (node.kind() === "object") { + transformHookCallbackObject( + node, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + return true; + } + + const binding = resolveLocalObjectBinding(node, objectBindings, root); + if (!binding) return false; + + const start = binding.object.range().start.index; + if (!transformedObjectStarts.has(start)) { + transformedObjectStarts.add(start); + transformHookCallbackObject( + binding.object, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + } + return true; +} + +function transformValidateCallbackNode( + node: SgNode, + edits: Edit[], + callbackBindings: LocalCallbackBinding[], + transformedCallbackStarts: Set, + typeContext: CallbackTypeContext, + root: SgNode, +): void { + if ( + transformPrincipalCallbackNode( + node, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ) + ) { + return; + } + + if (node.kind() === "array") { + for (const child of node.children()) { + transformValidateCallbackNode( + child, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + } + return; + } + + if (node.kind() !== "object") return; + for (const child of node.children()) { + if (child.kind() === "method_definition") { + transformPrincipalCallbackParam(child, edits, typeContext); + continue; + } + if (child.kind() !== "pair") continue; + const value = child.field("value"); + if (value) { + transformValidateCallbackNode( + value, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + } + } +} + +function transformPrincipalCallbacksInCall( + call: SgNode, + edits: Edit[], + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + callbackBindings: LocalCallbackBinding[], + objectBindings: LocalObjectBinding[], + transformedCallbackStarts: Set, + transformedObjectStarts: Set, + typeContext: CallbackTypeContext, + root: SgNode, +): void { + const memberName = findMemberCallName(call); + if (memberName !== "hooks" && memberName !== "validate") return; + if (!isSdkFieldMemberCall(call, sdkFieldRootNames, sdkFieldLocalBindings, root)) return; + + const args = call.field("arguments"); + if (!args) return; + if (memberName === "hooks") { + for (const arg of args.children()) { + if ( + transformHookCallbackConfigNode( + arg, + edits, + callbackBindings, + objectBindings, + transformedCallbackStarts, + transformedObjectStarts, + typeContext, + root, + ) + ) { + break; + } + } + return; + } + + for (const arg of args.children()) { + transformValidateCallbackNode( + arg, + edits, + callbackBindings, + transformedCallbackStarts, + typeContext, + root, + ); + } +} + +function transformParseArgsObject( + call: SgNode, + edits: Edit[], + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + root: SgNode, +): void { + const memberName = findMemberCallName(call); + if (memberName !== "parse") return; + if (!isSdkFieldMemberCall(call, sdkFieldRootNames, sdkFieldLocalBindings, root)) return; + + const args = call.field("arguments"); + const objArg = args?.children().find((c: SgNode) => c.kind() === "object"); + if (!objArg) return; + + for (const child of objArg.children()) { + const kind = child.kind(); + if (kind === "pair") { + const key = child.field("key"); + if (key?.text() === "user") edits.push(key.replace("invoker")); + } else if (kind === "shorthand_property_identifier" && child.text() === "user") { + edits.push(child.replace("invoker: user")); + } + } +} + +const KYSELY_PREDICATE_METHODS = new Set(["where", "having", "on"]); + +function excerptForLine(source: string, line: number): string { + const excerpt = (source.split(/\r?\n/)[line - 1] ?? "").trim(); + return excerpt.length > 160 ? `${excerpt.slice(0, 157)}...` : excerpt; +} + +function addReviewFinding( + findings: LlmReviewFinding[], + seen: Set, + source: string, + file: string, + node: SgNode, + message: string, +): void { + const line = node.range().start.line + 1; + const excerpt = excerptForLine(source, line); + const key = `${file}:${line}:${message}:${excerpt}`; + if (seen.has(key)) return; + seen.add(key); + findings.push({ file, line, message, excerpt }); +} + +interface ReviewPrincipalBinding { + name: string; + bindingStart: number; + scope: [number, number]; + shadowRoot?: SgNode; +} + +function resolvesToReviewBinding( + node: SgNode, + bindings: ReviewPrincipalBinding[], + root: SgNode, +): boolean { + if (node.kind() !== "identifier") return false; + const pos = node.range().start.index; + return bindings.some((binding) => { + if (binding.name !== node.text() || !rangeContains(binding.scope, pos)) return false; + if (binding.shadowRoot) { + return !isInsideAnyRange(pos, collectAllShadowRanges(binding.shadowRoot, binding.name)); + } + return !isShadowedLocalReference(root, binding.name, pos, binding.bindingStart); + }); +} + +function isReviewContextCallerMemberExpression( + node: SgNode, + contextBindings: ReviewPrincipalBinding[], + root: SgNode, +): boolean { + if (node.kind() !== "member_expression") return false; + if (node.field("property")?.text() !== "caller") return false; + const object = node.field("object"); + return object ? resolvesToReviewBinding(object, contextBindings, root) : false; +} + +function isPrincipalOptionalMemberExpression( + node: SgNode, + principalBindings: ReviewPrincipalBinding[], + contextBindings: ReviewPrincipalBinding[], + root: SgNode, +): boolean { + if (node.kind() !== "member_expression") return false; + if (!node.children().some((child) => child.kind() === "optional_chain")) return false; + const object = node.field("object"); + if (!object) return false; + if (object.kind() === "identifier") { + return resolvesToReviewBinding(object, principalBindings, root); + } + return isReviewContextCallerMemberExpression(object, contextBindings, root); +} + +function isDirectPrincipalExpression( + node: SgNode, + principalBindings: ReviewPrincipalBinding[], + contextBindings: ReviewPrincipalBinding[], + root: SgNode, +): boolean { + if (resolvesToReviewBinding(node, principalBindings, root)) return true; + return isReviewContextCallerMemberExpression(node, contextBindings, root); +} + +function nodeContainsArgumentPrincipalOptionalAccess( + node: SgNode, + principalBindings: ReviewPrincipalBinding[], + contextBindings: ReviewPrincipalBinding[], + safePrincipalRanges: Array<[number, number]>, + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + root: SgNode, +): boolean { + const nodeSafePrincipalRanges = + node.kind() === "call_expression" + ? [ + ...safePrincipalRanges, + ...collectSafeNullablePrincipalArgumentRanges( + node, + sdkFieldRootNames, + sdkFieldLocalBindings, + root, + ), + ] + : safePrincipalRanges; + if (isInsideAnyRange(node.range().start.index, nodeSafePrincipalRanges)) return false; + if (isFunctionNode(node)) return false; + if (isDirectPrincipalExpression(node, principalBindings, contextBindings, root)) return true; + if (isPrincipalOptionalMemberExpression(node, principalBindings, contextBindings, root)) + return true; + return node + .children() + .some((child) => + nodeContainsArgumentPrincipalOptionalAccess( + child, + principalBindings, + contextBindings, + nodeSafePrincipalRanges, + sdkFieldRootNames, + sdkFieldLocalBindings, + root, + ), + ); +} + +function reviewCallName(call: SgNode): string { + const fn = call.field("function"); + if (!fn) return "a call"; + if (fn.kind() === "identifier") return `${fn.text()}()`; + if (fn.kind() === "member_expression") { + const property = fn.field("property"); + if (property) return `${property.text()}()`; + } + return "a call"; +} + +function collectParseInvokerValueRanges(call: SgNode): Array<[number, number]> { + const args = call.field("arguments"); + const objectArg = args?.children().find((child) => child.kind() === "object"); + if (!objectArg) return []; + + const ranges: Array<[number, number]> = []; + for (const child of objectArg.children()) { + if (child.kind() === "shorthand_property_identifier" && child.text() === "invoker") { + const range = child.range(); + ranges.push([range.start.index, range.end.index]); + continue; + } + + if (child.kind() !== "pair") continue; + const key = child.field("key"); + if (key?.text() !== "invoker") continue; + const value = child.field("value"); + if (!value) continue; + const range = value.range(); + ranges.push([range.start.index, range.end.index]); + } + return ranges; +} + +function collectSafeNullablePrincipalArgumentRanges( + call: SgNode, + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + root: SgNode, +): Array<[number, number]> { + if (findMemberCallName(call) !== "parse") return []; + if (!isSdkFieldMemberCall(call, sdkFieldRootNames, sdkFieldLocalBindings, root)) return []; + return collectParseInvokerValueRanges(call); +} + +function collectNullableCallerReviewFindings( + root: SgNode, + source: string, + file: string, + principalBindings: ReviewPrincipalBinding[], + contextBindings: ReviewPrincipalBinding[], + sdkFieldRootNames: Set, + sdkFieldLocalBindings: SdkFieldLocalBinding[], + findings: LlmReviewFinding[], + seen: Set, +): void { + const calls = root.findAll({ rule: { kind: "call_expression" } }); + for (const call of calls) { + const args = call.field("arguments"); + const safePrincipalRanges = collectSafeNullablePrincipalArgumentRanges( + call, + sdkFieldRootNames, + sdkFieldLocalBindings, + root, + ); + const nullableArg = args + ?.children() + .find((child) => + nodeContainsArgumentPrincipalOptionalAccess( + child, + principalBindings, + contextBindings, + safePrincipalRanges, + sdkFieldRootNames, + sdkFieldLocalBindings, + root, + ), + ); + if (!nullableArg) continue; + + const memberName = findMemberCallName(call); + if (memberName && KYSELY_PREDICATE_METHODS.has(memberName)) { + addReviewFinding( + findings, + seen, + source, + file, + nullableArg, + "Nullable caller value is used as a Kysely predicate value.", + ); + continue; + } + + addReviewFinding( + findings, + seen, + source, + file, + nullableArg, + `Nullable caller value is passed as a non-null argument to ${reviewCallName(call)}.`, + ); + } +} + +function functionIdentifierParamName(fn: SgNode): string | null { + const param = getFirstFunctionParam(fn); + const pattern = param ? getFunctionParamPattern(param) : null; + return pattern?.kind() === "identifier" ? pattern.text() : null; +} + +function objectPatternTopLevelPropertyBindingName( + pattern: SgNode, + propertyName: string, +): string | null { + if (pattern.kind() !== "object_pattern") return null; + for (const child of pattern.children()) { + const kind = child.kind(); + if (kind === "shorthand_property_identifier_pattern" && child.text() === propertyName) { + return child.text(); + } + if (kind === "pair_pattern" && child.field("key")?.text() === propertyName) { + const value = child.field("value"); + return value?.kind() === "identifier" ? value.text() : propertyName; + } + if (kind === "object_assignment_pattern") { + const inner = child + .children() + .find((c: SgNode) => c.kind() === "shorthand_property_identifier_pattern"); + if (inner?.text() === propertyName) return inner.text(); + } + } + return null; +} + +function objectPatternHasTopLevelProperty(pattern: SgNode, propertyName: string): boolean { + return objectPatternTopLevelPropertyBindingName(pattern, propertyName) !== null; +} + +function functionReadsContextUser(fn: SgNode, contextName: string): boolean { + const body = fn.field("body"); + if (!body) return false; + const shadowRanges = collectCtxShadowRanges(body, contextName, fn); + const userProperties = body.findAll({ + rule: { kind: "property_identifier", regex: "^user$" }, + }); + for (const propId of userProperties) { + const parent = propId.parent(); + if (!parent || parent.kind() !== "member_expression") continue; + const object = parent.field("object"); + if (!object || object.kind() !== "identifier" || object.text() !== contextName) continue; + if (isInsideAnyRange(object.range().start.index, shadowRanges)) continue; + return true; + } + + const destructures = body.findAll({ + rule: { + kind: "variable_declarator", + has: { + field: "value", + kind: "identifier", + regex: `^${escapeRegex(contextName)}$`, + }, + }, + }); + for (const decl of destructures) { + const value = decl.field("value"); + if (!value || isInsideAnyRange(value.range().start.index, shadowRanges)) continue; + const name = decl.field("name"); + if (name && objectPatternHasTopLevelProperty(name, "user")) return true; + } + return false; +} + +function functionContextUserReadExpression(fn: SgNode): string | null { + const param = getFirstFunctionParam(fn); + const pattern = param ? getFunctionParamPattern(param) : null; + if (!pattern) return null; + if (pattern.kind() === "identifier") { + return functionReadsContextUser(fn, pattern.text()) ? `${pattern.text()}.user` : null; + } + return objectPatternTopLevelPropertyBindingName(pattern, "user"); +} + +type ContextUserHelperBinding = LocalCallbackBinding & { contextUserExpression: string }; + +function collectContextUserHelperBindings(root: SgNode): ContextUserHelperBinding[] { + return collectLocalCallbackBindings(root).flatMap((binding) => { + const contextUserExpression = functionContextUserReadExpression(binding.fn); + if (!contextUserExpression) return []; + return [{ ...binding, contextUserExpression }]; + }); +} + +function resolveContextUserHelperBinding( + node: SgNode, + bindings: ContextUserHelperBinding[], + root: SgNode, +): ContextUserHelperBinding | null { + if (node.kind() !== "identifier") return null; + const pos = node.range().start.index; + return ( + bindings.find( + (binding) => + binding.name === node.text() && + rangeContains(binding.scope, pos) && + !isShadowedLocalReference(root, binding.name, pos, binding.bindingStart), + ) ?? null + ); +} + +interface ResolverContextBody { + arrow: SgNode; + body: SgNode; + contextName: string; +} + +function addResolverContextBody(arrow: SgNode, bodies: ResolverContextBody[]): void { + const paramName = functionIdentifierParamName(arrow); + const body = arrow.field("body"); + if (!paramName || !body) return; + bodies.push({ arrow, body, contextName: paramName }); +} + +function collectResolverBodyArrows(root: SgNode): SgNode[] { + const sdkImports = root.findAll({ + rule: { + kind: "import_statement", + has: { kind: "string", regex: "^[\"']@tailor-platform/sdk(/test)?[\"']$" }, + }, + }); + const createResolverLocalNames = new Set(); + const sdkNamespaceNames = new Set(); + for (const importStmt of sdkImports) { + for (const namespaceName of iterateNamespaceImportLocalNames(importStmt)) { + sdkNamespaceNames.add(namespaceName); + } + for (const { importedName, localName } of iterateImportSpecs(importStmt)) { + if (importedName === "createResolver") createResolverLocalNames.add(localName); + } + } + + const arrows: SgNode[] = []; + for (const localName of createResolverLocalNames) { + const shadowRanges = collectAllShadowRanges(root, localName); + const calls = root.findAll({ + rule: { + kind: "call_expression", + has: { + field: "function", + kind: "identifier", + regex: `^${escapeRegex(localName)}$`, + }, + }, + }); + for (const call of calls) { + const callee = call.field("function"); + if (!callee || isInsideAnyRange(callee.range().start.index, shadowRanges)) continue; + const arrow = findResolverBodyArrow(call); + if (arrow) arrows.push(arrow); + } + } + + for (const namespaceName of sdkNamespaceNames) { + const shadowRanges = collectAllShadowRanges(root, namespaceName); + const calls = root.findAll({ + rule: { + kind: "call_expression", + has: { + field: "function", + kind: "member_expression", + has: { + field: "property", + kind: "property_identifier", + regex: "^createResolver$", + }, + }, + }, + }); + for (const call of calls) { + const callee = call.field("function"); + const object = callee?.field("object"); + if (!object || object.kind() !== "identifier" || object.text() !== namespaceName) continue; + if (isInsideAnyRange(object.range().start.index, shadowRanges)) continue; + const arrow = findResolverBodyArrow(call); + if (arrow) arrows.push(arrow); + } + } + return arrows; +} + +function collectResolverContextBodies(resolverBodyArrows: SgNode[]): ResolverContextBody[] { + const bodies: ResolverContextBody[] = []; + for (const arrow of resolverBodyArrows) { + addResolverContextBody(arrow, bodies); + } + return bodies; +} + +function collectResolverContextBindings( + root: SgNode, + resolverBodyArrows: SgNode[], +): ReviewPrincipalBinding[] { + const bindings: ReviewPrincipalBinding[] = []; + const rootRange = root.range(); + const rootScope: [number, number] = [rootRange.start.index, rootRange.end.index]; + + for (const arrow of resolverBodyArrows) { + const param = getFirstFunctionParam(arrow); + const pattern = param ? getFunctionParamPattern(param) : null; + const body = arrow.field("body"); + if (!pattern || pattern.kind() !== "identifier" || !body) continue; + + const bodyRange = body.range(); + bindings.push({ + name: pattern.text(), + bindingStart: pattern.range().start.index, + scope: [bodyRange.start.index, bodyRange.end.index], + shadowRoot: body, + }); + } + + let changed = true; + while (changed) { + changed = false; + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const name = decl.field("name"); + const value = decl.field("value"); + if (!name || name.kind() !== "identifier" || !value) continue; + + const bindingStart = name.range().start.index; + if (bindings.some((binding) => binding.bindingStart === bindingStart)) continue; + if (!resolvesToReviewBinding(value, bindings, root)) continue; + + bindings.push({ + name: name.text(), + bindingStart, + scope: enclosingScopeRange(decl) ?? rootScope, + }); + changed = true; + } + } + + return bindings; +} + +function collectCallerPatternBindings( + pattern: SgNode, + scope: [number, number], + bindings: ReviewPrincipalBinding[], + shadowRoot?: SgNode, +): void { + if (pattern.kind() !== "object_pattern") return; + for (const child of pattern.children()) { + const kind = child.kind(); + if (kind === "shorthand_property_identifier_pattern" && child.text() === "caller") { + bindings.push({ name: "caller", bindingStart: child.range().start.index, scope, shadowRoot }); + } else if (kind === "pair_pattern") { + const key = child.field("key"); + const value = child.field("value"); + if (key?.text() === "caller" && value?.kind() === "identifier") { + bindings.push({ + name: value.text(), + bindingStart: value.range().start.index, + scope, + shadowRoot, + }); + } + } else if (kind === "object_assignment_pattern") { + const inner = child + .children() + .find((c: SgNode) => c.kind() === "shorthand_property_identifier_pattern"); + if (inner?.text() === "caller") { + bindings.push({ + name: "caller", + bindingStart: inner.range().start.index, + scope, + shadowRoot, + }); + } + } + } +} + +function collectResolverPrincipalBindings( + root: SgNode, + contextBindings: ReviewPrincipalBinding[], + resolverBodyArrows: SgNode[], +): ReviewPrincipalBinding[] { + const bindings: ReviewPrincipalBinding[] = []; + for (const arrow of resolverBodyArrows) { + const param = getFirstFunctionParam(arrow); + const pattern = param ? getFunctionParamPattern(param) : null; + const body = arrow.field("body"); + if (!pattern || !body) continue; + const bodyRange = body.range(); + const bodyScope: [number, number] = [bodyRange.start.index, bodyRange.end.index]; + + if (pattern.kind() === "object_pattern") { + collectCallerPatternBindings(pattern, bodyScope, bindings, body); + continue; + } + + if (pattern.kind() !== "identifier") continue; + + const declarators = body.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const name = decl.field("name"); + const value = decl.field("value"); + if (!name || !value) continue; + + if (resolvesToReviewBinding(value, contextBindings, root)) { + collectCallerPatternBindings(name, enclosingScopeRange(decl) ?? bodyScope, bindings); + continue; + } + + if (name.kind() !== "identifier") continue; + const bindingStart = name.range().start.index; + if (bindings.some((binding) => binding.bindingStart === bindingStart)) continue; + if ( + !isReviewContextCallerMemberExpression(value, contextBindings, root) && + !resolvesToReviewBinding(value, bindings, root) + ) { + continue; + } + + bindings.push({ + name: name.text(), + bindingStart, + scope: enclosingScopeRange(decl) ?? bodyScope, + }); + } + } + return bindings; +} + +function firstIdentifierArgument(call: SgNode): SgNode | null { + const args = call.field("arguments"); + if (!args) return null; + for (const child of args.children()) { + if (child.kind() === "(" || child.kind() === ")" || child.kind() === ",") continue; + return child.kind() === "identifier" ? child : null; + } + return null; +} + +function collectContextUserHelperReviewFindings( + root: SgNode, + source: string, + file: string, + resolverBodyArrows: SgNode[], + findings: LlmReviewFinding[], + seen: Set, +): void { + const helperBindings = collectContextUserHelperBindings(root); + if (helperBindings.length === 0) return; + + const reportedDefinitions = new Set(); + for (const { arrow, body, contextName } of collectResolverContextBodies(resolverBodyArrows)) { + const shadowRanges = collectCtxShadowRanges(body, contextName, arrow); + const calls = body.findAll({ rule: { kind: "call_expression" } }); + for (const call of calls) { + if (isInsideAnyRange(call.range().start.index, shadowRanges)) continue; + const arg = firstIdentifierArgument(call); + if (!arg || arg.text() !== contextName) continue; + const callee = call.field("function"); + if (!callee) continue; + const helper = resolveContextUserHelperBinding(callee, helperBindings, root); + if (!helper) continue; + + if (!reportedDefinitions.has(helper.bindingStart)) { + reportedDefinitions.add(helper.bindingStart); + addReviewFinding( + findings, + seen, + source, + file, + helper.fn, + `Helper adapter ${helper.name} reads ${helper.contextUserExpression} and needs v2 caller/invoker semantics.`, + ); + } + addReviewFinding( + findings, + seen, + source, + file, + call, + `${helper.name}(${contextName}) passes an SDK resolver context into a helper that reads ${helper.contextUserExpression}.`, + ); + } + } +} + +export function reviewFindings( + source: string, + _filePath: string, + relativePath: string, +): LlmReviewFinding[] { + if (!source.includes("?.") && !source.includes("user") && !source.includes("caller")) { + return []; + } + + let root: SgNode; + try { + root = parse(Lang.TypeScript, source).root(); + } catch { + return []; + } + + const findings: LlmReviewFinding[] = []; + const seen = new Set(); + const resolverBodyArrows = collectResolverBodyArrows(root); + const contextBindings = collectResolverContextBindings(root, resolverBodyArrows); + const principalBindings = collectResolverPrincipalBindings( + root, + contextBindings, + resolverBodyArrows, + ); + const sdkFieldRootNames = collectSdkFieldRootNames(root); + const sdkFieldLocalBindings = collectSdkFieldLocalBindings(root, sdkFieldRootNames); + collectNullableCallerReviewFindings( + root, + source, + relativePath, + principalBindings, + contextBindings, + sdkFieldRootNames, + sdkFieldLocalBindings, + findings, + seen, + ); + collectContextUserHelperReviewFindings( + root, + source, + relativePath, + resolverBodyArrows, + findings, + seen, + ); + return findings; +} + +/** + * Migrate user/actor/invoker types and identifiers to the unified TailorPrincipal. + * + * - Renames `TailorUser` / `TailorActor` / `TailorInvoker` type references to `TailorPrincipal`. + * - Rewrites SDK imports (including the `/test` subpath) to use `TailorPrincipal` (deduped + * across statements) and drops `unauthenticatedTailorUser`. + * - Replaces standalone references to `unauthenticatedTailorUser` with `null`. Member-access + * forms like `unauthenticatedTailorUser.id` are left alone on purpose so the resulting TS + * error after the import is removed points the author at the broken access. + * - Rewrites actor member accesses from `userId` / `userType` to `id` / `type`. + * - Renames `user` to `caller` for top-level destructured resolver bodies (`{ input, user }`), + * handles aliased pairs (`{ user: currentUser }`) by rewriting only the property name, and + * rewrites `.user` for non-destructured single-param bodies — respecting variable + * shadowing in both directions. + * - Renames TailorDB hook/validator callback `user` parameters to `invoker`, and rewrites + * `.parse({ user: ... })` arguments to `.parse({ invoker: ... })`. + * @param source - TypeScript source text. + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string): string | null { + if (!quickFilter(source)) return null; + + const tree = parse(Lang.TypeScript, source).root(); + const edits: Edit[] = []; + + const sdkImports = tree.findAll({ + rule: { + kind: "import_statement", + has: { kind: "string", regex: "^[\"']@tailor-platform/sdk(/test)?[\"']$" }, + }, + }); + + // Only rewrite type identifiers that are imported from the SDK without an + // alias. A local `import type { TailorUser } from './domain'` must stay alone + // even when the file also imports something else from the SDK. + const sdkRenameSourceNames = new Set(); + const tailorUserTypeLocalNames = new Set(); + const nullableInvokerAliasLocalNames = new Set(); + const actorTypeAliasLocalNames = new Set(); + const actorTypeLocalNames = new Set(); + const actorTypeValueLocalNames = new Set(); + const sdkNamespaceNames = new Set(); + for (const importStmt of sdkImports) { + for (const namespaceName of iterateNamespaceImportLocalNames(importStmt)) { + sdkNamespaceNames.add(namespaceName); + } + for (const { importedName, aliasNode, localName } of iterateImportSpecs(importStmt)) { + if (TYPE_RENAME_MAP[importedName] && !aliasNode) { + sdkRenameSourceNames.add(importedName); + } + if (importedName === "TailorUser") { + tailorUserTypeLocalNames.add(localName); + } + if (importedName === "TailorActor") { + actorTypeLocalNames.add(localName); + } + if (importedName === "TailorActorType") { + actorTypeValueLocalNames.add(localName); + } + if (importedName === "TailorInvoker" && aliasNode) { + nullableInvokerAliasLocalNames.add(localName); + } + if (importedName === "TailorActorType" && aliasNode) { + actorTypeAliasLocalNames.add(localName); + } + } + } + + const transformedActorPropertyStarts = new Set(); + transformTailorActorTypedMemberAccesses( + tree, + actorTypeLocalNames, + sdkNamespaceNames, + edits, + transformedActorPropertyStarts, + ); + transformTailorActorTypeInitializerLiterals( + tree, + actorTypeValueLocalNames, + sdkNamespaceNames, + edits, + ); + transformTailorActorTypeBindingComparisons( + tree, + actorTypeValueLocalNames, + sdkNamespaceNames, + edits, + ); + + let importRemoved = false; + const globalEmittedRenamed = new Set(); // Populated only with names actually imported from the SDK (canonical or // alias). A file with a local `unauthenticatedTailorUser` declaration that // doesn't come from `@tailor-platform/sdk` is intentionally not rewritten. @@ -567,6 +2951,8 @@ export default function transform(source: string): string | null { importStmt, globalEmittedRenamed, unauthenticatedLocalNames, + nullableInvokerAliasLocalNames, + actorTypeAliasLocalNames, ); if (!touched) continue; edits.push(importStmt.replace(newText)); @@ -595,13 +2981,24 @@ export default function transform(source: string): string | null { // and unrelated local helpers named `createResolver` (when the SDK import // does not actually bring `createResolver` in) are not. const createResolverLocalNames = new Set(); + const createExecutorLocalNames = new Set(); + const sdkFieldRootNames = collectSdkFieldRootNames(tree); for (const importStmt of sdkImports) { for (const { importedName, localName } of iterateImportSpecs(importStmt)) { if (importedName === "createResolver") { createResolverLocalNames.add(localName); } + if (importedName === "createExecutor") { + createExecutorLocalNames.add(localName); + } } } + const sdkFieldLocalBindings = collectSdkFieldLocalBindings(tree, sdkFieldRootNames); + const parseContext: SdkFieldParseContext = { + sdkFieldRootNames, + sdkFieldLocalBindings, + root: tree, + }; for (const localName of createResolverLocalNames) { const shadowRanges = collectAllShadowRanges(tree, localName); const calls = tree.findAll({ @@ -620,8 +3017,139 @@ export default function transform(source: string): string | null { const pos = callee.range().start.index; if (isInsideAnyRange(pos, shadowRanges)) continue; const arrow = findResolverBodyArrow(call); - if (arrow) transformResolverBody(arrow, edits); + if (arrow) transformResolverBody(arrow, edits, parseContext); + } + } + for (const namespaceName of sdkNamespaceNames) { + const shadowRanges = collectAllShadowRanges(tree, namespaceName); + const calls = tree.findAll({ + rule: { + kind: "call_expression", + has: { + field: "function", + kind: "member_expression", + has: { + field: "property", + kind: "property_identifier", + regex: "^createResolver$", + }, + }, + }, + }); + for (const call of calls) { + const callee = call.field("function"); + const object = callee?.field("object"); + if (!object || object.kind() !== "identifier" || object.text() !== namespaceName) continue; + const pos = object.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + const arrow = findResolverBodyArrow(call); + if (arrow) transformResolverBody(arrow, edits, parseContext); + } + } + for (const localName of createExecutorLocalNames) { + const shadowRanges = collectAllShadowRanges(tree, localName); + const calls = tree.findAll({ + rule: { + kind: "call_expression", + has: { + field: "function", + kind: "identifier", + regex: `^${escapeRegex(localName)}$`, + }, + }, + }); + for (const call of calls) { + const callee = call.field("function"); + if (!callee) continue; + const pos = callee.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + for (const fn of findExecutorBodyFunctions(call)) { + transformExecutorBodyActorAccesses(fn, edits, transformedActorPropertyStarts); + } + } + } + for (const namespaceName of sdkNamespaceNames) { + const shadowRanges = collectAllShadowRanges(tree, namespaceName); + const calls = tree.findAll({ + rule: { + kind: "call_expression", + has: { + field: "function", + kind: "member_expression", + has: { + field: "property", + kind: "property_identifier", + regex: "^createExecutor$", + }, + }, + }, + }); + for (const call of calls) { + const callee = call.field("function"); + const object = callee?.field("object"); + if (!object || object.kind() !== "identifier" || object.text() !== namespaceName) continue; + const pos = object.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + for (const fn of findExecutorBodyFunctions(call)) { + transformExecutorBodyActorAccesses(fn, edits, transformedActorPropertyStarts); + } + } + } + transformActorTypeComparisonLiterals(tree, edits, transformedActorPropertyStarts); + + const callbackBindings = collectLocalCallbackBindings(tree); + const objectBindings = collectLocalObjectBindings(tree); + const typeContext: CallbackTypeContext = { + bindings: collectLocalCallbackTypeBindings(tree), + transformedTypeStarts: new Set(), + transformedPrincipalTypeStarts: new Set(), + tailorUserTypeLocalNames, + sdkNamespaceNames, + sdkFieldRootNames, + sdkFieldLocalBindings, + root: tree, + }; + const transformedCallbackStarts = new Set(); + const transformedObjectStarts = new Set(); + const memberCalls = tree.findAll({ rule: { kind: "call_expression" } }); + for (const call of memberCalls) { + transformPrincipalCallbacksInCall( + call, + edits, + sdkFieldRootNames, + sdkFieldLocalBindings, + callbackBindings, + objectBindings, + transformedCallbackStarts, + transformedObjectStarts, + typeContext, + tree, + ); + transformParseArgsObject(call, edits, sdkFieldRootNames, sdkFieldLocalBindings, tree); + } + + const typeIdents = tree.findAll({ + rule: { + kind: "type_identifier", + not: { inside: { kind: "import_statement" } }, + }, + }); + for (const id of typeIdents) { + if (typeContext.transformedPrincipalTypeStarts.has(id.range().start.index)) continue; + const qualifiedReplacement = namespaceQualifiedTypeReplacement(id, sdkNamespaceNames, tree); + if (qualifiedReplacement) { + edits.push(qualifiedReplacement.target.replace(qualifiedReplacement.text)); + continue; } + const newName = sdkRenameSourceNames.has(id.text()) + ? renamedTypeIdentifierText(id.text()) + : nullableInvokerAliasLocalNames.has(id.text()) + ? renamedTypeIdentifierText("TailorInvoker") + : actorTypeAliasLocalNames.has(id.text()) + ? renamedTypeIdentifierText("TailorActorType") + : null; + if (!newName) continue; + edits.push(id.replace(newName)); } if (edits.length === 0) return null; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access-local/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access-local/input.ts new file mode 100644 index 0000000000..18185eaf1c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access-local/input.ts @@ -0,0 +1,18 @@ +import { createExecutor } from "@tailor-platform/sdk"; + +const actor = { + userId: "domain-user", + userType: "performer", +}; + +export const domainActor = { + id: actor.userId, + type: actor.userType, +}; + +export const onEvent = createExecutor({ + operation: { + kind: "function", + body: () => domainActor, + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access/expected.ts new file mode 100644 index 0000000000..5d549ee20b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access/expected.ts @@ -0,0 +1,29 @@ +import { createExecutor } from "@tailor-platform/sdk"; + +export const onEvent = createExecutor({ + operation: { + kind: "function", + async body(args) { + let label = "unknown"; + switch (args.actor?.type) { + case "user": + label = "user"; + break; + case "machine_user": + label = "machine"; + break; + case undefined: + label = "missing"; + break; + } + return { + id: args.actor?.id, + type: args.actor?.type, + label, + isUser: args.actor?.type === "user", + isMachine: args.actor?.type === "machine_user", + isUnspecified: args.actor?.type === undefined, + }; + }, + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access/input.ts new file mode 100644 index 0000000000..94400e65be --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/actor-member-access/input.ts @@ -0,0 +1,29 @@ +import { createExecutor } from "@tailor-platform/sdk"; + +export const onEvent = createExecutor({ + operation: { + kind: "function", + async body(args) { + let label = "unknown"; + switch (args.actor?.userType) { + case "USER_TYPE_USER": + label = "user"; + break; + case "USER_TYPE_MACHINE_USER": + label = "machine"; + break; + case "USER_TYPE_UNSPECIFIED": + label = "missing"; + break; + } + return { + id: args.actor?.userId, + type: args.actor?.userType, + label, + isUser: args.actor?.userType === "USER_TYPE_USER", + isMachine: args.actor?.userType === "USER_TYPE_MACHINE_USER", + isUnspecified: args.actor?.userType === "USER_TYPE_UNSPECIFIED", + }; + }, + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-create-resolver/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-create-resolver/expected.ts index 696045d1af..fc945343bd 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-create-resolver/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-create-resolver/expected.ts @@ -4,5 +4,5 @@ export default makeResolver({ name: "n", operation: "query", output: t.string(), - body: ({ caller }) => caller.id, + body: ({ caller }) => caller?.id, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-destructure/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-destructure/expected.ts index 3aa4c354f4..f0b3a14d74 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-destructure/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-destructure/expected.ts @@ -4,5 +4,5 @@ export default createResolver({ name: "n", operation: "query", output: t.string(), - body: ({ caller: currentUser }) => currentUser.id, + body: ({ caller: currentUser }) => currentUser?.id, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/expected.ts index d3498e2ca6..3d76c81cef 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/expected.ts @@ -1,5 +1,6 @@ -import { type TailorPrincipal as MyUser } from "@tailor-platform/sdk"; +import { type TailorPrincipal as MyUser, type TailorPrincipal } from "@tailor-platform/sdk"; export type Props = { caller: MyUser; + invoker: (TailorPrincipal | null); }; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/input.ts index d3611f9016..c995a2e742 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/input.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/input.ts @@ -1,5 +1,6 @@ -import { type TailorUser as MyUser } from "@tailor-platform/sdk"; +import { type TailorUser as MyUser, type TailorInvoker as MyInvoker } from "@tailor-platform/sdk"; export type Props = { caller: MyUser; + invoker: MyInvoker; }; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/assignment-target/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/assignment-target/input.ts new file mode 100644 index 0000000000..f246ece128 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/assignment-target/input.ts @@ -0,0 +1,11 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ user }) => { + user.attributes.role = "ADMIN"; + return user.id; + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/expected.ts index 5c1553ae6e..e45ae39a2d 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/expected.ts @@ -6,7 +6,9 @@ export default createResolver({ input: t.object({ id: t.string() }), output: t.object({ id: t.string() }), body: ({ input, caller }) => { - return { id: caller.id }; + const parsed = t.string().parse({ value: input.id, data: {}, invoker: caller }); + const parsedOther = { parse: (arg: unknown) => arg }.parse({ user: caller }); + return { id: parsed.value ?? caller?.["id"] ?? caller?.id }; }, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/input.ts index 76bb3630e5..adb7c5ccb7 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/input.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/input.ts @@ -6,7 +6,9 @@ export default createResolver({ input: t.object({ id: t.string() }), output: t.object({ id: t.string() }), body: ({ input, user }) => { - return { id: user.id }; + const parsed = t.string().parse({ value: input.id, data: {}, user }); + const parsedOther = { parse: (arg: unknown) => arg }.parse({ user }); + return { id: parsed.value ?? user["id"] ?? user.id }; }, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/body-with-derived-const/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/body-with-derived-const/expected.ts index a0c293c7e4..4d1855042f 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/body-with-derived-const/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/body-with-derived-const/expected.ts @@ -5,7 +5,7 @@ export default createResolver({ operation: "query", output: t.string(), body: ({ caller }) => { - const userId = caller.id; + const userId = caller?.id; return userId; }, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-free-reference/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-free-reference/expected.ts new file mode 100644 index 0000000000..d5fb407d16 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-free-reference/expected.ts @@ -0,0 +1,13 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +const caller = { id: "outer-caller" }; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ caller: user }) => { + const parsed = t.string().parse({ value: "hello", data: {}, invoker: user }); + return user?.id ?? caller.id ?? parsed.value; + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-free-reference/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-free-reference/input.ts new file mode 100644 index 0000000000..278d0d9ad2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-free-reference/input.ts @@ -0,0 +1,13 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +const caller = { id: "outer-caller" }; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ user }) => { + const parsed = t.string().parse({ value: "hello", data: {}, user }); + return user.id ?? caller.id ?? parsed.value; + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/context-arg/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/context-arg/expected.ts index 997a81ed6f..ecec00dbaf 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/context-arg/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/context-arg/expected.ts @@ -5,7 +5,7 @@ export default createResolver({ operation: "query", output: t.object({ id: t.string(), type: t.string() }), body: (ctx) => ({ - id: ctx.caller.id, - type: ctx.caller.type, + id: ctx.caller?.id, + type: ctx.caller?.type, }), }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-destructure/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-destructure/expected.ts index 00575ecc37..fc8e2050fe 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-destructure/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-destructure/expected.ts @@ -6,6 +6,6 @@ export default createResolver({ output: t.string(), body: (ctx) => { const { caller: user } = ctx; - return user.id; + return user?.id; }, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/defaulted-destructure/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/defaulted-destructure/expected.ts index 5f726a55a2..3dc1253229 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/defaulted-destructure/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/defaulted-destructure/expected.ts @@ -6,5 +6,5 @@ export default createResolver({ name: "n", operation: "query", output: t.string(), - body: ({ caller = fallback }) => caller.id, + body: ({ caller = fallback }) => caller?.id, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/namespace-import/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/namespace-import/expected.ts new file mode 100644 index 0000000000..e49dc3660b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/namespace-import/expected.ts @@ -0,0 +1,38 @@ +import * as sdk from "@tailor-platform/sdk"; + +type User = sdk.TailorPrincipal; +type Invokers = (sdk.TailorPrincipal | null)[]; + +const role = sdk.db.string().hooks({ + create: ({ invoker }: { invoker: sdk.TailorPrincipal | null }) => invoker?.id ?? "anonymous", +}); + +export const actorTypeValue: (sdk.TailorPrincipal["type"] | undefined) = "user"; +export const allowedActorTypes: (sdk.TailorPrincipal["type"] | undefined)[] = [ + "user", + "machine_user", +]; + +export function isMachineUser(type: (sdk.TailorPrincipal["type"] | undefined)) { + return type === "machine_user"; +} + +export function actorFields(actor: sdk.TailorPrincipal | null) { + return { + id: actor?.id, + type: actor?.type, + }; +} + +export default sdk.createResolver({ + name: "n", + operation: "query", + output: sdk.t.string(), + body: ({ caller }) => { + const parsed = sdk.t.string().parse({ value: "hello", data: {}, invoker: caller }); + return caller?.id ?? parsed.value; + }, +}); + +export const helper = (u: User) => role.parse({ value: u.id, data: {}, invoker: null }); +export const invokers: Invokers = []; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/namespace-import/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/namespace-import/input.ts new file mode 100644 index 0000000000..884d29e5b9 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/namespace-import/input.ts @@ -0,0 +1,38 @@ +import * as sdk from "@tailor-platform/sdk"; + +type User = sdk.TailorUser; +type Invokers = sdk.TailorInvoker[]; + +const role = sdk.db.string().hooks({ + create: ({ user }: { user: sdk.TailorUser | null }) => user?.id ?? "anonymous", +}); + +export const actorTypeValue: sdk.TailorActorType = "USER_TYPE_USER"; +export const allowedActorTypes: sdk.TailorActorType[] = [ + "USER_TYPE_USER", + "USER_TYPE_MACHINE_USER", +]; + +export function isMachineUser(type: sdk.TailorActorType) { + return type === "USER_TYPE_MACHINE_USER"; +} + +export function actorFields(actor: sdk.TailorActor | null) { + return { + id: actor?.userId, + type: actor?.userType, + }; +} + +export default sdk.createResolver({ + name: "n", + operation: "query", + output: sdk.t.string(), + body: ({ user }) => { + const parsed = sdk.t.string().parse({ value: "hello", data: {}, user }); + return user.id ?? parsed.value; + }, +}); + +export const helper = (u: User) => role.parse({ value: u.id, data: {}, user: null }); +export const invokers: Invokers = []; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-ctx-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-ctx-shadow/expected.ts index a645dde3d3..28475e6345 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-ctx-shadow/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-ctx-shadow/expected.ts @@ -7,7 +7,7 @@ export default createResolver({ operation: "query", output: t.array(t.string()), body: (ctx) => ({ - me: ctx.caller.id, + me: ctx.caller?.id, others: items.map((ctx) => ctx.user.id), }), }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure-shadow/expected.ts index bd3efce010..a8706f56a4 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure-shadow/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure-shadow/expected.ts @@ -6,5 +6,5 @@ export default createResolver({ name: "n", operation: "query", output: t.array(t.string()), - body: ({ caller }) => items.map(({ user }) => user.id).concat(caller.id), + body: ({ caller }) => items.map(({ user }) => user.id).concat(caller?.id), }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/resolver-nested-principal-pattern/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/resolver-nested-principal-pattern/input.ts new file mode 100644 index 0000000000..2128998b27 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/resolver-nested-principal-pattern/input.ts @@ -0,0 +1,8 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ user: { id } }) => id, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadow/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadow/expected.ts index bf92469935..d987710ecc 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadow/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadow/expected.ts @@ -10,6 +10,6 @@ export default createResolver({ const user = { id: "fake" }; return { id: user.id }; } - return { id: caller.id }; + return { id: caller?.id }; }, }); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadowed-sdk-field/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadowed-sdk-field/input.ts new file mode 100644 index 0000000000..11951b8a71 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadowed-sdk-field/input.ts @@ -0,0 +1,7 @@ +import { db } from "@tailor-platform/sdk"; + +export function buildField(db: { string(): { hooks(config: unknown): unknown } }) { + return db.string().hooks({ + create: ({ user }: { user: { id: string } }) => user.id, + }); +} diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-callbacks/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-callbacks/expected.ts new file mode 100644 index 0000000000..e8feb308ec --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-callbacks/expected.ts @@ -0,0 +1,150 @@ +import { db, t, type TailorPrincipal, type TailorPrincipal as MyUser } from "@tailor-platform/sdk"; + +const roleCreate = ({ value, invoker }: { value: string; invoker: TailorPrincipal | null }) => + invoker?.attributes.role === "ADMIN" ? value : "user"; + +function hasMachineUser({ invoker }: { invoker: TailorPrincipal | null }) { + return invoker?.type === "machine_user"; +} + +const hasInvoker = (ctx: { invoker: TailorPrincipal | null }) => ctx.invoker?.id !== ""; + +type HookArgs = { + value: string; + invoker: TailorPrincipal | null; +}; + +interface ValidatorArgs { + invoker: TailorPrincipal | null; +} + +const namedTypeHook = ({ invoker }: HookArgs) => invoker?.id ?? "anonymous"; +const namedTypeValidator = ({ invoker }: ValidatorArgs) => invoker?.id !== ""; + +type StrictHookArgs = { + value: string; + invoker: TailorPrincipal | null; +}; + +const strictHook = ({ invoker }: { invoker: TailorPrincipal | null }) => { + const { id } = invoker ?? {}; + return id; +}; + +const namedStrictHook = ({ invoker }: StrictHookArgs) => { + const { id } = invoker ?? {}; + return id; +}; + +const aliasedStrictHook = ({ invoker }: { invoker: MyUser | null }) => invoker?.id; + +const sharedHooks = { + create: ({ invoker }: { invoker: TailorPrincipal | null }) => invoker?.id ?? "anonymous", + update: namedTypeHook, +}; + +const role = db + .string() + .hooks({ + create: roleCreate, + update: (ctx: { invoker: TailorPrincipal | null }) => ctx.invoker?.id ?? "anonymous", + delete({ user }) { + return user?.id ?? "anonymous"; + }, + }) + .validate([ + [hasMachineUser, "Machine user required"], + ctx => ctx.invoker?.id !== "", + ]) + .validate(hasInvoker) + .validate(namedTypeValidator); + +const localHookedRole = db.string().hooks(sharedHooks); +const strictHookedRole = db + .string() + .hooks({ create: strictHook, update: namedStrictHook }) + .hooks({ create: aliasedStrictHook }); + +const invoker = { id: "outer-invoker" }; + +const directHookedRole = db.string().hooks({ + create: ({ invoker }) => { + const parsed = t.string().parse({ value: "hello", data: {}, invoker }); + const parsedOther = { parse: (arg: unknown) => arg }.parse({ user: invoker }); + const { id } = invoker ?? {}; + return parsed.value ?? invoker?.["id"] ?? id; + }, + update: (ctx) => { + const { invoker: user } = ctx; + const { invoker: currentUser } = ctx; + return user?.id ?? currentUser?.id; + }, +}); + +const externalInvokerHookedRole = db.string().hooks({ + create: ({ invoker: user }) => { + const parsed = t.string().parse({ value: "hello", data: {}, invoker: user }); + return user?.id ?? invoker.id ?? parsed.value; + }, +}); + +const reviewer = t.string(); +const zodLike = { parse: (arg: unknown) => arg }; + +export const user = db + .type("User", { + role, + note: db.string(), + }) + .hooks({ + note: { + create: ({ invoker: currentUser }) => { + const audit = [{ user: { id: "data-user" } }].map(({ user }) => user.id); + return currentUser?.id ?? audit[0] ?? "anonymous"; + }, + update({ invoker: user }) { + const invoker = user?.id ?? "anonymous"; + return invoker; + }, + }, + }) + .validate({ + note: (ctx) => ctx.invoker?.type !== "machine_user", + fallback: ({ invoker: user = null }) => { + const labels = ["anonymous"].map((invoker) => invoker); + return user?.id !== labels[0]; + }, + typed: ({ invoker }: { invoker: TailorPrincipal | null }) => invoker?.id !== "", + }); + +export const audit = db + .type("Audit", { + create: db.string(), + update: db.string(), + }) + .hooks({ + create: { + create: ({ invoker }: { invoker: TailorPrincipal | null }) => invoker?.id ?? "anonymous", + update: (ctx: { invoker: TailorPrincipal | null }) => ctx.invoker?.id ?? "anonymous", + }, + }); + +export const parsed = t.string().parse({ + value: "hello", + data: {}, + invoker: null, +}); + +export const parsedLocal = reviewer.parse({ + value: "hello", + data: {}, + invoker: null, +}); + +export const parsedOther = zodLike.parse({ + user: null, +}); + +export function parseWithShadow(reviewer: { parse(arg: unknown): unknown }, user: unknown) { + return reviewer.parse({ user }); +} diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-callbacks/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-callbacks/input.ts new file mode 100644 index 0000000000..8411583a1a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-callbacks/input.ts @@ -0,0 +1,151 @@ +import { db, t, type TailorUser, type TailorUser as MyUser } from "@tailor-platform/sdk"; +import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; + +const roleCreate = ({ value, user }: { value: string; user: TailorUser | null }) => + user?.attributes.role === "ADMIN" ? value : "user"; + +function hasMachineUser({ user }: { user: TailorUser | null }) { + return user?.type === "machine_user"; +} + +const hasInvoker = (ctx: { user: TailorUser | null }) => ctx.user?.id !== ""; + +type HookArgs = { + value: string; + user: TailorUser | null; +}; + +interface ValidatorArgs { + user: TailorUser | null; +} + +const namedTypeHook = ({ user }: HookArgs) => user?.id ?? "anonymous"; +const namedTypeValidator = ({ user }: ValidatorArgs) => user?.id !== ""; + +type StrictHookArgs = { + value: string; + user: TailorUser; +}; + +const strictHook = ({ user }: { user: TailorUser }) => { + const { id } = user; + return id; +}; + +const namedStrictHook = ({ user }: StrictHookArgs) => { + const { id } = user; + return id; +}; + +const aliasedStrictHook = ({ user }: { user: MyUser }) => user.id; + +const sharedHooks = { + create: ({ user }: { user: TailorUser | null }) => user?.id ?? "anonymous", + update: namedTypeHook, +}; + +const role = db + .string() + .hooks({ + create: roleCreate, + update: (ctx: { user: TailorUser | null }) => ctx.user?.id ?? "anonymous", + delete({ user }) { + return user?.id ?? "anonymous"; + }, + }) + .validate([ + [hasMachineUser, "Machine user required"], + ctx => ctx.user?.id !== "", + ]) + .validate(hasInvoker) + .validate(namedTypeValidator); + +const localHookedRole = db.string().hooks(sharedHooks); +const strictHookedRole = db + .string() + .hooks({ create: strictHook, update: namedStrictHook }) + .hooks({ create: aliasedStrictHook }); + +const invoker = { id: "outer-invoker" }; + +const directHookedRole = db.string().hooks({ + create: ({ user }) => { + const parsed = t.string().parse({ value: "hello", data: {}, user }); + const parsedOther = { parse: (arg: unknown) => arg }.parse({ user }); + const { id } = user; + return parsed.value ?? user["id"] ?? id; + }, + update: (ctx) => { + const { user } = ctx; + const { user: currentUser } = ctx; + return user.id ?? currentUser.id; + }, +}); + +const externalInvokerHookedRole = db.string().hooks({ + create: ({ user }) => { + const parsed = t.string().parse({ value: "hello", data: {}, user }); + return user.id ?? invoker.id ?? parsed.value; + }, +}); + +const reviewer = t.string(); +const zodLike = { parse: (arg: unknown) => arg }; + +export const user = db + .type("User", { + role, + note: db.string(), + }) + .hooks({ + note: { + create: ({ user: currentUser }) => { + const audit = [{ user: { id: "data-user" } }].map(({ user }) => user.id); + return currentUser?.id ?? audit[0] ?? "anonymous"; + }, + update({ user }) { + const invoker = user?.id ?? "anonymous"; + return invoker; + }, + }, + }) + .validate({ + note: (ctx) => ctx.user?.type !== "machine_user", + fallback: ({ user = unauthenticatedTailorUser }) => { + const labels = ["anonymous"].map((invoker) => invoker); + return user?.id !== labels[0]; + }, + typed: ({ user }: { user: TailorUser | null }) => user?.id !== "", + }); + +export const audit = db + .type("Audit", { + create: db.string(), + update: db.string(), + }) + .hooks({ + create: { + create: ({ user }: { user: TailorUser | null }) => user?.id ?? "anonymous", + update: (ctx: { user: TailorUser | null }) => ctx.user?.id ?? "anonymous", + }, + }); + +export const parsed = t.string().parse({ + value: "hello", + data: {}, + user: unauthenticatedTailorUser, +}); + +export const parsedLocal = reviewer.parse({ + value: "hello", + data: {}, + user: unauthenticatedTailorUser, +}); + +export const parsedOther = zodLike.parse({ + user: unauthenticatedTailorUser, +}); + +export function parseWithShadow(reviewer: { parse(arg: unknown): unknown }, user: unknown) { + return reviewer.parse({ user }); +} diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-nested-principal-pattern/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-nested-principal-pattern/input.ts new file mode 100644 index 0000000000..00363a5ac1 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/tailordb-nested-principal-pattern/input.ts @@ -0,0 +1,5 @@ +import { db } from "@tailor-platform/sdk"; + +export const role = db.string().hooks({ + create: ({ user: { id } }) => id, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/expected.ts index ee77509637..ab3398b965 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/expected.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/expected.ts @@ -3,5 +3,30 @@ import type { TailorPrincipal } from "@tailor-platform/sdk"; export type Props = { user: TailorPrincipal; actor: TailorPrincipal; - invoker: TailorPrincipal; + invoker: (TailorPrincipal | null); + nullableInvoker: (TailorPrincipal | null) | null; + invokers: (TailorPrincipal | null)[]; + actorType: (TailorPrincipal["type"] | undefined); }; + +export function actorFields(actor: TailorPrincipal | null) { + return { + id: actor?.id, + type: actor?.type, + }; +} + +export const actorTypeValue: (TailorPrincipal["type"] | undefined) = "user"; +export const actorTypeMissing: (TailorPrincipal["type"] | undefined) = undefined; +export const allowedActorTypes: (TailorPrincipal["type"] | undefined)[] = [ + "user", + "machine_user", +]; +export const actorTypeConfig: { primary: (TailorPrincipal["type"] | undefined); fallback?: (TailorPrincipal["type"] | undefined) } = { + primary: "user", + fallback: undefined, +}; + +export function isUserType(type: (TailorPrincipal["type"] | undefined)) { + return type === "user"; +} diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/input.ts index d42dfc8985..e60423d984 100644 --- a/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/input.ts +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/input.ts @@ -1,7 +1,32 @@ -import type { TailorUser, TailorActor, TailorInvoker } from "@tailor-platform/sdk"; +import type { TailorUser, TailorActor, TailorInvoker, TailorActorType } from "@tailor-platform/sdk"; export type Props = { user: TailorUser; actor: TailorActor; invoker: TailorInvoker; + nullableInvoker: TailorInvoker | null; + invokers: TailorInvoker[]; + actorType: TailorActorType; }; + +export function actorFields(actor: TailorActor | null) { + return { + id: actor?.userId, + type: actor?.userType, + }; +} + +export const actorTypeValue: TailorActorType = "USER_TYPE_USER"; +export const actorTypeMissing: TailorActorType = "USER_TYPE_UNSPECIFIED"; +export const allowedActorTypes: TailorActorType[] = [ + "USER_TYPE_USER", + "USER_TYPE_MACHINE_USER", +]; +export const actorTypeConfig: { primary: TailorActorType; fallback?: TailorActorType } = { + primary: "USER_TYPE_USER", + fallback: "USER_TYPE_UNSPECIFIED", +}; + +export function isUserType(type: TailorActorType) { + return type === "USER_TYPE_USER"; +} diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/codemod.yaml b/packages/sdk-codemod/codemods/v2/rename-bin/codemod.yaml new file mode 100644 index 0000000000..3c7789c8c9 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/rename-bin" +version: "1.0.0" +description: "Rename the CLI binary from tailor-sdk to tailor in scripts, source files, CI workflows, and documentation" +engine: jssg +language: text +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/rename-bin/scripts/transform.ts new file mode 100644 index 0000000000..bd8ceef52d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/scripts/transform.ts @@ -0,0 +1,1633 @@ +import { parse, Lang } from "@ast-grep/napi"; +import * as path from "pathe"; +import type { SgNode } from "@ast-grep/napi"; + +const SOURCE_ARG_VALUE = `(?:[^\\s'"\`;|&]+|'[^']*'|"(?:(?:\\\\.)|[^"\\\\])*")`; +const RUNNER_OPTION_VALUE_FLAG_LIST = [ + "--registry", + "--cache", + "--userconfig", + "--prefix", + "--filter", + "-F", + "--dir", + "-C", + "--cwd", +] as const; +const RUNNER_OPTION_VALUE_FLAG_PATTERN = `(?:${RUNNER_OPTION_VALUE_FLAG_LIST.join("|")})`; +const PACKAGE_RUNNER_BOOLEAN_OPTION = `(?!(?:${RUNNER_OPTION_VALUE_FLAG_PATTERN})(?:=|\\s|$))(?:-\\w+|--\\w[\\w-]*)(?:=${SOURCE_ARG_VALUE})?`; +const PACKAGE_RUNNER_OPTION = `(?:${RUNNER_OPTION_VALUE_FLAG_PATTERN}(?:=${SOURCE_ARG_VALUE}|\\s+${SOURCE_ARG_VALUE})|${PACKAGE_RUNNER_BOOLEAN_OPTION})`; +const PACKAGE_RUNNER_COMMAND = `(?:npx|bunx|(?:pnpm|yarn)(?:\\s+${PACKAGE_RUNNER_OPTION})*\\s+dlx)`; + +// Package-runner forms (`npx`, `pnpm dlx`, `yarn dlx`, `bunx`) resolve npm package +// names, so `tailor-sdk@...` must become `@tailor-platform/sdk@...` — rewriting +// to `tailor@...` would download the unrelated CSS Sprites Generator instead. +// Optional flags (e.g. `-y`, `--yes`) between the runner and the package name are +// captured as part of the runner group so the replacement preserves them. +const PKG_RUNNER_RE = new RegExp( + `\\b(${PACKAGE_RUNNER_COMMAND}(?:\\s+(?:-\\w+|--\\w[\\w-]*))*)\\s+tailor-sdk(?![\\w-])(@[^\\s'"\`;|&)]+)?`, + "g", +); + +// Match the `tailor-sdk` binary, optionally with a version pin (`@latest`, +// `@2.0.0`, etc.). Lookbehind excludes `.tailor-sdk` (preceded by `.`) and +// `create-tailor-sdk` (preceded by `-`). Lookahead excludes trailing `-word` +// (e.g. `tailor-sdk-skills`) to avoid partial-match rewrites. +const TAILOR_SDK_RE = /(? + version ? `${runner} @tailor-platform/sdk${version}` : `${runner} @tailor-platform/sdk`, + ); + return withRunners.replace(TAILOR_SDK_RE, (_match, version?: string) => + version ? `@tailor-platform/sdk${version}` : "tailor", + ); +} + +function renamePackageName(value: string): string { + return value.replace(TAILOR_SDK_TOKEN_RE, (_match, version?: string) => + version ? `@tailor-platform/sdk${version}` : "@tailor-platform/sdk", + ); +} + +function renameSourcePackageToken(token: string): string | null { + const value = sourceTokenValue(token); + if (!TAILOR_SDK_TOKEN_RE.test(value)) return null; + return replaceSourceTokenValue(token, renamePackageName(value)); +} + +function renameSourceBinaryToken(token: string): string | null { + const value = sourceTokenValue(token); + if (!TAILOR_SDK_COMMAND_TOKEN_RE.test(value)) return null; + return replaceSourceTokenValue( + token, + value.includes("@") && !/\.(?:cmd|ps1|exe)$/.test(value) + ? renamePackageName(value) + : renameBinary(value), + ); +} + +function isTailorPackageValue(value: string): boolean { + return TAILOR_SDK_TOKEN_RE.test(value) || TAILOR_PLATFORM_SDK_TOKEN_RE.test(value); +} + +function sourcePathBasename(value: string): string { + return value.split(/[\\/]/).at(-1) ?? value; +} + +function isTailorSdkBinaryTokenValue(value: string): boolean { + return TAILOR_SDK_COMMAND_TOKEN_RE.test(sourcePathBasename(value)); +} + +function isTailorCliTokenValue(value: string): boolean { + const basename = sourcePathBasename(value); + return ( + TAILOR_CLI_TOKEN_RE.test(value) || + TAILOR_COMMAND_TOKEN_RE.test(basename) || + TAILOR_SDK_COMMAND_TOKEN_RE.test(basename) + ); +} + +function replaceSourceSpans( + value: string, + replacements: Map, + tokens: Array<{ start: number; end: number }>, +): string { + let updated = value; + for (const [index, replacement] of [...replacements.entries()].toSorted(([a], [b]) => b - a)) { + const token = tokens[index]; + if (token == null) continue; + updated = `${updated.slice(0, token.start)}${replacement}${updated.slice(token.end)}`; + } + return updated; +} + +type ProtectedSourceValue = { placeholder: string; value: string }; + +function sourceValuePlaceholder( + index: number, + source: string, + usedPlaceholders: Set, +): string { + let attempt = 0; + while (true) { + const placeholder = `__TAILOR_SDK_SOURCE_VALUE_${index}_${attempt}__`; + if (!source.includes(placeholder) && !usedPlaceholders.has(placeholder)) { + usedPlaceholders.add(placeholder); + return placeholder; + } + attempt += 1; + } +} + +function protectSourceCliValueReferences(value: string): { + source: string; + protectedValues: ProtectedSourceValue[]; +} { + const protectedValues: ProtectedSourceValue[] = []; + const usedPlaceholders = new Set(); + const source = value.replace( + SOURCE_OPTION_VALUE_REFERENCE_RE, + (match: string, prefix: string, arg: string, offset: number) => { + if (!arg.includes("tailor-sdk")) return match; + const flag = sourceOptionFlag(prefix); + const afterPackageRunner = isAfterPackageRunnerPrefix(value, offset); + if (afterPackageRunner && !isPackageRunnerOptionValue(value, offset, flag)) { + return match; + } + if (!afterPackageRunner && !isAfterTailorCliToken(value, offset)) { + return match; + } + if (isPackageFlag(flag) && NPX_PACKAGE_FLAG_CONTEXT_RE.test(value.slice(0, offset))) { + return match; + } + const placeholder = sourceValuePlaceholder(protectedValues.length, value, usedPlaceholders); + protectedValues.push({ placeholder, value: arg }); + return `${prefix}${placeholder}`; + }, + ); + return { source, protectedValues }; +} + +function restoreSourceCliValueReferences( + value: string, + protectedValues: ProtectedSourceValue[], +): string { + let restored = value; + for (const protectedValue of protectedValues) { + restored = restored.replaceAll(protectedValue.placeholder, () => protectedValue.value); + } + return restored; +} + +function sourceOptionFlag(prefix: string): string { + return prefix.trim().replace(/[=\s].*$/, ""); +} + +function isPackageFlag(value: string): boolean { + return value === "-p" || value === "--package"; +} + +function sourceTokens(value: string): string[] | null { + const tokens = sourceTokenSpans(value); + return tokens == null ? null : tokens.map((token) => token.value); +} + +function sourceTokenSpans(value: string): Array<{ + start: number; + end: number; + text: string; + value: string; +}> | null { + const tokens: string[] = []; + SOURCE_TOKEN_RE.lastIndex = 0; + let lastEnd = 0; + for (const match of value.matchAll(SOURCE_TOKEN_RE)) { + if (value.slice(lastEnd, match.index).trim() !== "") return null; + tokens.push(sourceTokenValue(match[0])); + lastEnd = (match.index ?? 0) + match[0].length; + } + if (value.slice(lastEnd).trim() !== "") return null; + + const spans = [...value.matchAll(new RegExp(SOURCE_CLI_ARG_VALUE, "g"))].map((match) => ({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + text: match[0], + value: sourceTokenValue(match[0]), + })); + return mergeSourceEqualsQuotedTokenSpans(spans); +} + +function mergeSourceEqualsQuotedTokenSpans( + spans: Array<{ start: number; end: number; text: string; value: string }>, +): Array<{ start: number; end: number; text: string; value: string }> { + const merged: Array<{ start: number; end: number; text: string; value: string }> = []; + for (const span of spans) { + const previous = merged.at(-1); + if (previous != null && previous.end === span.start && previous.text.endsWith("=")) { + previous.end = span.end; + previous.text += span.text; + previous.value += span.value; + continue; + } + merged.push({ ...span }); + } + return merged; +} + +function sourceTokenValue(token: string): string { + if (token.startsWith('\\"') && token.endsWith('\\"')) return token.slice(2, -2); + if (token.startsWith("\\'") && token.endsWith("\\'")) return token.slice(2, -2); + if (token.startsWith('"') && token.endsWith('"')) return token.slice(1, -1); + if (token.startsWith("'") && token.endsWith("'")) return token.slice(1, -1); + return token; +} + +function replaceSourceTokenValue(token: string, replacement: string): string { + if (token.startsWith('\\"') && token.endsWith('\\"')) return `\\"${replacement}\\"`; + if (token.startsWith("\\'") && token.endsWith("\\'")) return `\\'${replacement}\\'`; + if (token.startsWith('"') && token.endsWith('"')) return `"${replacement}"`; + if (token.startsWith("'") && token.endsWith("'")) return `'${replacement}'`; + return replacement; +} + +function activeQuoteStart(source: string, start: number, offset: number): number | null { + let quote: { delimiter: string; escaped: boolean; start: number } | null = null; + for (let index = start; index < offset; index += 1) { + const char = source[index]; + if (quote != null) { + if (quote.escaped) { + if (char === "\\") { + let runEnd = index + 1; + while (source[runEnd] === "\\") runEnd += 1; + if (runEnd === index + 1 && source[runEnd] === quote.delimiter) { + quote = null; + index = runEnd; + } else { + index = runEnd - 1; + } + } + } else if (char === "\\") { + index += 1; + } else if (char === quote.delimiter) { + quote = null; + } + continue; + } + if (char === "\\" && (source[index + 1] === '"' || source[index + 1] === "'")) { + quote = { delimiter: source[index + 1]!, escaped: true, start: index }; + index += 1; + } else if (char === '"' || char === "'") { + quote = { delimiter: char, escaped: false, start: index }; + } + } + return quote?.start ?? null; +} + +function skipsRunnerOptionValue(token: string, executable?: string): boolean { + const flag = token.split("=", 1)[0]!; + return ( + (RUNNER_OPTION_VALUE_FLAGS.has(flag) || + (executable === "npm" && NPM_OPTION_VALUE_FLAGS.has(flag))) && + !token.includes("=") + ); +} + +function isPotentialValueFlag(value: string): boolean { + const flag = value.split("=", 1)[0]!; + return /^--[\w-]+$/.test(flag) || /^-\w$/.test(flag); +} + +function packageRunnerPackageStartTokenIndex(tokens: readonly string[]): number | null { + const executable = tokens[0]; + if (executable === "npx" || executable === "bunx") { + return 1; + } else if (executable === "npm") { + let index = 1; + for (; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (token === "exec") { + return index + 1; + } + if (token.startsWith("-")) { + if (skipsRunnerOptionValue(token, executable)) index += 1; + continue; + } + return null; + } + } else if (executable === "pnpm" || executable === "yarn") { + let index = 1; + for (; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (token === "dlx") { + return index + 1; + } + if (token.startsWith("-")) { + if (skipsRunnerOptionValue(token, executable)) index += 1; + continue; + } + return null; + } + } + return null; +} + +function renameSourcePackageRunnerTokens(value: string): string { + return transformSourceCommandSegments(value, renameSourcePackageRunnerTokenSegment); +} + +function transformSourceCommandSegments( + value: string, + transform: (segment: string) => string, +): string { + let result = ""; + let segmentStart = 0; + let quote: string | null = null; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]!; + if (quote != null) { + if (char === "\\") { + index += 1; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === "\\" && (value[index + 1] === '"' || value[index + 1] === "'")) { + index += 1; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === ";" || char === "&" || char === "|") { + result += transform(value.slice(segmentStart, index)) + char; + segmentStart = index + 1; + } + } + return result + transform(value.slice(segmentStart)); +} + +function renameSourcePackageRunnerTokenSegment(value: string): string { + const spans = sourceTokenSpans(value); + if (spans == null) return value; + const tokens = spans.map((span) => span.value); + const start = packageRunnerPackageStartTokenIndex(tokens); + if (start == null) return value; + + const replacements = new Map(); + let hasPackageFlag = false; + let hasTailorPackageFlag = false; + + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (SOURCE_PACKAGE_FLAG_RE.test(token)) { + hasPackageFlag = true; + if (token.includes("=")) { + const [flag, rawValue = ""] = spans[index]!.text.split(/=(.*)/s, 2); + const packageReplacement = renameSourcePackageToken(rawValue); + if (packageReplacement != null) { + replacements.set(index, `${flag}=${packageReplacement}`); + hasTailorPackageFlag = true; + } else if (isTailorPackageValue(sourceTokenValue(rawValue))) { + hasTailorPackageFlag = true; + } + } else { + const valueIndex = index + 1; + const value = spans[valueIndex]; + if (value != null) { + const packageReplacement = renameSourcePackageToken(value.text); + if (packageReplacement != null) { + replacements.set(valueIndex, packageReplacement); + hasTailorPackageFlag = true; + } else if (isTailorPackageValue(value.value)) { + hasTailorPackageFlag = true; + } + } + index += 1; + } + continue; + } + + if (token.startsWith("-")) { + if (skipsRunnerOptionValue(token, tokens[0])) index += 1; + continue; + } + + if (!hasPackageFlag) { + const packageReplacement = renameSourcePackageToken(spans[index]!.text); + if (packageReplacement != null) replacements.set(index, packageReplacement); + break; + } + + if (hasTailorPackageFlag) { + const binaryReplacement = renameSourceBinaryToken(spans[index]!.text); + if (binaryReplacement != null) replacements.set(index, binaryReplacement); + } + break; + } + + return replacements.size === 0 ? value : replaceSourceSpans(value, replacements, spans); +} + +function hasPositionalPackageBeforeSourcePackageFlag( + tokens: readonly string[], + start: number, +): boolean { + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (SOURCE_PACKAGE_FLAG_RE.test(token)) { + if (!token.includes("=")) index += 1; + continue; + } + if (token.startsWith("-")) { + if (skipsRunnerOptionValue(token, tokens[0])) index += 1; + continue; + } + return true; + } + return false; +} + +function firstRunnerPackageToken(tokens: string[]): string | null { + const start = packageRunnerPackageStartTokenIndex(tokens); + if (start == null) return null; + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (SOURCE_PACKAGE_FLAG_RE.test(token)) { + return token.includes("=") + ? sourceTokenValue(token.slice(token.indexOf("=") + 1)) + : (tokens[index + 1] ?? null); + } + if (token.startsWith("-")) { + if (skipsRunnerOptionValue(token, tokens[0])) index += 1; + continue; + } + return token; + } + return null; +} + +function isPackageRunnerOptionValue(source: string, offset: number, flag: string): boolean { + if (!RUNNER_OPTION_VALUE_FLAGS.has(flag)) return false; + const segmentStart = Math.max( + source.lastIndexOf(";", offset - 1), + source.lastIndexOf("&", offset - 1), + source.lastIndexOf("|", offset - 1), + ); + const tokens = sourceTokens(source.slice(segmentStart + 1, offset).trim()); + const executable = tokens?.[0]; + return ( + executable === "npx" || executable === "bunx" || executable === "pnpm" || executable === "yarn" + ); +} + +function isAfterPackageRunnerPrefix(source: string, offset: number): boolean { + const segmentStart = Math.max( + source.lastIndexOf(";", offset - 1), + source.lastIndexOf("&", offset - 1), + source.lastIndexOf("|", offset - 1), + ); + const tokens = sourceTokens(source.slice(segmentStart + 1, offset).trim()); + const executable = tokens?.[0]; + if (executable === "npx" || executable === "bunx") return true; + return (executable === "pnpm" || executable === "yarn") && tokens?.includes("dlx") === true; +} + +function isPackageFlagValueInPackageRunner(source: string, offset: number): boolean { + const segmentStart = Math.max( + source.lastIndexOf(";", offset - 1), + source.lastIndexOf("&", offset - 1), + source.lastIndexOf("|", offset - 1), + ); + const tokens = sourceTokens(source.slice(segmentStart + 1, offset).trim()); + if (tokens == null) return false; + const start = packageRunnerPackageStartTokenIndex(tokens); + return start != null && !hasPositionalPackageBeforeSourcePackageFlag(tokens, start); +} + +function sourcePackageFlagsAllowBinaryRewrite(source: string): boolean { + const tokens = sourceTokens(source.trim()); + if (tokens == null) return false; + const start = packageRunnerPackageStartTokenIndex(tokens); + if (start == null) return false; + + let hasPackageFlag = false; + let hasTailorPackageFlag = false; + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (SOURCE_PACKAGE_FLAG_RE.test(token)) { + hasPackageFlag = true; + const rawValue = token.includes("=") + ? token.slice(token.indexOf("=") + 1) + : tokens[index + 1]; + const value = rawValue == null ? null : sourceTokenValue(rawValue); + if (value != null && isTailorPackageValue(value)) { + hasTailorPackageFlag = true; + } + if (!token.includes("=")) index += 1; + continue; + } + if (token.startsWith("-")) { + if (skipsRunnerOptionValue(token, tokens[0])) index += 1; + continue; + } + return false; + } + return hasPackageFlag && hasTailorPackageFlag; +} + +function isAfterOtherPackageRunner(source: string, offset: number): boolean { + const segmentStart = Math.max( + source.lastIndexOf(";", offset - 1), + source.lastIndexOf("&", offset - 1), + source.lastIndexOf("|", offset - 1), + ); + const segment = source.slice(segmentStart + 1, offset).trim(); + const tokens = sourceTokens(segment); + if (tokens == null) { + const quoteStart = activeQuoteStart(source, segmentStart + 1, offset); + if (quoteStart == null) return false; + const prefixTokens = sourceTokens(source.slice(segmentStart + 1, quoteStart).trim()); + const packageToken = prefixTokens == null ? null : firstRunnerPackageToken(prefixTokens); + return packageToken != null && !TAILOR_SDK_TOKEN_RE.test(packageToken); + } + const packageToken = firstRunnerPackageToken(tokens); + return packageToken != null && !TAILOR_SDK_TOKEN_RE.test(packageToken); +} + +function isAfterTailorCliToken(source: string, offset: number): boolean { + const segmentStart = Math.max( + source.lastIndexOf(";", offset - 1), + source.lastIndexOf("&", offset - 1), + source.lastIndexOf("|", offset - 1), + ); + const segment = source.slice(segmentStart + 1, offset).trim(); + const tokens = sourceTokens(segment); + return tokens != null && tokens.some((token) => isTailorCliTokenValue(token)); +} + +function isAfterTemplatePlaceholder(source: string, offset: number): boolean { + const segmentStart = Math.max( + source.lastIndexOf(";", offset - 1), + source.lastIndexOf("&", offset - 1), + source.lastIndexOf("|", offset - 1), + ); + const segment = source.slice(segmentStart + 1, offset).trim(); + const tokens = sourceTokens(segment); + return tokens != null && tokens.some((token) => SOURCE_TEMPLATE_EXPR_PLACEHOLDER_RE.test(token)); +} + +function isTemplateSubstitutionCliValue(text: string, offset: number): boolean { + const tokens = sourceTokens(text.slice(0, offset).trimEnd()); + if (tokens == null || tokens.length === 0) return false; + const previous = tokens.at(-1)!; + if (!isTailorCliValueFlag(previous)) return false; + return tokens.slice(0, -1).some((token) => isTailorCliTokenValue(token)); +} + +function needsCliRenameMigration(value: string): boolean { + if (!value.includes("tailor-sdk")) return false; + const tokens = sourceTokens(value); + if (tokens == null) return CLI_RENAME_LEGACY_RE.test(value); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (isTailorCliValueToken(tokens, index)) continue; + if (token !== value && token.includes("tailor-sdk") && needsCliRenameMigration(token)) { + return true; + } + if (!isTailorSdkBinaryTokenValue(token)) continue; + if (tokensAfterCliBinaryNeedRename(tokens, index + 1)) return true; + } + return false; +} + +function isTailorCliValueToken(tokens: readonly string[], index: number): boolean { + const token = tokens[index]!; + const flag = token.split("=", 1)[0]!; + if ( + token.includes("=") && + TAILOR_CLI_VALUE_FLAGS.has(flag) && + tokens.slice(0, index).some((value) => isTailorCliTokenValue(value)) + ) { + return true; + } + + const previous = tokens[index - 1]; + return ( + previous != null && + TAILOR_CLI_VALUE_FLAGS.has(previous.split("=", 1)[0]!) && + !previous.includes("=") && + tokens.slice(0, index - 1).some((value) => isTailorCliTokenValue(value)) + ); +} + +function tokensAfterCliBinaryNeedRename(tokens: readonly string[], start: number): boolean { + let commandSeen = false; + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (token === "--machineuser" || token.startsWith("--machineuser=")) return true; + if (token.startsWith("-")) { + if (TAILOR_CLI_VALUE_FLAGS.has(token.split("=", 1)[0]!) && !token.includes("=")) { + index += 1; + } + continue; + } + if (!commandSeen) { + if (token === "apply" || token === "crash-report") return true; + commandSeen = true; + } + } + return false; +} + +function renameSourceCommandText(value: string): string { + if (needsCliRenameMigration(value)) return value; + + const protectedValue = protectSourceCliValueReferences(value); + const withQuotedPackageFlagValues = protectedValue.source.replace( + SOURCE_PACKAGE_FLAG_EQUALS_QUOTED_VALUE_RE, + ( + match: string, + prefix: string, + openQuote: string, + version: string | undefined, + closeQuote: string, + offset: number, + source: string, + ) => { + if (!isPackageFlagValueInPackageRunner(source, offset)) return match; + const packageName = version ? `@tailor-platform/sdk${version}` : "@tailor-platform/sdk"; + return `${prefix}${openQuote}${packageName}${closeQuote}`; + }, + ); + const withTokenPackageRunners = renameSourcePackageRunnerTokens(withQuotedPackageFlagValues); + const withPackageFlagValues = withTokenPackageRunners.replace( + SOURCE_PACKAGE_FLAG_VALUE_RE, + ( + match: string, + prefix: string, + version: string | undefined, + offset: number, + source: string, + ) => { + if (!isPackageFlagValueInPackageRunner(source, offset)) return match; + return version ? `${prefix}@tailor-platform/sdk${version}` : `${prefix}@tailor-platform/sdk`; + }, + ); + const withPackageRunners = withPackageFlagValues.replace( + SOURCE_PKG_RUNNER_RE, + (match: string, runner: string, version?: string) => { + if (/\s(?:-p|--package)(?:=|\s|$)/.test(runner)) return match; + return version + ? `${runner} @tailor-platform/sdk${version}` + : `${runner} @tailor-platform/sdk`; + }, + ); + const withPackageFlagBinaries = withPackageRunners.replace( + SOURCE_PACKAGE_FLAG_BINARY_RE, + (match: string, prefix: string, version?: string) => { + if (!/\s(?:-p|--package)(?:=|\s)/.test(prefix)) return match; + if (/(?:^|\s)(?:-p|--package)\s+$/.test(prefix)) return match; + if (!sourcePackageFlagsAllowBinaryRewrite(prefix)) return match; + return version ? `${prefix}@tailor-platform/sdk${version}` : `${prefix}tailor`; + }, + ); + const withCommands = withPackageFlagBinaries.replace( + SOURCE_TAILOR_SDK_RE, + ( + match: string, + shim: string | undefined, + version: string | undefined, + offset: number, + source: string, + ) => { + if (isAfterOtherPackageRunner(source, offset)) return match; + if (isAfterTemplatePlaceholder(source, offset)) return match; + if (shim != null) return `tailor${shim}`; + return version ? `@tailor-platform/sdk${version}` : "tailor"; + }, + ); + const withDynamicCommands = withCommands.replace( + SOURCE_DYNAMIC_TAILOR_SDK_RE, + (_match, prefix: string, shim?: string, version?: string) => + shim != null + ? `${prefix}tailor${shim}` + : version + ? `${prefix}@tailor-platform/sdk${version}` + : `${prefix}tailor`, + ); + const updated = withDynamicCommands.replace( + SOURCE_DYNAMIC_OPTION_TAILOR_SDK_RE, + (_match, prefix: string, shim?: string, version?: string) => + shim != null + ? `${prefix}tailor${shim}` + : version + ? `${prefix}@tailor-platform/sdk${version}` + : `${prefix}tailor`, + ); + return restoreSourceCliValueReferences(updated, protectedValue.protectedValues); +} + +function sourceLang(filePath: string): Lang { + const ext = path.extname(filePath).toLowerCase(); + return ext === ".tsx" || ext === ".jsx" || ext === ".js" ? Lang.Tsx : Lang.TypeScript; +} + +function pushSourceTextEdit( + edits: Array<[number, number, string]>, + source: string, + start: number, + end: number, +): void { + const text = source.slice(start, end); + const replacement = renameSourceCommandText(text); + if (replacement !== text) { + edits.push([start, end, replacement]); + } +} + +function nodeRangeKey(node: SgNode): string { + const range = node.range(); + return `${range.start.index}:${range.end.index}`; +} + +function sourceStringContent(node: SgNode, source: string): string | null { + const kind = node.kind(); + if (kind !== "string" && kind !== "template_string") return null; + if ( + kind === "template_string" && + node.children().some((child: SgNode) => child.kind() === "template_substitution") + ) { + return null; + } + const range = node.range(); + return source.slice(range.start.index + 1, range.end.index - 1); +} + +function sourceStringRawContent(node: SgNode, source: string): string | null { + const kind = node.kind(); + if (kind !== "string" && kind !== "template_string") return null; + const range = node.range(); + return source.slice(range.start.index + 1, range.end.index - 1); +} + +function sourceConstInitializerContent(node: SgNode, source: string): string | null { + const directValue = sourceStringContent(node, source); + if (directValue != null) return directValue; + if ( + node.kind() !== "as_expression" && + node.kind() !== "satisfies_expression" && + node.kind() !== "parenthesized_expression" + ) { + return null; + } + for (const child of node.children()) { + const childValue = sourceConstInitializerContent(child, source); + if (childValue != null) return childValue; + } + return null; +} + +function isConstVariableDeclarator(node: SgNode): boolean { + return ( + node + .parent() + ?.children() + .some((child) => child.kind() === "const") ?? false + ); +} + +function sourceStaticStringContent(node: SgNode, source: string): string | null { + return ( + sourceStringContent(node, source) ?? + (node.kind() === "identifier" ? sourceScopedStringVariableContent(node, source) : null) + ); +} + +function sourceScopedStringVariableContent(identifier: SgNode, source: string): string | null { + const name = identifier.text(); + const before = identifier.range().start.index; + let current = identifier.parent(); + while (current != null) { + if (isSourceScopeNode(current)) { + const value = findSourceStringVariableInScope(current, name, before, source); + if (value != null) return value; + } + current = current.parent(); + } + return null; +} + +function findSourceStringVariableInScope( + scope: SgNode, + name: string, + before: number, + source: string, +): string | null { + let value: string | null = null; + const visit = (node: SgNode): void => { + if (node !== scope && isSourceScopeNode(node)) return; + if (node.kind() === "variable_declarator" && node.range().end.index < before) { + const declarationValue = sourceStringVariableDeclarationValue(node, name, source); + if (declarationValue != null) value = declarationValue; + return; + } + for (const child of node.children()) { + visit(child); + } + }; + visit(scope); + return value; +} + +function sourceStringVariableDeclarationValue( + node: SgNode, + name: string, + source: string, +): string | null { + if (!isConstVariableDeclarator(node)) return null; + const children = node.children(); + const identifier = children.find((child) => child.kind() === "identifier"); + if (identifier?.text() !== name) return null; + const initializer = children.findLast( + (child) => sourceConstInitializerContent(child, source) != null, + ); + return initializer == null ? null : sourceConstInitializerContent(initializer, source); +} + +function isSourceScopeNode(node: SgNode): boolean { + const kind = node.kind(); + return ( + kind === "program" || + kind === "statement_block" || + kind === "function_declaration" || + kind === "arrow_function" || + kind === "method_definition" + ); +} + +function isSyntaxOnlyNode(node: SgNode): boolean { + const kind = node.kind(); + return ( + kind === "[" || + kind === "]" || + kind === "(" || + kind === ")" || + kind === "," || + kind === "comment" + ); +} + +function sourceArrayElements(node: SgNode): SgNode[] { + return node.children().filter((child: SgNode) => !isSyntaxOnlyNode(child)); +} + +function nodeIndex(nodes: SgNode[], node: SgNode): number { + return nodes.findIndex((child: SgNode) => nodeRangeKey(child) === nodeRangeKey(node)); +} + +function callExpressionCalleeText(argumentsNode: SgNode): string | null { + const parentRange = nodeRangeKey(argumentsNode); + const call = argumentsNode.parent(); + if (call?.kind() !== "call_expression") return null; + const callee = call.children().find((child: SgNode) => nodeRangeKey(child) !== parentRange); + return callee?.text() ?? null; +} + +function firstNonOptionIndex( + elements: SgNode[], + start: number, + source: string, + executable: string, +): number | null { + for (let index = start; index < elements.length; index += 1) { + const value = sourceStringContent(elements[index]!, source); + if (value == null) return null; + if (!value.startsWith("-")) return index; + if (skipsRunnerOptionValue(value, executable)) { + index += 1; + } + } + return null; +} + +function isTailorCliValueFlag(value: string): boolean { + return TAILOR_CLI_VALUE_FLAGS.has(value.split("=", 1)[0]!) || isPotentialValueFlag(value); +} + +function packageRunnerPackageStartIndex( + executable: string, + elements: SgNode[], + source: string, +): number | null { + if (SOURCE_PACKAGE_RUNNERS.has(executable)) return 0; + if (SOURCE_NPM_EXEC_PACKAGE_RUNNERS.has(executable)) { + const execIndex = firstNonOptionIndex(elements, 0, source, executable); + if (execIndex == null || sourceStringContent(elements[execIndex]!, source) !== "exec") { + return null; + } + return execIndex + 1; + } + if (!SOURCE_DLX_PACKAGE_RUNNERS.has(executable)) return null; + const dlxIndex = firstNonOptionIndex(elements, 0, source, executable); + if (dlxIndex == null || sourceStringContent(elements[dlxIndex]!, source) !== "dlx") { + return null; + } + return dlxIndex + 1; +} + +function hasPackageFlagBeforeArrayPackage( + elements: SgNode[], + index: number, + source: string, + start: number, + executable: string, +): boolean { + for (let currentIndex = start; currentIndex < index; currentIndex += 1) { + const value = sourceStringContent(elements[currentIndex]!, source); + if (value == null) return false; + if (SOURCE_PACKAGE_FLAG_RE.test(value)) return true; + if (value.startsWith("-")) { + if (skipsRunnerOptionValue(value, executable)) currentIndex += 1; + continue; + } + return false; + } + return false; +} + +function isKnownTailorPackageValue(value: string | null): boolean { + return ( + value != null && (TAILOR_SDK_TOKEN_RE.test(value) || TAILOR_PLATFORM_SDK_TOKEN_RE.test(value)) + ); +} + +function hasTailorPackageFlagBeforeArrayCommand( + elements: SgNode[], + index: number, + source: string, + start: number, + executable: string, +): boolean { + for (let currentIndex = start; currentIndex < index; currentIndex += 1) { + const value = sourceStringContent(elements[currentIndex]!, source); + if (value == null) return false; + if (SOURCE_PACKAGE_FLAG_RE.test(value)) { + if (value.includes("=")) { + if (isKnownTailorPackageValue(value.slice(value.indexOf("=") + 1))) return true; + } else { + const nextValue = sourceStringContent(elements[currentIndex + 1]!, source); + if (isKnownTailorPackageValue(nextValue)) return true; + currentIndex += 1; + } + continue; + } + if (value.startsWith("-")) { + if (skipsRunnerOptionValue(value, executable)) currentIndex += 1; + continue; + } + return false; + } + return false; +} + +function isSplitPackageFlagValue( + elements: SgNode[], + index: number, + source: string, + start: number, + executable: string, +): boolean { + const previous = elements[index - 1]; + if (previous == null) return false; + const previousValue = sourceStringContent(previous, source); + return ( + (previousValue === "-p" || previousValue === "--package") && + hasPackageFlagBeforeArrayPackage(elements, index, source, start, executable) + ); +} + +function sourcePackageFlagReplacement( + node: SgNode, + source: string, +): { text: string; replacement: string } | null { + const text = sourceStringContent(node, source); + if (text == null) return null; + const match = /^(?-p=|--package=)tailor-sdk(?@[^\s'"`;|&)]+)?$/.exec(text); + if (match?.groups == null) return null; + const parent = node.parent(); + if (parent?.kind() !== "array" || !isPackageRunnerArrayArgument(node, source)) return null; + const argumentsNode = parent.parent(); + if (argumentsNode?.kind() !== "arguments") return null; + const callArgs = sourceArrayElements(argumentsNode); + const executableNode = callArgs[0]; + if (executableNode == null) return null; + const executable = sourceStringContent(executableNode, source); + if (executable == null) return null; + const elements = sourceArrayElements(parent); + const index = nodeIndex(elements, node); + const start = packageRunnerPackageStartIndex(executable, elements, source); + if (index < 0 || start == null) return null; + if (!hasPackageFlagBeforeArrayPackage(elements, index + 1, source, start, executable)) { + return null; + } + return { + text, + replacement: `${match.groups.prefix}${renamePackageName( + `tailor-sdk${match.groups.version ?? ""}`, + )}`, + }; +} + +function firstTailorPackageIndex( + elements: SgNode[], + start: number, + source: string, + executable: string, +): number | null { + for (let index = start; index < elements.length; index += 1) { + const value = sourceStringContent(elements[index]!, source); + if (value == null) return null; + if (value.startsWith("-")) { + if (skipsRunnerOptionValue(value, executable)) index += 1; + continue; + } + if (TAILOR_SDK_TOKEN_RE.test(value)) { + return index; + } + return null; + } + return null; +} + +function isPackageRunnerArrayArgument(node: SgNode, source: string): boolean { + const parent = node.parent(); + if (parent?.kind() !== "array") return false; + + const argumentsNode = parent.parent(); + if (argumentsNode?.kind() !== "arguments") return false; + const callee = callExpressionCalleeText(argumentsNode); + if (!CLI_ARGUMENT_CALLEE_RE.test(callee ?? "")) return false; + + const callArgs = sourceArrayElements(argumentsNode); + const executableNode = callArgs[0]; + if (executableNode == null) return false; + const executable = sourceStringContent(executableNode, source); + if (executable == null) return false; + + if (executable === "bunx" || executable === "npx") return true; + if (executable === "npm") { + const elements = sourceArrayElements(parent); + const execIndex = firstNonOptionIndex(elements, 0, source, executable); + return execIndex != null && sourceStringContent(elements[execIndex]!, source) === "exec"; + } + if (executable !== "pnpm" && executable !== "yarn") return false; + + const elements = sourceArrayElements(parent); + const dlxIndex = firstNonOptionIndex(elements, 0, source, executable); + return dlxIndex != null && sourceStringContent(elements[dlxIndex]!, source) === "dlx"; +} + +function isPackageRunnerPackageArgument(node: SgNode, source: string): boolean { + const parent = node.parent(); + if (parent?.kind() !== "array" || !isPackageRunnerArrayArgument(node, source)) return false; + + const argumentsNode = parent.parent(); + if (argumentsNode?.kind() !== "arguments") return false; + const callArgs = sourceArrayElements(argumentsNode); + const executableNode = callArgs[0]; + if (executableNode == null) return false; + const executable = sourceStringContent(executableNode, source); + if (executable == null) return false; + + const elements = sourceArrayElements(parent); + const index = nodeIndex(elements, node); + if (index < 0) return false; + const start = packageRunnerPackageStartIndex(executable, elements, source); + if (start == null) return false; + + if (hasPackageFlagBeforeArrayPackage(elements, index, source, start, executable)) { + return isSplitPackageFlagValue(elements, index, source, start, executable); + } + + return ( + firstTailorPackageIndex(elements, start, source, executable) === index && + !arrayHasCliRenameLegacyArgs(elements, index + 1, source) + ); +} + +function isPackageRunnerCommandBinaryArgument(node: SgNode, source: string): boolean { + const parent = node.parent(); + if (parent?.kind() !== "array" || !isPackageRunnerArrayArgument(node, source)) return false; + + const elements = sourceArrayElements(parent); + const index = nodeIndex(elements, node); + if (index < 0) return false; + + const argumentsNode = parent.parent(); + if (argumentsNode?.kind() !== "arguments") return false; + const callArgs = sourceArrayElements(argumentsNode); + const executableNode = callArgs[0]; + if (executableNode == null) return false; + const executable = sourceStringContent(executableNode, source); + if (executable == null) return false; + const start = packageRunnerPackageStartIndex(executable, elements, source); + if ( + start == null || + !hasTailorPackageFlagBeforeArrayCommand(elements, index, source, start, executable) + ) { + return false; + } + if (arrayHasCliRenameLegacyArgs(elements, index + 1, source)) { + return false; + } + const commandIndex = packageRunnerCommandIndex(elements, start, source, executable); + if (commandIndex !== index) return false; + + const text = sourceStringContent(node, source); + return ( + text != null && + TAILOR_SDK_TOKEN_RE.test(text) && + !isSplitPackageFlagValue(elements, index, source, start, executable) + ); +} + +function packageRunnerCommandIndex( + elements: SgNode[], + start: number, + source: string, + executable: string, +): number | null { + for (let index = start; index < elements.length; index += 1) { + const value = sourceStringContent(elements[index]!, source); + if (value == null) return null; + if (SOURCE_PACKAGE_FLAG_RE.test(value)) { + if (!value.includes("=")) index += 1; + continue; + } + if (value.startsWith("-")) { + if (skipsRunnerOptionValue(value, executable)) index += 1; + continue; + } + return index; + } + return null; +} + +function isPackageManagerExecBinaryArgument(node: SgNode, source: string): boolean { + const parent = node.parent(); + if (parent?.kind() !== "array") return false; + + const argumentsNode = parent.parent(); + if (argumentsNode?.kind() !== "arguments") return false; + const callee = callExpressionCalleeText(argumentsNode); + if (!CLI_ARGUMENT_CALLEE_RE.test(callee ?? "")) return false; + + const callArgs = sourceArrayElements(argumentsNode); + const executableNode = callArgs[0]; + if (executableNode == null) return false; + const executable = sourceStringContent(executableNode, source); + if (executable == null || !SOURCE_EXEC_PACKAGE_MANAGERS.has(executable)) return false; + + const elements = sourceArrayElements(parent); + const index = nodeIndex(elements, node); + if (index < 0) return false; + + const execIndex = firstNonOptionIndex(elements, 0, source, executable); + if (execIndex == null || sourceStringContent(elements[execIndex]!, source) !== "exec") { + return false; + } + return ( + firstNonOptionIndex(elements, execIndex + 1, source, executable) === index && + !arrayHasCliRenameLegacyArgs(elements, index + 1, source) + ); +} + +function isCliBinaryArgument(node: SgNode, source: string): boolean { + const parent = node.parent(); + if (parent?.kind() === "array") { + if (isPackageRunnerCommandBinaryArgument(node, source)) return true; + if (isPackageRunnerArrayArgument(node, source)) return false; + return isPackageManagerExecBinaryArgument(node, source); + } + + if (parent?.kind() !== "arguments") return false; + if (!CLI_ARGUMENT_CALLEE_RE.test(callExpressionCalleeText(parent) ?? "")) return false; + const args = sourceArrayElements(parent); + if (args[0] == null || nodeRangeKey(args[0]) !== nodeRangeKey(node)) return false; + const argv = args[1]; + if (argv == null) return true; + if (argv.kind() === "object") return true; + return ( + argv.kind() === "array" && !arrayHasCliRenameLegacyArgs(sourceArrayElements(argv), 0, source) + ); +} + +function arrayHasCliRenameLegacyArgs(elements: SgNode[], start: number, source: string): boolean { + let commandSeen = false; + for (let index = start; index < elements.length; index += 1) { + const value = + sourceStaticStringContent(elements[index]!, source) ?? + sourceStringRawContent(elements[index]!, source); + if (value == null) continue; + if (value === "--machineuser" || value.startsWith("--machineuser=")) return true; + if ( + TAILOR_CLI_VALUE_FLAGS.has(value.split("=", 1)[0]!) || + (commandSeen && TAILOR_CLI_COMMAND_VALUE_FLAGS.has(value.split("=", 1)[0]!)) + ) { + if (!value.includes("=")) index += 1; + continue; + } + if (value.startsWith("-")) continue; + if (!commandSeen) { + if (CLI_RENAME_LEGACY_RE.test(value)) return true; + if (value === "apply" || value === "crash-report") return true; + commandSeen = true; + } + } + return false; +} + +function isTailorCliArgumentArray(arrayNode: SgNode, index: number, source: string): boolean { + const argumentsNode = arrayNode.parent(); + if (argumentsNode?.kind() === "arguments") { + const callArgs = sourceArrayElements(argumentsNode); + const executable = callArgs[0] == null ? null : sourceStaticStringContent(callArgs[0]!, source); + if (executable != null && isTailorCliTokenValue(executable)) return true; + } + + const elements = sourceArrayElements(arrayNode); + return elements.slice(0, index).some((element) => { + const value = sourceStaticStringContent(element, source); + return value != null && isTailorCliTokenValue(value); + }); +} + +function isCliValueArgument(node: SgNode, source: string): boolean { + const parent = node.parent(); + if (parent?.kind() !== "array") return false; + const elements = sourceArrayElements(parent); + const index = nodeIndex(elements, node); + if (index < 0) return false; + if (!isTailorCliArgumentArray(parent, index, source)) return false; + const text = sourceStringContent(node, source) ?? sourceStringRawContent(node, source); + if ( + text != null && + text.includes("tailor-sdk") && + isTailorCliValueFlag(text) && + text.includes("=") + ) { + return true; + } + if (index === 0) return false; + const previousValue = sourceStaticStringContent(elements[index - 1]!, source); + return ( + text != null && + text.includes("tailor-sdk") && + previousValue != null && + isTailorCliValueFlag(previousValue) && + !previousValue.includes("=") + ); +} + +function pushSourceStringEdit( + edits: Array<[number, number, string]>, + source: string, + node: SgNode, +): void { + const range = node.range(); + const start = range.start.index + 1; + const end = range.end.index - 1; + const text = source.slice(start, end); + const packageFlagReplacement = sourcePackageFlagReplacement(node, source); + const replacement = + packageFlagReplacement != null + ? packageFlagReplacement.replacement + : TAILOR_SDK_TOKEN_RE.test(text) && isPackageRunnerPackageArgument(node, source) + ? renamePackageName(text) + : (TAILOR_SDK_TOKEN_RE.test(text) || TAILOR_SDK_PATH_RE.test(text)) && + isCliBinaryArgument(node, source) + ? renameBinary(text) + : isCliValueArgument(node, source) + ? text + : renameSourceCommandText(text); + if (replacement !== text) { + edits.push([start, end, replacement]); + } +} + +function templateSubstitutionPlaceholder( + index: number, + text: string, + usedPlaceholders: Set, +): string { + let attempt = 0; + while (true) { + const placeholder = `__TAILOR_SDK_TEMPLATE_EXPR_${index}_${attempt}__`; + if (!text.includes(placeholder) && !usedPlaceholders.has(placeholder)) { + usedPlaceholders.add(placeholder); + return placeholder; + } + attempt += 1; + } +} + +function restoreTemplateMigrationText( + text: string, + substitutions: ReadonlyArray<{ placeholder: string; migrationText: string }>, +): string { + let restored = text; + for (const substitution of substitutions) { + restored = restored.replaceAll(substitution.placeholder, () => substitution.migrationText); + } + return restored; +} + +function hasSourceTailorSdkReference(value: string): boolean { + TAILOR_SDK_RE.lastIndex = 0; + const matches = TAILOR_SDK_RE.test(value); + TAILOR_SDK_RE.lastIndex = 0; + return matches; +} + +function hasOnlyProtectedSourceCliValueLegacyToken(value: string): boolean { + const protectedValue = protectSourceCliValueReferences(value); + return ( + protectedValue.protectedValues.length > 0 && !hasSourceTailorSdkReference(protectedValue.source) + ); +} + +function templateSubstitutionMigrationText(node: SgNode, source: string, value: string): string { + const identifier = node.children().find((child) => child.kind() === "identifier"); + if (identifier != null && node.text() === `\${${identifier.text()}}`) { + const identifierValue = sourceStaticStringContent(identifier, source); + if (identifierValue != null) return identifierValue; + } + if (!value.startsWith("${") || !value.endsWith("}")) return value; + return value.slice(2, -1).trim(); +} + +function pushTemplateStringEdit( + edits: Array<[number, number, string]>, + source: string, + node: SgNode, +): void { + const range = node.range(); + const start = range.start.index + 1; + const end = range.end.index - 1; + let text = source.slice(start, end); + const substitutions: Array<{ placeholder: string; text: string; migrationText: string }> = []; + const usedPlaceholders = new Set(); + + const substitutionNodes = node + .children() + .filter((child: SgNode) => child.kind() === "template_substitution"); + for (const child of substitutionNodes.toReversed()) { + const childRange = child.range(); + const childStart = childRange.start.index - start; + const childEnd = childRange.end.index - start; + const placeholder = templateSubstitutionPlaceholder( + substitutions.length, + text, + usedPlaceholders, + ); + const substitutionText = isTemplateSubstitutionCliValue(text, childStart) + ? source.slice(childRange.start.index, childRange.end.index) + : transformTemplateSubstitutionText( + source.slice(childRange.start.index, childRange.end.index), + ); + substitutions.push({ + placeholder, + text: substitutionText, + migrationText: templateSubstitutionMigrationText(child, source, substitutionText), + }); + text = `${text.slice(0, childStart)}${placeholder}${text.slice(childEnd)}`; + } + + const migrationText = restoreTemplateMigrationText(text, substitutions); + if (hasOnlyProtectedSourceCliValueLegacyToken(migrationText)) { + return; + } + + let replacement = needsCliRenameMigration(migrationText) ? text : renameSourceCommandText(text); + for (const substitution of substitutions) { + replacement = replacement.replaceAll(substitution.placeholder, () => substitution.text); + } + if (replacement !== source.slice(start, end)) { + edits.push([start, end, replacement]); + } +} + +function transformTemplateSubstitutionText(value: string): string { + if (!value.includes("tailor-sdk") || !value.startsWith("${") || !value.endsWith("}")) { + return value; + } + const expression = value.slice(2, -1); + const transformed = transformSourceFile(expression, "template-expression.ts"); + return transformed == null ? value : `\${${transformed}}`; +} + +function transformSourceFile(source: string, filePath: string): string | null { + let root: SgNode; + try { + root = parse(sourceLang(filePath), source).root(); + } catch { + return null; + } + + const edits: Array<[number, number, string]> = []; + const visit = (node: SgNode): void => { + const kind = node.kind(); + const range = node.range(); + + if (kind === "comment" || kind === "jsx_text" || kind === "string_fragment") { + pushSourceTextEdit(edits, source, range.start.index, range.end.index); + return; + } + + if (kind === "string") { + pushSourceStringEdit(edits, source, node); + return; + } + + if (kind === "template_string") { + const hasSubstitution = node + .children() + .some((child: SgNode) => child.kind() === "template_substitution"); + if (!hasSubstitution) { + pushSourceStringEdit(edits, source, node); + return; + } + if (isCliValueArgument(node, source)) return; + pushTemplateStringEdit(edits, source, node); + return; + } + + for (const child of node.children()) { + visit(child); + } + }; + visit(root); + + if (edits.length === 0) return null; + let updated = source; + for (const [start, end, replacement] of edits.toSorted(([a], [b]) => b - a)) { + updated = `${updated.slice(0, start)}${replacement}${updated.slice(end)}`; + } + return updated === source ? null : updated; +} + +function transformPackageJson(source: string): string | null { + let parsed: Record; + try { + parsed = JSON.parse(source) as Record; + } catch { + return null; + } + + let modified = false; + const scripts = parsed.scripts; + if (typeof scripts === "object" && scripts != null && !Array.isArray(scripts)) { + for (const [name, value] of Object.entries(scripts as Record)) { + if (typeof value !== "string") continue; + if (!value.includes("tailor-sdk")) continue; + const updated = renameBinary(value); + if (updated !== value) { + (scripts as Record)[name] = updated; + modified = true; + } + } + } + + if (!modified) return null; + const trailing = source.endsWith("\n") ? "\n" : ""; + return JSON.stringify(parsed, null, 2) + trailing; +} + +/** + * Rename `tailor-sdk` binary references to `tailor`. + * + * Handles optional `@version` pins: + * - `npx tailor-sdk@latest` → `npx @tailor-platform/sdk@latest` (package-runner form) + * - `npx -y tailor-sdk login` → `npx -y @tailor-platform/sdk login` (runner flags preserved) + * - `pnpm dlx tailor-sdk@latest` → `pnpm dlx @tailor-platform/sdk@latest` (package-runner form) + * - `tailor-sdk@latest` elsewhere → `@tailor-platform/sdk@latest` + * Does not rewrite `.tailor-sdk` directory paths or `create-tailor-sdk`. + * @param source - File contents + * @param filePath - Absolute path to the file (used to dispatch package.json vs text) + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string, filePath: string): string | null { + if (!source.includes("tailor-sdk")) return null; + + const ext = path.extname(filePath).toLowerCase(); + if (ext === ".json") return transformPackageJson(source); + if (SOURCE_EXTENSIONS.has(ext)) return transformSourceFile(source, filePath); + + const updated = renameBinary(source); + return updated === source ? null : updated; +} diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-package-json/expected.json b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-package-json/expected.json new file mode 100644 index 0000000000..d8d47ae293 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-package-json/expected.json @@ -0,0 +1,10 @@ +{ + "name": "my-app", + "version": "1.0.0", + "scripts": { + "deploy": "tailor deploy", + "login": "pnpm exec tailor login", + "generate": "@tailor-platform/sdk@latest generate", + "build": "tsc" + } +} diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-package-json/input.json b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-package-json/input.json new file mode 100644 index 0000000000..0300c9746b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-package-json/input.json @@ -0,0 +1,10 @@ +{ + "name": "my-app", + "version": "1.0.0", + "scripts": { + "deploy": "tailor-sdk deploy", + "login": "pnpm exec tailor-sdk login", + "generate": "tailor-sdk@latest generate", + "build": "tsc" + } +} diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-shell/expected.sh b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-shell/expected.sh new file mode 100644 index 0000000000..1752dc093d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-shell/expected.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +pnpm exec tailor deploy +tailor login +@tailor-platform/sdk@latest deploy +npx @tailor-platform/sdk@2.0.0 workspace list +npx -y @tailor-platform/sdk login diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-shell/input.sh b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-shell/input.sh new file mode 100644 index 0000000000..e7743b6b70 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-shell/input.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +pnpm exec tailor-sdk deploy +tailor-sdk login +tailor-sdk@latest deploy +npx tailor-sdk@2.0.0 workspace list +npx -y tailor-sdk login diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-yaml/expected.yml b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-yaml/expected.yml new file mode 100644 index 0000000000..91cb025a08 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-yaml/expected.yml @@ -0,0 +1,5 @@ +steps: + - name: Deploy + run: tailor deploy -w ${{ secrets.WORKSPACE_ID }} + - name: Login check + run: npx @tailor-platform/sdk@latest login --help diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-yaml/input.yml b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-yaml/input.yml new file mode 100644 index 0000000000..8c7f4a7e36 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/basic-yaml/input.yml @@ -0,0 +1,5 @@ +steps: + - name: Deploy + run: tailor-sdk deploy -w ${{ secrets.WORKSPACE_ID }} + - name: Login check + run: npx tailor-sdk@latest login --help diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/declaration-comment/expected.d.ts b/packages/sdk-codemod/codemods/v2/rename-bin/tests/declaration-comment/expected.d.ts new file mode 100644 index 0000000000..6d5f65e208 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/declaration-comment/expected.d.ts @@ -0,0 +1,2 @@ +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' +export {}; diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/declaration-comment/input.d.ts b/packages/sdk-codemod/codemods/v2/rename-bin/tests/declaration-comment/input.d.ts new file mode 100644 index 0000000000..e31ff90b57 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/declaration-comment/input.d.ts @@ -0,0 +1,2 @@ +// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +export {}; diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/no-match/input.sh b/packages/sdk-codemod/codemods/v2/rename-bin/tests/no-match/input.sh new file mode 100644 index 0000000000..75da395ed7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/no-match/input.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# create-tailor-sdk is a scaffolding package, not the CLI binary +npx create-tailor-sdk@latest my-app + +# .tailor-sdk directory paths are not the binary +ls .tailor-sdk/cache +cat .tailor-sdk/config.json + +# tailor-sdk-skills has a trailing hyphen+word after the base token — not a binary invocation +tailor-sdk-skills diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-js-string/expected.js b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-js-string/expected.js new file mode 100644 index 0000000000..19c241cf0a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-js-string/expected.js @@ -0,0 +1,119 @@ +const script = "tailor deploy"; +const proseApply = "Run tailor deploy to apply changes"; +const spawned = spawn("tailor", ["deploy"]); +const optionOverloadSpawned = spawn("tailor", { stdio: "inherit" }); +const runtimeArg = getArg(); +const scopedRuntimeArgSpawned = spawn("tailor", [runtimeArg]); +const hiddenApplyArgs = ["apply"]; +const hiddenApplySpawned = spawn("tailor-sdk", hiddenApplyArgs); +const argSpawned = spawn("tailor", ["--arg", "tailor-sdk deploy", "deploy"]); +const tailorBin = "tailor"; +const aliasArgSpawned = spawn(tailorBin, ["--arg", "tailor-sdk deploy", "deploy"]); +const inlineArgSpawned = spawn("tailor", ["--arg=tailor-sdk deploy", "deploy"]); +const templateArgSpawned = spawn("tailor", ["--arg", `tailor-sdk ${cmd}`, "deploy"]); +const inlineTemplateArgSpawned = spawn("tailor", [`--arg=tailor-sdk ${cmd}`, "deploy"]); +const nameValueSpawned = spawn("tailor", ["tailordb", "migration", "generate", "--name", "tailor-sdk deploy"]); +const directNameValueSpawned = spawn("tailor", ["tailordb", "migration", "generate", "--name", "tailor-sdk deploy"]); +const shellSpawned = spawn("sh", ["-c", "tailor deploy"]); +const legacyApplyArg = "apply"; +const legacyMachineUserArg = "--machineuser"; +const legacyTemplateArg = "apply"; +const templateBin = "tailor"; +const templatedAliasArg = `${templateBin} --arg "tailor-sdk deploy" deploy`; +const applySpawned = spawn("tailor-sdk", ["apply"]); +const applyAliasSpawned = spawn("tailor-sdk", [legacyApplyArg]); +function unrelatedRuntimeArgScope() { + const runtimeArg = "apply"; + return runtimeArg; +} +function duplicateAliasOne() { + const duplicateLegacyArg = "apply"; + return spawn("tailor-sdk", [duplicateLegacyArg]); +} +function duplicateAliasTwo() { + const duplicateLegacyArg = "apply"; + return spawn("tailor-sdk", [duplicateLegacyArg]); +} +function conflictingAliasLegacy() { + const scopedArg = "apply"; + return spawn("tailor-sdk", [scopedArg]); +} +function conflictingAliasDeploy() { + const scopedArg = "deploy"; + return spawn("tailor", [scopedArg]); +} +const cliRenameCommandSpawned = spawn("tailor-sdk", ["crash-report", "list"]); +const cliRenameFlagSpawned = spawn("tailor-sdk", ["login", "--machineuser"]); +const cliRenameFlagAliasSpawned = spawn("tailor-sdk", ["login", legacyMachineUserArg]); +const secretValueApplySpawned = spawn("tailor", ["secret", "create", "--value", "apply"]); +const secretShortValueApplySpawned = spawn("tailor", ["secret", "create", "-v", "apply"]); +const dynamicCliRenameCommandSpawned = spawn("tailor-sdk", [`${"apply"}`]); +const dynamicCliRenameAliasCommand = `tailor-sdk ${legacyTemplateArg}`; +const npxSpawned = spawn("npx", ["@tailor-platform/sdk", "login"]); +const npxLegacyApplySpawned = spawn("npx", ["tailor-sdk", "apply"]); +const npxLegacyCrashReportSpawned = spawn("npx", ["tailor-sdk", "crash-report", "list"]); +const npxLegacyMachineUserSpawned = spawn("npx", ["tailor-sdk", "login", "--machineuser"]); +const npxOptionSpawned = spawn("npx", ["--yes", "@tailor-platform/sdk@latest", "login"]); +const npxProfileSpawned = spawn("npx", ["@tailor-platform/sdk", "--profile", "dev", "login"]); +const npxShortProfileSpawned = spawn("npx", ["@tailor-platform/sdk", "-p", "dev", "login"]); +const npxProfileNamedTailorSdkSpawned = spawn("npx", ["@tailor-platform/sdk", "--profile", "tailor-sdk", "login"]); +const npxShortProfileNamedTailorSdkSpawned = spawn("npx", ["@tailor-platform/sdk", "-p", "tailor-sdk", "login"]); +const npxInlineProfileNamedTailorSdkSpawned = spawn("npx", ["@tailor-platform/sdk", "-p=tailor-sdk", "login"]); +const npxVersionSpawned = spawn("npx", ["@tailor-platform/sdk", "--version"]); +const npxDynamicSpawned = spawn("npx", ["@tailor-platform/sdk", subcommand]); +const npxOtherPackageSpawned = spawn("npx", ["foo", "tailor-sdk", "login"]); +const npxOtherPackageFlagSpawned = spawn("npx", ["foo", "-p", "tailor-sdk", "tailor-sdk", "login"]); +const npxOtherPackageEqualsSpawned = spawn("npx", ["foo", "--package=tailor-sdk", "tailor-sdk", "login"]); +const npxOtherPackageSplitSpawned = spawn("npx", ["foo", "--package", "tailor-sdk", "tailor-sdk", "login"]); +const npxToolValueSpawned = spawn("npx", ["-p", "some-tool", "tool", "--name", "tailor-sdk", "deploy"]); +const npxPackageFlagSpawned = spawn("npx", ["-p", "@tailor-platform/sdk", "tailor", "login"]); +const npxPackageFlagWithRunnerOptionSpawned = spawn("npx", ["--package", "@tailor-platform/sdk", "--yes", "tailor", "login"]); +const npxMigratedPackageFlagWithRunnerOptionSpawned = spawn("npx", ["--package", "@tailor-platform/sdk", "--yes", "tailor", "login"]); +const npxMultiPackageFlagSpawned = spawn("npx", ["-p", "@tailor-platform/sdk", "-p", "dotenv-cli", "tailor", "login"]); +const npxMultiPackageFlagSecondSpawned = spawn("npx", ["-p", "dotenv-cli", "-p", "@tailor-platform/sdk", "tailor", "login"]); +const npxPackageEqualsSpawned = spawn("npx", ["--package=@tailor-platform/sdk", "tailor", "login"]); +const npxPackageFlagDynamicSpawned = spawn("npx", ["-p", "@tailor-platform/sdk", "tailor", subcommand]); +const npxPackageEqualsDynamicSpawned = spawn("npx", ["--package=@tailor-platform/sdk", "tailor", subcommand]); +const npxPackageMigratedSpawned = spawn("npx", ["--package", "@tailor-platform/sdk", "tailor", "login"]); +const npxPackageDynamicSpawned = spawn("npx", ["-p", pkg, "tailor-sdk", "login"]); +const npxOtherPackageCommandSpawned = spawn("npx", ["-p", "dotenv-cli", "tailor-sdk", "login"]); +const pnpmDlxSpawned = spawn("pnpm", ["dlx", "@tailor-platform/sdk", "login"]); +const pnpmDlxDynamicSpawned = spawn("pnpm", ["dlx", "@tailor-platform/sdk"]); +const pnpmDlxOtherPackageSpawned = spawn("pnpm", ["dlx", "foo", "tailor-sdk", "login"]); +const pnpmDlxOtherPackageFlagSpawned = spawn("pnpm", ["dlx", "foo", "-p", "tailor-sdk", "tailor-sdk", "login"]); +const pnpmDlxOptionSpawned = spawn("pnpm", ["--silent", "dlx", "@tailor-platform/sdk", "login"]); +const pnpmDlxSplitOptionSpawned = spawn("pnpm", ["--filter", "app", "dlx", "@tailor-platform/sdk", "login"]); +const pnpmDlxWorkspaceRootSpawned = spawn("pnpm", ["-w", "dlx", "@tailor-platform/sdk", "login"]); +const pnpmDlxRegistrySpawned = spawn("pnpm", ["--registry", registry, "dlx", "@tailor-platform/sdk", "login"]); +const pnpmExecSplitOptionSpawned = spawn("pnpm", ["--filter", "app", "exec", "tailor", "deploy"]); +const yarnDlxOptionSpawned = spawn("yarn", ["--quiet", "dlx", "@tailor-platform/sdk", "login"]); +const pnpmBinarySpawned = spawn("pnpm", ["tailor-sdk", "deploy"]); +const pnpmExecSpawned = spawn("pnpm", ["exec", "tailor", "deploy"]); +const pnpmExecDynamicSpawned = spawn("pnpm", ["exec", "tailor", subcommand]); +const pnpmExecHelpSpawned = spawn("pnpm", ["exec", "tailor", "--help"]); +const npmExecSpawned = spawn("npm", ["exec", "@tailor-platform/sdk", "login"]); +const npmExecWorkspaceSpawned = spawn("npm", ["-w", "app", "exec", "@tailor-platform/sdk", "login"]); +const npmExecLongWorkspaceSpawned = spawn("npm", [ + "--workspace", + "app", + "exec", + "@tailor-platform/sdk", + "login", +]); +const npmExecPackageFlagSpawned = spawn("npm", ["exec", "--package", "@tailor-platform/sdk", "tailor", "login"]); +const npmExecPackageEqualsSpawned = spawn("npm", ["exec", "--package=@tailor-platform/sdk", "tailor", "login"]); +const pathQualifiedSpawned = spawn("./node_modules/.bin/tailor", ["deploy"]); +const pathQualifiedArgSpawned = spawn("./node_modules/.bin/tailor", ["--arg", "tailor-sdk deploy", "deploy"]); +const packageDirectoryPathSpawned = spawn("./node_modules/tailor-sdk/bin/cli.js", ["deploy"]); +const windowsShimArgSpawned = spawn("tailor.cmd", ["--arg", "tailor-sdk deploy", "deploy"]); +const pathQualifiedWindowsShimArgSpawned = spawn("./node_modules/.bin/tailor.cmd", ["--arg", "tailor-sdk deploy", "deploy"]); +const arrayCommand = ["tailor-sdk", "--profile", "dev", "deploy"]; +const npxArgs = ["tailor-sdk", "login"]; +spawn("npx", npxArgs); +const docs = ( + <> +

package tailor-sdk is installed

+ tailor deploy + npx @tailor-platform/sdk@latest login + +); diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-js-string/input.js b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-js-string/input.js new file mode 100644 index 0000000000..4e7c65ff0d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-js-string/input.js @@ -0,0 +1,119 @@ +const script = "tailor-sdk deploy"; +const proseApply = "Run tailor-sdk deploy to apply changes"; +const spawned = spawn("tailor-sdk", ["deploy"]); +const optionOverloadSpawned = spawn("tailor-sdk", { stdio: "inherit" }); +const runtimeArg = getArg(); +const scopedRuntimeArgSpawned = spawn("tailor-sdk", [runtimeArg]); +const hiddenApplyArgs = ["apply"]; +const hiddenApplySpawned = spawn("tailor-sdk", hiddenApplyArgs); +const argSpawned = spawn("tailor-sdk", ["--arg", "tailor-sdk deploy", "deploy"]); +const tailorBin = "tailor"; +const aliasArgSpawned = spawn(tailorBin, ["--arg", "tailor-sdk deploy", "deploy"]); +const inlineArgSpawned = spawn("tailor-sdk", ["--arg=tailor-sdk deploy", "deploy"]); +const templateArgSpawned = spawn("tailor-sdk", ["--arg", `tailor-sdk ${cmd}`, "deploy"]); +const inlineTemplateArgSpawned = spawn("tailor-sdk", [`--arg=tailor-sdk ${cmd}`, "deploy"]); +const nameValueSpawned = spawn("tailor", ["tailordb", "migration", "generate", "--name", "tailor-sdk deploy"]); +const directNameValueSpawned = spawn("tailor-sdk", ["tailordb", "migration", "generate", "--name", "tailor-sdk deploy"]); +const shellSpawned = spawn("sh", ["-c", "tailor-sdk deploy"]); +const legacyApplyArg = "apply"; +const legacyMachineUserArg = "--machineuser"; +const legacyTemplateArg = "apply"; +const templateBin = "tailor"; +const templatedAliasArg = `${templateBin} --arg "tailor-sdk deploy" deploy`; +const applySpawned = spawn("tailor-sdk", ["apply"]); +const applyAliasSpawned = spawn("tailor-sdk", [legacyApplyArg]); +function unrelatedRuntimeArgScope() { + const runtimeArg = "apply"; + return runtimeArg; +} +function duplicateAliasOne() { + const duplicateLegacyArg = "apply"; + return spawn("tailor-sdk", [duplicateLegacyArg]); +} +function duplicateAliasTwo() { + const duplicateLegacyArg = "apply"; + return spawn("tailor-sdk", [duplicateLegacyArg]); +} +function conflictingAliasLegacy() { + const scopedArg = "apply"; + return spawn("tailor-sdk", [scopedArg]); +} +function conflictingAliasDeploy() { + const scopedArg = "deploy"; + return spawn("tailor-sdk", [scopedArg]); +} +const cliRenameCommandSpawned = spawn("tailor-sdk", ["crash-report", "list"]); +const cliRenameFlagSpawned = spawn("tailor-sdk", ["login", "--machineuser"]); +const cliRenameFlagAliasSpawned = spawn("tailor-sdk", ["login", legacyMachineUserArg]); +const secretValueApplySpawned = spawn("tailor-sdk", ["secret", "create", "--value", "apply"]); +const secretShortValueApplySpawned = spawn("tailor-sdk", ["secret", "create", "-v", "apply"]); +const dynamicCliRenameCommandSpawned = spawn("tailor-sdk", [`${"apply"}`]); +const dynamicCliRenameAliasCommand = `tailor-sdk ${legacyTemplateArg}`; +const npxSpawned = spawn("npx", ["tailor-sdk", "login"]); +const npxLegacyApplySpawned = spawn("npx", ["tailor-sdk", "apply"]); +const npxLegacyCrashReportSpawned = spawn("npx", ["tailor-sdk", "crash-report", "list"]); +const npxLegacyMachineUserSpawned = spawn("npx", ["tailor-sdk", "login", "--machineuser"]); +const npxOptionSpawned = spawn("npx", ["--yes", "tailor-sdk@latest", "login"]); +const npxProfileSpawned = spawn("npx", ["tailor-sdk", "--profile", "dev", "login"]); +const npxShortProfileSpawned = spawn("npx", ["tailor-sdk", "-p", "dev", "login"]); +const npxProfileNamedTailorSdkSpawned = spawn("npx", ["tailor-sdk", "--profile", "tailor-sdk", "login"]); +const npxShortProfileNamedTailorSdkSpawned = spawn("npx", ["tailor-sdk", "-p", "tailor-sdk", "login"]); +const npxInlineProfileNamedTailorSdkSpawned = spawn("npx", ["tailor-sdk", "-p=tailor-sdk", "login"]); +const npxVersionSpawned = spawn("npx", ["tailor-sdk", "--version"]); +const npxDynamicSpawned = spawn("npx", ["tailor-sdk", subcommand]); +const npxOtherPackageSpawned = spawn("npx", ["foo", "tailor-sdk", "login"]); +const npxOtherPackageFlagSpawned = spawn("npx", ["foo", "-p", "tailor-sdk", "tailor-sdk", "login"]); +const npxOtherPackageEqualsSpawned = spawn("npx", ["foo", "--package=tailor-sdk", "tailor-sdk", "login"]); +const npxOtherPackageSplitSpawned = spawn("npx", ["foo", "--package", "tailor-sdk", "tailor-sdk", "login"]); +const npxToolValueSpawned = spawn("npx", ["-p", "some-tool", "tool", "--name", "tailor-sdk", "deploy"]); +const npxPackageFlagSpawned = spawn("npx", ["-p", "tailor-sdk", "tailor-sdk", "login"]); +const npxPackageFlagWithRunnerOptionSpawned = spawn("npx", ["--package", "tailor-sdk", "--yes", "tailor-sdk", "login"]); +const npxMigratedPackageFlagWithRunnerOptionSpawned = spawn("npx", ["--package", "@tailor-platform/sdk", "--yes", "tailor-sdk", "login"]); +const npxMultiPackageFlagSpawned = spawn("npx", ["-p", "tailor-sdk", "-p", "dotenv-cli", "tailor-sdk", "login"]); +const npxMultiPackageFlagSecondSpawned = spawn("npx", ["-p", "dotenv-cli", "-p", "tailor-sdk", "tailor-sdk", "login"]); +const npxPackageEqualsSpawned = spawn("npx", ["--package=tailor-sdk", "tailor-sdk", "login"]); +const npxPackageFlagDynamicSpawned = spawn("npx", ["-p", "tailor-sdk", "tailor-sdk", subcommand]); +const npxPackageEqualsDynamicSpawned = spawn("npx", ["--package=tailor-sdk", "tailor-sdk", subcommand]); +const npxPackageMigratedSpawned = spawn("npx", ["--package", "@tailor-platform/sdk", "tailor-sdk", "login"]); +const npxPackageDynamicSpawned = spawn("npx", ["-p", pkg, "tailor-sdk", "login"]); +const npxOtherPackageCommandSpawned = spawn("npx", ["-p", "dotenv-cli", "tailor-sdk", "login"]); +const pnpmDlxSpawned = spawn("pnpm", ["dlx", "tailor-sdk", "login"]); +const pnpmDlxDynamicSpawned = spawn("pnpm", ["dlx", "tailor-sdk"]); +const pnpmDlxOtherPackageSpawned = spawn("pnpm", ["dlx", "foo", "tailor-sdk", "login"]); +const pnpmDlxOtherPackageFlagSpawned = spawn("pnpm", ["dlx", "foo", "-p", "tailor-sdk", "tailor-sdk", "login"]); +const pnpmDlxOptionSpawned = spawn("pnpm", ["--silent", "dlx", "tailor-sdk", "login"]); +const pnpmDlxSplitOptionSpawned = spawn("pnpm", ["--filter", "app", "dlx", "tailor-sdk", "login"]); +const pnpmDlxWorkspaceRootSpawned = spawn("pnpm", ["-w", "dlx", "tailor-sdk", "login"]); +const pnpmDlxRegistrySpawned = spawn("pnpm", ["--registry", registry, "dlx", "tailor-sdk", "login"]); +const pnpmExecSplitOptionSpawned = spawn("pnpm", ["--filter", "app", "exec", "tailor-sdk", "deploy"]); +const yarnDlxOptionSpawned = spawn("yarn", ["--quiet", "dlx", "tailor-sdk", "login"]); +const pnpmBinarySpawned = spawn("pnpm", ["tailor-sdk", "deploy"]); +const pnpmExecSpawned = spawn("pnpm", ["exec", "tailor-sdk", "deploy"]); +const pnpmExecDynamicSpawned = spawn("pnpm", ["exec", "tailor-sdk", subcommand]); +const pnpmExecHelpSpawned = spawn("pnpm", ["exec", "tailor-sdk", "--help"]); +const npmExecSpawned = spawn("npm", ["exec", "tailor-sdk", "login"]); +const npmExecWorkspaceSpawned = spawn("npm", ["-w", "app", "exec", "tailor-sdk", "login"]); +const npmExecLongWorkspaceSpawned = spawn("npm", [ + "--workspace", + "app", + "exec", + "tailor-sdk", + "login", +]); +const npmExecPackageFlagSpawned = spawn("npm", ["exec", "--package", "tailor-sdk", "tailor-sdk", "login"]); +const npmExecPackageEqualsSpawned = spawn("npm", ["exec", "--package=tailor-sdk", "tailor-sdk", "login"]); +const pathQualifiedSpawned = spawn("./node_modules/.bin/tailor-sdk", ["deploy"]); +const pathQualifiedArgSpawned = spawn("./node_modules/.bin/tailor-sdk", ["--arg", "tailor-sdk deploy", "deploy"]); +const packageDirectoryPathSpawned = spawn("./node_modules/tailor-sdk/bin/cli.js", ["deploy"]); +const windowsShimArgSpawned = spawn("tailor-sdk.cmd", ["--arg", "tailor-sdk deploy", "deploy"]); +const pathQualifiedWindowsShimArgSpawned = spawn("./node_modules/.bin/tailor-sdk.cmd", ["--arg", "tailor-sdk deploy", "deploy"]); +const arrayCommand = ["tailor-sdk", "--profile", "dev", "deploy"]; +const npxArgs = ["tailor-sdk", "login"]; +spawn("npx", npxArgs); +const docs = ( + <> +

package tailor-sdk is installed

+ tailor-sdk deploy + npx tailor-sdk@latest login + +); diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-template/expected.ts b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-template/expected.ts new file mode 100644 index 0000000000..95a2434a22 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-template/expected.ts @@ -0,0 +1,102 @@ +const siteName = "portal"; +const setup = `pnpm tailor staticwebsite deploy --name ${siteName}`; +const deploy = "tailor deploy"; +const envFileDeploy = "tailor --env-file .env deploy"; +const profileValue = "tailor --profile tailor-sdk deploy"; +const pnpmExecProfileValue = "pnpm exec tailor --profile tailor-sdk deploy"; +const yarnExecProfileValue = "yarn exec tailor --profile tailor-sdk deploy"; +const nameValue = "tailor tailordb migration generate --name \"tailor-sdk deploy\""; +const directNameValue = "tailor tailordb migration generate --name \"tailor-sdk deploy\""; +const namespaceValue = "tailor tailordb truncate --namespace tailor-sdk"; +const dirValue = "tailor staticwebsite deploy --name site --dir tailor-sdk"; +const pathQualifiedArgValue = "./node_modules/.bin/tailor --arg \"tailor-sdk deploy\" deploy"; +const placeholderCollision = "tailor deploy --arg \"tailor-sdk deploy\" __TAILOR_SDK_SOURCE_VALUE_0_0__"; +const pathQualifiedCliRenameCommand = "./node_modules/.bin/tailor-sdk apply"; +const help = "tailor --help"; +const optionHelp = "tailor --env-file .env --help"; +const optionVersion = "tailor --profile dev --version"; +const windowsShimDeploy = "tailor.cmd deploy"; +const pathQualifiedWindowsShimDeploy = "./node_modules/.bin/tailor.ps1 deploy"; +const npxVersion = "npx @tailor-platform/sdk --version"; +const npmExecWorkspace = "npm -w app exec @tailor-platform/sdk login"; +const npmExecLongWorkspace = "npm --workspace app exec @tailor-platform/sdk login"; +const generated = "Run tailor generate after changes"; +const cliRenameCommand = "tailor-sdk crash-report list"; +const legacyConstApplyArg = "apply" as const; +const applyConstAliasSpawned = spawn("tailor-sdk", [legacyConstApplyArg]); +const windowsShimCliRenameCommand = "tailor-sdk.cmd crash-report list"; +const cliRenameFlag = "tailor-sdk login --machineuser"; +const shellWrappedCliRenameCommand = "sh -c \"tailor-sdk apply\""; +const dynamicCommand = `tailor ${subcommand}`; +const dynamicCliRenameCommand = `tailor-sdk ${"apply"}`; +const dynamicCommandWithTrailingFlag = `tailor ${subcommand} --json`; +const dynamicCommandBeforeSeparator = `tailor ${subcommand} && echo done`; +const dynamicPnpmCommand = `pnpm tailor ${subcommand}`; +const dynamicPnpmCommandBeforePipe = `pnpm tailor ${subcommand} | jq`; +const dynamicProfileCommand = `tailor --profile ${profile} deploy`; +const dynamicEqualsProfileCommand = `tailor --profile=${profile} deploy`; +const dynamicEqualsWorkspaceCommand = `tailor --workspace-id=${workspaceId} deploy`; +const indentedDynamicCommand = ` + tailor ${subcommand}`; +const latest = "npx @tailor-platform/sdk@latest login"; +const latestOnly = "npx @tailor-platform/sdk@latest"; +const latestWithRunnerOption = "npx --yes @tailor-platform/sdk@latest login"; +const dynamicNpxCommand = `npx @tailor-platform/sdk ${subcommand}`; +const dynamicNpxRegistryCommand = `npx --registry ${registry} @tailor-platform/sdk login`; +const dynamicRunnerCommand = `${runner} tailor-sdk login`; +const literalPlaceholderCommand = `prefix __TAILOR_SDK_TEMPLATE_EXPR_0__ tailor-sdk ${subcommand}`; +const npxOtherPackage = "npx foo tailor-sdk login"; +const npxBooleanOtherPackage = "npx --yes foo tailor-sdk login"; +const npxOtherPackageFlag = "npx foo -p tailor-sdk tailor-sdk login"; +const npxOtherPackageFlagEquals = "npx foo --package=tailor-sdk tailor-sdk login"; +const npxQuotedPackage = "npx \"@tailor-platform/sdk\" login"; +const dynamicBunxCommand = `bunx @tailor-platform/sdk ${subcommand}`; +const dynamicDlxCommand = `pnpm dlx @tailor-platform/sdk ${subcommand}`; +const pnpmDlxWithOption = "pnpm --silent dlx @tailor-platform/sdk login"; +const pnpmDlxWithOptionValue = "pnpm --filter app dlx @tailor-platform/sdk login"; +const pnpmWorkspaceRootDlx = "pnpm -w dlx @tailor-platform/sdk login"; +const pnpmFilterNamedDlxCommand = "pnpm --filter dlx tailor deploy"; +const pnpmDlxAfterFilterNamedDlx = "pnpm --filter dlx dlx @tailor-platform/sdk login"; +const pnpmDlxBooleanOtherPackage = "pnpm dlx --yes foo tailor-sdk login"; +const dynamicPnpmDlxWithOptionValue = `pnpm --filter ${app} dlx @tailor-platform/sdk login`; +const yarnDlxWithOption = "yarn --quiet dlx @tailor-platform/sdk login"; +const nestedCommand = `run ${"tailor deploy"}`; +const nestedTailorCommand = `tailor deploy ${"tailor login"}`; +const nestedArgValue = `tailor --arg ${"tailor-sdk deploy"} deploy`; +const nestedInlineArgValue = `tailor --arg=${"tailor-sdk deploy"} deploy`; +const npxPackageFlag = "npx -p @tailor-platform/sdk tailor login"; +const npxMultiPackageFlag = "npx -p @tailor-platform/sdk -p dotenv-cli tailor login"; +const npxMultiPackageFlagSecond = "npx -p dotenv-cli -p @tailor-platform/sdk tailor login"; +const npxPackageFlagWithRunnerOption = "npx --package @tailor-platform/sdk --yes tailor login"; +const npxPackageSplitOnly = "npx --package @tailor-platform/sdk"; +const npxPackageSplitHelp = "npx -p @tailor-platform/sdk --help"; +const npxPackageFlagEquals = "npx --package=@tailor-platform/sdk tailor login"; +const npxPackageSingleQuoted = "npx --package '@tailor-platform/sdk' tailor login"; +const npxPackageDoubleQuoted = "npx --package \"@tailor-platform/sdk\" tailor login"; +const npxPackageEqualsSingleQuoted = "npx --package='@tailor-platform/sdk' tailor login"; +const npxPackageEqualsDoubleQuoted = "npx --package=\"@tailor-platform/sdk\" tailor login"; +const npxPackageDoubleQuotedBeforeAnd = "npx --package \"@tailor-platform/sdk\" tailor login && echo done"; +const npxDynamicPackageFlag = `npx -p ${pkg} tailor-sdk login`; +const npxDynamicPackageFlagEquals = `npx --package=${pkg} tailor-sdk login`; +const npxOtherPackageFlagEqualsSource = "npx --package=dotenv-cli tailor-sdk login"; +const npxPackageFlagDynamic = `npx -p @tailor-platform/sdk tailor ${subcommand}`; +const npxPackageEqualsDynamic = `npx --package=@tailor-platform/sdk tailor ${subcommand}`; +const npxRegistryValue = "npx --registry tailor-sdk @tailor-platform/sdk login"; +const npxRegistryPackageFlag = "npx --registry https://registry.npmjs.org -p @tailor-platform/sdk tailor login"; +const npxProfileValue = "npx @tailor-platform/sdk -p tailor-sdk login"; +const pnpmDlxPackageFlag = "pnpm dlx --package @tailor-platform/sdk tailor login"; +const pnpmDlxPackageFlagEquals = "pnpm dlx --package=@tailor-platform/sdk tailor login"; +const yarnDlxPackageFlag = "yarn dlx --package @tailor-platform/sdk tailor login"; +const pnpmDlxOtherPackageFlag = "pnpm dlx foo -p tailor-sdk tailor-sdk login"; +const npxOtherPackageQuoted = "npx foo \"tailor-sdk login\""; +const npxOtherPackageSingleQuoted = "npx foo 'tailor-sdk login'"; +const npxOtherPackageEscapedQuoteValue = "npx foo \"hello\\\"tailor-sdk login\""; +const shellWrapped = "sh -c \"tailor deploy\""; +const escapedArg = "tailor --arg \"tailor-sdk deploy\" deploy"; +const dollarArg = "tailor --arg \"$& tailor-sdk deploy\" deploy"; +const packageName = "tailor-sdk"; +const packageMessage = "package tailor-sdk is installed"; +const mixedPackageAndCommand = "Install tailor-sdk before running tailor deploy"; +const outputDir = ".tailor-sdk/"; +const scaffold = "create-tailor-sdk"; +const skills = "tailor-sdk-skills install"; diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-template/input.ts b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-template/input.ts new file mode 100644 index 0000000000..3d2266f05c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/source-template/input.ts @@ -0,0 +1,102 @@ +const siteName = "portal"; +const setup = `pnpm tailor-sdk staticwebsite deploy --name ${siteName}`; +const deploy = "tailor-sdk deploy"; +const envFileDeploy = "tailor-sdk --env-file .env deploy"; +const profileValue = "tailor-sdk --profile tailor-sdk deploy"; +const pnpmExecProfileValue = "pnpm exec tailor-sdk --profile tailor-sdk deploy"; +const yarnExecProfileValue = "yarn exec tailor-sdk --profile tailor-sdk deploy"; +const nameValue = "tailor tailordb migration generate --name \"tailor-sdk deploy\""; +const directNameValue = "tailor-sdk tailordb migration generate --name \"tailor-sdk deploy\""; +const namespaceValue = "tailor tailordb truncate --namespace tailor-sdk"; +const dirValue = "tailor staticwebsite deploy --name site --dir tailor-sdk"; +const pathQualifiedArgValue = "./node_modules/.bin/tailor-sdk --arg \"tailor-sdk deploy\" deploy"; +const placeholderCollision = "tailor-sdk deploy --arg \"tailor-sdk deploy\" __TAILOR_SDK_SOURCE_VALUE_0_0__"; +const pathQualifiedCliRenameCommand = "./node_modules/.bin/tailor-sdk apply"; +const help = "tailor-sdk --help"; +const optionHelp = "tailor-sdk --env-file .env --help"; +const optionVersion = "tailor-sdk --profile dev --version"; +const windowsShimDeploy = "tailor-sdk.cmd deploy"; +const pathQualifiedWindowsShimDeploy = "./node_modules/.bin/tailor-sdk.ps1 deploy"; +const npxVersion = "npx tailor-sdk --version"; +const npmExecWorkspace = "npm -w app exec tailor-sdk login"; +const npmExecLongWorkspace = "npm --workspace app exec tailor-sdk login"; +const generated = "Run tailor-sdk generate after changes"; +const cliRenameCommand = "tailor-sdk crash-report list"; +const legacyConstApplyArg = "apply" as const; +const applyConstAliasSpawned = spawn("tailor-sdk", [legacyConstApplyArg]); +const windowsShimCliRenameCommand = "tailor-sdk.cmd crash-report list"; +const cliRenameFlag = "tailor-sdk login --machineuser"; +const shellWrappedCliRenameCommand = "sh -c \"tailor-sdk apply\""; +const dynamicCommand = `tailor-sdk ${subcommand}`; +const dynamicCliRenameCommand = `tailor-sdk ${"apply"}`; +const dynamicCommandWithTrailingFlag = `tailor-sdk ${subcommand} --json`; +const dynamicCommandBeforeSeparator = `tailor-sdk ${subcommand} && echo done`; +const dynamicPnpmCommand = `pnpm tailor-sdk ${subcommand}`; +const dynamicPnpmCommandBeforePipe = `pnpm tailor-sdk ${subcommand} | jq`; +const dynamicProfileCommand = `tailor-sdk --profile ${profile} deploy`; +const dynamicEqualsProfileCommand = `tailor-sdk --profile=${profile} deploy`; +const dynamicEqualsWorkspaceCommand = `tailor-sdk --workspace-id=${workspaceId} deploy`; +const indentedDynamicCommand = ` + tailor-sdk ${subcommand}`; +const latest = "npx tailor-sdk@latest login"; +const latestOnly = "npx tailor-sdk@latest"; +const latestWithRunnerOption = "npx --yes tailor-sdk@latest login"; +const dynamicNpxCommand = `npx tailor-sdk ${subcommand}`; +const dynamicNpxRegistryCommand = `npx --registry ${registry} tailor-sdk login`; +const dynamicRunnerCommand = `${runner} tailor-sdk login`; +const literalPlaceholderCommand = `prefix __TAILOR_SDK_TEMPLATE_EXPR_0__ tailor-sdk ${subcommand}`; +const npxOtherPackage = "npx foo tailor-sdk login"; +const npxBooleanOtherPackage = "npx --yes foo tailor-sdk login"; +const npxOtherPackageFlag = "npx foo -p tailor-sdk tailor-sdk login"; +const npxOtherPackageFlagEquals = "npx foo --package=tailor-sdk tailor-sdk login"; +const npxQuotedPackage = "npx \"tailor-sdk\" login"; +const dynamicBunxCommand = `bunx tailor-sdk ${subcommand}`; +const dynamicDlxCommand = `pnpm dlx tailor-sdk ${subcommand}`; +const pnpmDlxWithOption = "pnpm --silent dlx tailor-sdk login"; +const pnpmDlxWithOptionValue = "pnpm --filter app dlx tailor-sdk login"; +const pnpmWorkspaceRootDlx = "pnpm -w dlx tailor-sdk login"; +const pnpmFilterNamedDlxCommand = "pnpm --filter dlx tailor-sdk deploy"; +const pnpmDlxAfterFilterNamedDlx = "pnpm --filter dlx dlx tailor-sdk login"; +const pnpmDlxBooleanOtherPackage = "pnpm dlx --yes foo tailor-sdk login"; +const dynamicPnpmDlxWithOptionValue = `pnpm --filter ${app} dlx tailor-sdk login`; +const yarnDlxWithOption = "yarn --quiet dlx tailor-sdk login"; +const nestedCommand = `run ${"tailor-sdk deploy"}`; +const nestedTailorCommand = `tailor deploy ${"tailor-sdk login"}`; +const nestedArgValue = `tailor-sdk --arg ${"tailor-sdk deploy"} deploy`; +const nestedInlineArgValue = `tailor-sdk --arg=${"tailor-sdk deploy"} deploy`; +const npxPackageFlag = "npx -p tailor-sdk tailor-sdk login"; +const npxMultiPackageFlag = "npx -p tailor-sdk -p dotenv-cli tailor-sdk login"; +const npxMultiPackageFlagSecond = "npx -p dotenv-cli -p tailor-sdk tailor-sdk login"; +const npxPackageFlagWithRunnerOption = "npx --package tailor-sdk --yes tailor-sdk login"; +const npxPackageSplitOnly = "npx --package tailor-sdk"; +const npxPackageSplitHelp = "npx -p tailor-sdk --help"; +const npxPackageFlagEquals = "npx --package=tailor-sdk tailor-sdk login"; +const npxPackageSingleQuoted = "npx --package 'tailor-sdk' tailor-sdk login"; +const npxPackageDoubleQuoted = "npx --package \"tailor-sdk\" tailor-sdk login"; +const npxPackageEqualsSingleQuoted = "npx --package='tailor-sdk' tailor-sdk login"; +const npxPackageEqualsDoubleQuoted = "npx --package=\"tailor-sdk\" tailor-sdk login"; +const npxPackageDoubleQuotedBeforeAnd = "npx --package \"tailor-sdk\" tailor-sdk login && echo done"; +const npxDynamicPackageFlag = `npx -p ${pkg} tailor-sdk login`; +const npxDynamicPackageFlagEquals = `npx --package=${pkg} tailor-sdk login`; +const npxOtherPackageFlagEqualsSource = "npx --package=dotenv-cli tailor-sdk login"; +const npxPackageFlagDynamic = `npx -p tailor-sdk tailor-sdk ${subcommand}`; +const npxPackageEqualsDynamic = `npx --package=tailor-sdk tailor-sdk ${subcommand}`; +const npxRegistryValue = "npx --registry tailor-sdk tailor-sdk login"; +const npxRegistryPackageFlag = "npx --registry https://registry.npmjs.org -p tailor-sdk tailor-sdk login"; +const npxProfileValue = "npx tailor-sdk -p tailor-sdk login"; +const pnpmDlxPackageFlag = "pnpm dlx --package tailor-sdk tailor-sdk login"; +const pnpmDlxPackageFlagEquals = "pnpm dlx --package=tailor-sdk tailor-sdk login"; +const yarnDlxPackageFlag = "yarn dlx --package tailor-sdk tailor-sdk login"; +const pnpmDlxOtherPackageFlag = "pnpm dlx foo -p tailor-sdk tailor-sdk login"; +const npxOtherPackageQuoted = "npx foo \"tailor-sdk login\""; +const npxOtherPackageSingleQuoted = "npx foo 'tailor-sdk login'"; +const npxOtherPackageEscapedQuoteValue = "npx foo \"hello\\\"tailor-sdk login\""; +const shellWrapped = "sh -c \"tailor-sdk deploy\""; +const escapedArg = "tailor-sdk --arg \"tailor-sdk deploy\" deploy"; +const dollarArg = "tailor-sdk --arg \"$& tailor-sdk deploy\" deploy"; +const packageName = "tailor-sdk"; +const packageMessage = "package tailor-sdk is installed"; +const mixedPackageAndCommand = "Install tailor-sdk before running tailor-sdk deploy"; +const outputDir = ".tailor-sdk/"; +const scaffold = "create-tailor-sdk"; +const skills = "tailor-sdk-skills install"; diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/version-qualified/expected.sh b/packages/sdk-codemod/codemods/v2/rename-bin/tests/version-qualified/expected.sh new file mode 100644 index 0000000000..5cf7033196 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/version-qualified/expected.sh @@ -0,0 +1,7 @@ +@tailor-platform/sdk@latest deploy -w my-workspace +pnpm dlx @tailor-platform/sdk@2.0.0 login +yarn dlx @tailor-platform/sdk@latest login +bunx @tailor-platform/sdk@1.0.0 login +npx @tailor-platform/sdk@latest login +npx @tailor-platform/sdk login +npx -y @tailor-platform/sdk@latest login diff --git a/packages/sdk-codemod/codemods/v2/rename-bin/tests/version-qualified/input.sh b/packages/sdk-codemod/codemods/v2/rename-bin/tests/version-qualified/input.sh new file mode 100644 index 0000000000..25ef731c5c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/rename-bin/tests/version-qualified/input.sh @@ -0,0 +1,7 @@ +tailor-sdk@latest deploy -w my-workspace +pnpm dlx tailor-sdk@2.0.0 login +yarn dlx tailor-sdk@latest login +bunx tailor-sdk@1.0.0 login +npx tailor-sdk@latest login +npx tailor-sdk login +npx -y tailor-sdk@latest login diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/codemod.yaml b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/codemod.yaml new file mode 100644 index 0000000000..a00bf50582 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/runtime-globals-opt-in" +version: "1.0.0" +description: "Rewrite simple direct tailor.idp.Client runtime global usage to the typed idp wrapper and flag broader runtime globals for review" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/scripts/transform.ts new file mode 100644 index 0000000000..7e5d6586d4 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/scripts/transform.ts @@ -0,0 +1,156 @@ +import { parse, Lang } from "@ast-grep/napi"; +import { + buildAddNamedImportEdit, + findImportStatements, + importBindings, + localDeclarationNames, +} from "../../../../src/ast-grep-helpers"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const RUNTIME_MODULE = "@tailor-platform/sdk/runtime"; +const TAILOR_IDP_CLIENT = "tailor.idp.Client"; +const NON_ARGUMENT_KINDS = new Set(["(", ")", ",", "comment"]); + +function quickFilter(source: string): boolean { + return source.includes(TAILOR_IDP_CLIENT); +} + +function sourceLang(filePath: string, source: string): Lang { + return filePath.endsWith(".tsx") || filePath.endsWith(".jsx") || source.includes("[number]): boolean { + return binding.source === RUNTIME_MODULE && binding.importedName === "idp"; +} + +function hasCollision( + imports: SgNode[], + localNames: Set, + idpLocal: string, + injectingNewIdpName: boolean, +): boolean { + if ( + localNames.has("tailor") || + (injectingNewIdpName && localNames.has("idp")) || + localNames.has(idpLocal) + ) + return true; + + for (const importStmt of imports) { + for (const binding of importBindings(importStmt)) { + if (binding.localName === "tailor") return true; + if (binding.localName !== "idp") continue; + if (isRuntimeIdpBinding(binding)) continue; + return true; + } + } + + return false; +} + +function isDirectiveStatement(node: SgNode): boolean { + return node.kind() === "expression_statement" && node.children()[0]?.kind() === "string"; +} + +function importInsertionIndex(root: SgNode, imports: SgNode[], source: string): number { + const lastImport = imports.at(-1); + if (lastImport) return lastImport.range().end.index; + + let pos = 0; + if (source.startsWith("#!")) { + const newlineIndex = source.indexOf("\n"); + pos = newlineIndex === -1 ? source.length : newlineIndex + 1; + } + + for (const child of root.children()) { + if (child.range().start.index < pos) continue; + if (child.kind() === "comment") { + pos = child.range().end.index; + continue; + } + if (!isDirectiveStatement(child)) break; + pos = child.range().end.index; + } + + return pos; +} + +function buildAddRuntimeImportEdit(root: SgNode, source: string, imports: SgNode[]): Edit { + return buildAddNamedImportEdit({ + importName: "idp", + imports, + insertionIndex: importInsertionIndex, + moduleName: RUNTIME_MODULE, + root, + source, + }); +} + +function argumentExpressions(args: SgNode): SgNode[] { + return args.children().filter((child) => !NON_ARGUMENT_KINDS.has(child.kind())); +} + +function hasConstructorArguments(newExpression: SgNode): boolean { + const args = newExpression.field("arguments"); + return args ? argumentExpressions(args).length > 0 : false; +} + +function findTailorIdpClientConstructors(root: SgNode): SgNode[] { + return root + .findAll({ rule: { kind: "new_expression" } }) + .filter(hasConstructorArguments) + .map((node) => node.field("constructor")) + .filter((node): node is SgNode => node?.text() === TAILOR_IDP_CLIENT); +} + +/** + * Rewrite direct `new tailor.idp.Client(...)` calls to the typed runtime + * wrapper. Files with local `tailor` or conflicting `idp` bindings are left + * unchanged for the runtime-globals review prompt. + * @param source - File contents + * @param filePath - Absolute path to the file + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string, filePath: string): string | null { + if (!quickFilter(source)) return null; + + const root = parse(sourceLang(filePath, source), source).root(); + const constructors = findTailorIdpClientConstructors(root); + if (constructors.length === 0) return null; + + const imports = findImportStatements(root); + const existingIdpLocal = runtimeIdpLocalName(imports); + const idpLocal = existingIdpLocal ?? "idp"; + if (hasCollision(imports, localDeclarationNames(root), idpLocal, existingIdpLocal === null)) { + return null; + } + + const edits: Edit[] = constructors.map((constructor) => + constructor.replace(`${idpLocal}.Client`), + ); + + if (!existingIdpLocal) { + if (filePath.endsWith(".cts")) return null; + edits.push(buildAddRuntimeImportEdit(root, source, imports)); + } + + const result = root.commitEdits(edits); + return result === source ? null : result; +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-import-local-idp/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-import-local-idp/expected.ts new file mode 100644 index 0000000000..8903f66070 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-import-local-idp/expected.ts @@ -0,0 +1,8 @@ +import { idp as runtimeIdp } from "@tailor-platform/sdk/runtime"; + +const idp = createLocalIdp(); + +export async function run() { + const client = new runtimeIdp.Client({ namespace: "default" }); + return client.listUsers(idp); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-import-local-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-import-local-idp/input.ts new file mode 100644 index 0000000000..3b3fb04335 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-import-local-idp/input.ts @@ -0,0 +1,8 @@ +import { idp as runtimeIdp } from "@tailor-platform/sdk/runtime"; + +const idp = createLocalIdp(); + +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(idp); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-local/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-local/input.ts new file mode 100644 index 0000000000..41d31b8e43 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/aliased-idp-local/input.ts @@ -0,0 +1,6 @@ +import { idp as runtimeIdp } from "@tailor-platform/sdk/runtime"; + +export const run = (runtimeIdp: unknown) => { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +}; diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/arrow-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/arrow-idp/input.ts new file mode 100644 index 0000000000..527d164709 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/arrow-idp/input.ts @@ -0,0 +1,4 @@ +export const run = idp => { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +}; diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/basic/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/basic/expected.ts new file mode 100644 index 0000000000..2f61259b29 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/basic/expected.ts @@ -0,0 +1,6 @@ +import { idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const client = new idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/basic/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/basic/input.ts new file mode 100644 index 0000000000..e21a2970c1 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/basic/input.ts @@ -0,0 +1,4 @@ +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/catch-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/catch-idp/input.ts new file mode 100644 index 0000000000..f3c34f04d7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/catch-idp/input.ts @@ -0,0 +1,8 @@ +export async function run() { + try { + return await load(); + } catch (idp) { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); + } +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/catch-pattern-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/catch-pattern-idp/input.ts new file mode 100644 index 0000000000..7b70c382da --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/catch-pattern-idp/input.ts @@ -0,0 +1,8 @@ +export async function run() { + try { + return await load(); + } catch ({ idp }) { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); + } +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/comment-only-args/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/comment-only-args/input.ts new file mode 100644 index 0000000000..2fcb13ef20 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/comment-only-args/input.ts @@ -0,0 +1,4 @@ +export async function run() { + const client = new tailor.idp.Client(/* namespace required */); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/cts-auto-import/input.cts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/cts-auto-import/input.cts new file mode 100644 index 0000000000..e21a2970c1 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/cts-auto-import/input.cts @@ -0,0 +1,4 @@ +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/directive-prologue/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/directive-prologue/expected.ts new file mode 100644 index 0000000000..60671aa7a4 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/directive-prologue/expected.ts @@ -0,0 +1,4 @@ +"use server"; +import { idp } from "@tailor-platform/sdk/runtime"; + +export const client = new idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/directive-prologue/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/directive-prologue/input.ts new file mode 100644 index 0000000000..4ce7d57f76 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/directive-prologue/input.ts @@ -0,0 +1,3 @@ +"use server"; + +export const client = new tailor.idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-idp-import/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-idp-import/expected.ts new file mode 100644 index 0000000000..2f61259b29 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-idp-import/expected.ts @@ -0,0 +1,6 @@ +import { idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const client = new idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-idp-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-idp-import/input.ts new file mode 100644 index 0000000000..a516d8d508 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-idp-import/input.ts @@ -0,0 +1,6 @@ +import { idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-runtime-import/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-runtime-import/expected.ts new file mode 100644 index 0000000000..025c226006 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-runtime-import/expected.ts @@ -0,0 +1,7 @@ +import { workflow, idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + await workflow.wait("ready"); + const client = new idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-runtime-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-runtime-import/input.ts new file mode 100644 index 0000000000..dfd6409fb6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/existing-runtime-import/input.ts @@ -0,0 +1,7 @@ +import { workflow } from "@tailor-platform/sdk/runtime"; + +export async function run() { + await workflow.wait("ready"); + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/for-of-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/for-of-idp/input.ts new file mode 100644 index 0000000000..b6b5dc761d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/for-of-idp/input.ts @@ -0,0 +1,6 @@ +export async function run(users: unknown[]) { + for (const idp of users) { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); + } +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/function-expression-tailor/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/function-expression-tailor/input.ts new file mode 100644 index 0000000000..408cb57850 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/function-expression-tailor/input.ts @@ -0,0 +1,4 @@ +export const run = function tailor() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +}; diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/import-alias-tailor/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/import-alias-tailor/input.ts new file mode 100644 index 0000000000..fb4d7e05ec --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/import-alias-tailor/input.ts @@ -0,0 +1,5 @@ +namespace X { + import tailor = localTailor; + + export const client = new tailor.idp.Client({ namespace: "default" }); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/import-equals-tailor/input.cts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/import-equals-tailor/input.cts new file mode 100644 index 0000000000..a96026f641 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/import-equals-tailor/input.cts @@ -0,0 +1,6 @@ +import tailor = require("./local-tailor"); + +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/leading-comment-directive/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/leading-comment-directive/expected.ts new file mode 100644 index 0000000000..f1ba1a4c73 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/leading-comment-directive/expected.ts @@ -0,0 +1,5 @@ +// @ts-nocheck +"use client"; +import { idp } from "@tailor-platform/sdk/runtime"; + +export const client = new idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/leading-comment-directive/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/leading-comment-directive/input.ts new file mode 100644 index 0000000000..b9c6152093 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/leading-comment-directive/input.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +"use client"; + +export const client = new tailor.idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/local-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/local-idp/input.ts new file mode 100644 index 0000000000..61dd769f69 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/local-idp/input.ts @@ -0,0 +1,6 @@ +const idp = createLocalIdp(); + +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/local-tailor/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/local-tailor/input.ts new file mode 100644 index 0000000000..81d7567dee --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/local-tailor/input.ts @@ -0,0 +1,6 @@ +const tailor = createLocalTailor(); + +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/nested-ambient-import/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/nested-ambient-import/expected.ts new file mode 100644 index 0000000000..38d7ec6fdc --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/nested-ambient-import/expected.ts @@ -0,0 +1,11 @@ +import { idp } from "@tailor-platform/sdk/runtime"; + +declare module "pkg" { + import { Existing } from "other"; + + export interface Thing { + value: Existing; + } +} + +export const client = new idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/nested-ambient-import/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/nested-ambient-import/input.ts new file mode 100644 index 0000000000..6d3fb547bc --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/nested-ambient-import/input.ts @@ -0,0 +1,9 @@ +declare module "pkg" { + import { Existing } from "other"; + + export interface Thing { + value: Existing; + } +} + +export const client = new tailor.idp.Client({ namespace: "default" }); diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/no-args/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/no-args/input.ts new file mode 100644 index 0000000000..4f3a82a559 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/no-args/input.ts @@ -0,0 +1,4 @@ +export async function run() { + const client = new tailor.idp.Client(); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/string-literal/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/string-literal/input.ts new file mode 100644 index 0000000000..f95c1c608d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/string-literal/input.ts @@ -0,0 +1 @@ +export const source = 'const client = new tailor.idp.Client({ namespace: "default" });'; diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp-with-runtime-value/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp-with-runtime-value/expected.ts new file mode 100644 index 0000000000..025c226006 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp-with-runtime-value/expected.ts @@ -0,0 +1,7 @@ +import { workflow, idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + await workflow.wait("ready"); + const client = new idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp-with-runtime-value/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp-with-runtime-value/input.ts new file mode 100644 index 0000000000..0fbe749e5c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp-with-runtime-value/input.ts @@ -0,0 +1,7 @@ +import { workflow, type idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + await workflow.wait("ready"); + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp/expected.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp/expected.ts new file mode 100644 index 0000000000..2f61259b29 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp/expected.ts @@ -0,0 +1,6 @@ +import { idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const client = new idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp/input.ts b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp/input.ts new file mode 100644 index 0000000000..7ab633df14 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/runtime-globals-opt-in/tests/type-only-idp/input.ts @@ -0,0 +1,6 @@ +import { type idp } from "@tailor-platform/sdk/runtime"; + +export async function run() { + const client = new tailor.idp.Client({ namespace: "default" }); + return client.listUsers(); +} diff --git a/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/codemod.yaml b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/codemod.yaml new file mode 100644 index 0000000000..ededfbd3cd --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/tailor-output-ignore-dir" +version: "1.0.0" +description: "Rename exact ignore-file entries for the generated output directory from .tailor-sdk to .tailor" +engine: jssg +language: text +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/scripts/transform.ts new file mode 100644 index 0000000000..9414bfb03c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/scripts/transform.ts @@ -0,0 +1,17 @@ +const GENERATED_DIR_IGNORE_ENTRY_RE = /^(!?\/?)\.tailor-sdk(\/?)([ \t]*)$/gm; + +/** + * Rewrite exact ignore-file entries for the generated SDK output directory. + * @param source - File contents + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string): string | null { + if (!source.includes(".tailor-sdk")) return null; + + const updated = source.replace( + GENERATED_DIR_IGNORE_ENTRY_RE, + (_match, prefix: string, slash: string, trailingWhitespace: string) => + `${prefix}.tailor${slash}${trailingWhitespace}`, + ); + return updated === source ? null : updated; +} diff --git a/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/basic-gitignore/expected.gitignore b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/basic-gitignore/expected.gitignore new file mode 100644 index 0000000000..90d6c47670 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/basic-gitignore/expected.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.tailor/ +/.tailor/ +.tailor +/.tailor +!.tailor/ +dist/ diff --git a/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/basic-gitignore/input.gitignore b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/basic-gitignore/input.gitignore new file mode 100644 index 0000000000..ddcc668999 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/basic-gitignore/input.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.tailor-sdk/ +/.tailor-sdk/ +.tailor-sdk +/.tailor-sdk +!.tailor-sdk/ +dist/ diff --git a/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/no-match/input.gitignore b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/no-match/input.gitignore new file mode 100644 index 0000000000..ba91154572 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailor-output-ignore-dir/tests/no-match/input.gitignore @@ -0,0 +1,7 @@ +# .tailor-sdk/ +.tailor-sdk/cache +docs: `.tailor-sdk/` was the old generated directory +prefix/.tailor-sdk/ + .tailor-sdk/ +.tailor-sdk/extra +.tailor-sdk-old/ diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/codemod.yaml b/packages/sdk-codemod/codemods/v2/tailordb-namespace/codemod.yaml index 83219b1f88..dcd93c07b2 100644 --- a/packages/sdk-codemod/codemods/v2/tailordb-namespace/codemod.yaml +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/codemod.yaml @@ -1,6 +1,6 @@ name: "@tailor-platform/tailordb-namespace" version: "1.0.0" -description: "Rename the deprecated capital-cased `Tailordb` ambient namespace to lowercase `tailordb` (e.g. `Tailordb.QueryResult` → `tailordb.QueryResult`)" +description: "Rename the removed capital-cased `Tailordb` ambient namespace to lowercase `tailordb` (e.g. `Tailordb.QueryResult` → `tailordb.QueryResult`)" engine: jssg language: typescript since: "1.0.0" diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/tailordb-namespace/scripts/transform.ts index a803bbea53..29cbf88a55 100644 --- a/packages/sdk-codemod/codemods/v2/tailordb-namespace/scripts/transform.ts +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/scripts/transform.ts @@ -1,8 +1,9 @@ -// Members exposed by the deprecated `Tailordb` ambient namespace from -// `@tailor-platform/function-types`. Each was a type-only declaration that -// has been re-published under the new lowercase `tailordb` namespace by the -// SDK. Anything outside this list is left untouched so user-defined symbols -// that happen to share the `Tailordb.` prefix are not rewritten by accident. +// Members of the removed capital-cased `Tailordb` ambient namespace +// (originally from `@tailor-platform/function-types`). Each is a type-only +// declaration now available under the lowercase `tailordb` namespace exposed +// by `@tailor-platform/sdk/runtime/globals`. Anything outside this list is +// left untouched so user-defined symbols that happen to share the +// `Tailordb.` prefix are not rewritten by accident. const TAILORDB_MEMBERS = ["QueryResult", "CommandType", "Client"] as const; const MEMBER_GROUP = TAILORDB_MEMBERS.join("|"); @@ -14,10 +15,11 @@ const MEMBER_GROUP = TAILORDB_MEMBERS.join("|"); const PATTERN = new RegExp(String.raw`\bTailordb\.(${MEMBER_GROUP})\b`, "g"); /** - * Rewrite references to the deprecated capital-cased `Tailordb` ambient - * namespace to the new lowercase `tailordb` namespace. The capital-cased - * namespace was inherited from `@tailor-platform/function-types`; the SDK - * keeps it as a `@deprecated` alias in v1 and removes it in v2. + * Rewrite references to the capital-cased `Tailordb` ambient namespace to the + * lowercase `tailordb` namespace. The capital-cased namespace was inherited + * from `@tailor-platform/function-types`; the SDK kept it as a `@deprecated` + * alias in v1 and removed it in v2, leaving only the lowercase `tailordb.*` + * namespace exposed by `@tailor-platform/sdk/runtime/globals`. * * Only the known type-only members (`QueryResult`, `CommandType`, `Client`) * are rewritten so that unrelated user-defined symbols sharing the diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/codemod.yaml b/packages/sdk-codemod/codemods/v2/wait-point-rename/codemod.yaml new file mode 100644 index 0000000000..35ce27041d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/wait-point-rename" +version: "1.0.0" +description: "Rename defineWaitPoint/defineWaitPoints to createWaitPoint/createWaitPoints" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/scripts/transform.ts new file mode 100644 index 0000000000..dfc2cf4632 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/scripts/transform.ts @@ -0,0 +1,202 @@ +import { parse, Lang } from "@ast-grep/napi"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const SDK_MODULE = "@tailor-platform/sdk"; + +const RENAMES: Record = { + defineWaitPoint: "createWaitPoint", + defineWaitPoints: "createWaitPoints", +}; + +function isInsideImportStatement(node: SgNode): boolean { + let current: SgNode | null = node.parent(); + while (current) { + if (current.kind() === "import_statement") return true; + current = current.parent(); + } + return false; +} + +/** + * Rename `defineWaitPoint` and `defineWaitPoints` imported from `@tailor-platform/sdk` + * to `createWaitPoint` and `createWaitPoints`, updating both the import specifiers + * and all usages in the file body. + * @param source - File contents + * @param filePath - Absolute path to the file (kept for the runner signature) + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string, _filePath?: string): string | null { + const hasMatch = Object.keys(RENAMES).some((name) => source.includes(name)); + if (!hasMatch) return null; + if (!source.includes(SDK_MODULE)) return null; + + const lang = source.includes("") ? Lang.Tsx : Lang.TypeScript; + const root = parse(lang, source).root(); + + const edits: Edit[] = []; + // Non-aliased imports need their body references renamed too. + const needsBodyRename = new Set(); + + const importStmts = root.findAll({ + rule: { + kind: "import_statement", + has: { kind: "string", regex: `^["']${SDK_MODULE}["']$` }, + }, + }); + + for (const importStmt of importStmts) { + const specs = importStmt.findAll({ rule: { kind: "import_specifier" } }); + for (const spec of specs) { + const idents = spec.children().filter((c: SgNode) => c.kind() === "identifier"); + if (idents.length === 0) continue; + + const importedName = idents[0]!.text(); + const newName = RENAMES[importedName]; + if (!newName) continue; + + const isAliased = idents.length > 1; + edits.push(idents[0]!.replace(newName)); + if (!isAliased) needsBodyRename.add(importedName); + } + } + + if (edits.length === 0) return null; + + if (needsBodyRename.size > 0) { + // Build byte-range maps for scopes where each name is shadowed by a local declaration. + // Only identifiers outside these ranges should be renamed. + const shadowedRanges = new Map>(); + + const addShadowedRange = (name: string, scopeNode: SgNode) => { + const r = scopeNode.range(); + if (!shadowedRanges.has(name)) shadowedRanges.set(name, []); + shadowedRanges.get(name)!.push({ start: r.start.index, end: r.end.index }); + }; + + // Variable declarations and local function declarations. + const localDecls = root.findAll({ + rule: { any: [{ kind: "function_declaration" }, { kind: "variable_declarator" }] }, + }); + for (const decl of localDecls) { + if (isInsideImportStatement(decl)) continue; + // For variable_declarator: check direct identifier bindings and shorthand + // destructuring patterns (const { x } = ...) — both create local shadows. + const nameChild = + decl + .children() + .filter((c: SgNode) => c.kind() === "identifier") + .find((c: SgNode) => needsBodyRename.has(c.text())) ?? + decl + .children() + .find((c: SgNode) => c.kind() === "object_pattern") + ?.children() + .find( + (c: SgNode) => + c.kind() === "shorthand_property_identifier_pattern" && needsBodyRename.has(c.text()), + ); + if (!nameChild || !needsBodyRename.has(nameChild.text())) continue; + + // Walk up to the nearest statement_block or program — that is the scope + // where this declaration shadows the imported name. + let scopeNode: SgNode = root; + let p: SgNode | null = decl.parent(); + while (p) { + const k = p.kind(); + if ( + k === "statement_block" || + k === "program" || + k === "for_statement" || + k === "for_in_statement" + ) { + scopeNode = p; + break; + } + p = p.parent(); + } + + addShadowedRange(nameChild.text(), scopeNode); + } + + // Function/arrow parameters — covers required (param: T) and optional (param?: T). + const paramNodes = root.findAll({ + rule: { any: [{ kind: "required_parameter" }, { kind: "optional_parameter" }] }, + }); + for (const param of paramNodes) { + if (isInsideImportStatement(param)) continue; + // The name identifier may be a direct child or wrapped in rest_pattern. + const nameChild = param + .children() + .flatMap((c: SgNode) => + c.kind() === "rest_pattern" + ? c.children().filter((cc: SgNode) => cc.kind() === "identifier") + : c.kind() === "identifier" + ? [c] + : [], + ) + .find((c: SgNode) => needsBodyRename.has(c.text())); + if (!nameChild) continue; + + // Walk up past formal_parameters to the enclosing function/arrow, then use its body. + let scopeNode: SgNode = root; + let p: SgNode | null = param.parent(); + while (p) { + const k = p.kind(); + if (k === "formal_parameters") { + p = p.parent(); + continue; + } + if ( + k === "function_declaration" || + k === "function_expression" || + k === "arrow_function" || + k === "method_definition" + ) { + // Use the whole function node so the parameter list itself is also covered. + scopeNode = p; + break; + } + break; + } + + addShadowedRange(nameChild.text(), scopeNode); + } + + // for...of / for...in binding identifiers (direct children of for_in_statement, + // appearing before the 'of' or 'in' keyword). + const forInStmts = root.findAll({ rule: { kind: "for_in_statement" } }); + for (const stmt of forInStmts) { + const children = stmt.children(); + const keywordIdx = children.findIndex((c: SgNode) => c.kind() === "of" || c.kind() === "in"); + if (keywordIdx < 0) continue; + for (let i = 0; i < keywordIdx; i++) { + const child = children[i]!; + if (child.kind() === "identifier" && needsBodyRename.has(child.text())) { + addShadowedRange(child.text(), stmt); + } + } + } + + const renameNode = (node: SgNode) => { + const name = node.text(); + if (!needsBodyRename.has(name)) return; + if (isInsideImportStatement(node)) return; + const ranges = shadowedRanges.get(name); + if (ranges) { + const pos = node.range().start.index; + if (ranges.some((r) => pos >= r.start && pos < r.end)) return; + } + edits.push(node.replace(RENAMES[name]!)); + }; + + for (const ident of root.findAll({ rule: { kind: "identifier" } })) { + renameNode(ident); + } + // Shorthand property references in object literals ({ defineWaitPoints }) use a + // distinct AST node kind and must be renamed separately. + for (const prop of root.findAll({ rule: { kind: "shorthand_property_identifier" } })) { + renameNode(prop); + } + } + + return root.commitEdits(edits); +} diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/aliased/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/aliased/expected.ts new file mode 100644 index 0000000000..2a7ab09048 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/aliased/expected.ts @@ -0,0 +1,7 @@ +import { createWaitPoints as wp, createWaitPoint as single } from "@tailor-platform/sdk"; + +export const { approval } = wp((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +export const step = single("step"); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/aliased/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/aliased/input.ts new file mode 100644 index 0000000000..2fcfa6395e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/aliased/input.ts @@ -0,0 +1,7 @@ +import { defineWaitPoints as wp, defineWaitPoint as single } from "@tailor-platform/sdk"; + +export const { approval } = wp((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +export const step = single("step"); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/basic/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/basic/expected.ts new file mode 100644 index 0000000000..8c0f6309d9 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/basic/expected.ts @@ -0,0 +1,17 @@ +import { createWorkflow, createWorkflowJob, createWaitPoint, createWaitPoints } from "@tailor-platform/sdk"; + +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +export const singlePoint = createWaitPoint<{ id: string }, boolean>("my-step"); + +export const processJob = createWorkflowJob({ + name: "process", + body: async (input: { orderId: string }) => { + const result = await approval.wait({ message: `approve ${input.orderId}` }); + return { approved: result.approved }; + }, +}); + +export default createWorkflow({ name: "my-workflow", mainJob: processJob }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/basic/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/basic/input.ts new file mode 100644 index 0000000000..6026086f75 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/basic/input.ts @@ -0,0 +1,17 @@ +import { createWorkflow, createWorkflowJob, defineWaitPoint, defineWaitPoints } from "@tailor-platform/sdk"; + +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +export const singlePoint = defineWaitPoint<{ id: string }, boolean>("my-step"); + +export const processJob = createWorkflowJob({ + name: "process", + body: async (input: { orderId: string }) => { + const result = await approval.wait({ message: `approve ${input.orderId}` }); + return { approved: result.approved }; + }, +}); + +export default createWorkflow({ name: "my-workflow", mainJob: processJob }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/destructuring-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/destructuring-shadow/expected.ts new file mode 100644 index 0000000000..c03f074e70 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/destructuring-shadow/expected.ts @@ -0,0 +1,14 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// Object destructuring shadows the import — calls inside should NOT be renamed +function helper(source: Record void>) { + const { defineWaitPoints } = source; + return defineWaitPoints(); +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/destructuring-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/destructuring-shadow/input.ts new file mode 100644 index 0000000000..a07ac53ec0 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/destructuring-shadow/input.ts @@ -0,0 +1,14 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// Object destructuring shadows the import — calls inside should NOT be renamed +function helper(source: Record void>) { + const { defineWaitPoints } = source; + return defineWaitPoints(); +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-loop-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-loop-shadow/expected.ts new file mode 100644 index 0000000000..33428ceab4 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-loop-shadow/expected.ts @@ -0,0 +1,13 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// for-loop counter shares the name — everything inside the for is NOT renamed +for (let defineWaitPoints = 0; defineWaitPoints < 3; defineWaitPoints++) { + void defineWaitPoints; +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-loop-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-loop-shadow/input.ts new file mode 100644 index 0000000000..3cf7fbc06c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-loop-shadow/input.ts @@ -0,0 +1,13 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// for-loop counter shares the name — everything inside the for is NOT renamed +for (let defineWaitPoints = 0; defineWaitPoints < 3; defineWaitPoints++) { + void defineWaitPoints; +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-of-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-of-shadow/expected.ts new file mode 100644 index 0000000000..6f7234a5ca --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-of-shadow/expected.ts @@ -0,0 +1,13 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// for...of loop variable shadows the import — everything inside the loop is NOT renamed +for (const defineWaitPoints of []) { + void defineWaitPoints; +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-of-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-of-shadow/input.ts new file mode 100644 index 0000000000..4462004e6e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/for-of-shadow/input.ts @@ -0,0 +1,13 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// for...of loop variable shadows the import — everything inside the loop is NOT renamed +for (const defineWaitPoints of []) { + void defineWaitPoints; +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/local-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/local-shadow/expected.ts new file mode 100644 index 0000000000..f0ff1cd3e3 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/local-shadow/expected.ts @@ -0,0 +1,8 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Local declaration shadows the SDK import — body usages should NOT be renamed +function defineWaitPoints() {} + +defineWaitPoints(); + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/local-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/local-shadow/input.ts new file mode 100644 index 0000000000..69061524d7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/local-shadow/input.ts @@ -0,0 +1,8 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Local declaration shadows the SDK import — body usages should NOT be renamed +function defineWaitPoints() {} + +defineWaitPoints(); + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/nested-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/nested-shadow/expected.ts new file mode 100644 index 0000000000..d0da7227d8 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/nested-shadow/expected.ts @@ -0,0 +1,14 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +function helper() { + // Nested local shadow — these should NOT be renamed + function defineWaitPoints() {} + defineWaitPoints(); +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/nested-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/nested-shadow/input.ts new file mode 100644 index 0000000000..70bd01d084 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/nested-shadow/input.ts @@ -0,0 +1,14 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +function helper() { + // Nested local shadow — these should NOT be renamed + function defineWaitPoints() {} + defineWaitPoints(); +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/no-match/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/no-match/input.ts new file mode 100644 index 0000000000..40c9dc8853 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/no-match/input.ts @@ -0,0 +1,4 @@ +import { createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; + +export const job = createWorkflowJob({ name: "job", body: () => ({ ok: true }) }); +export default createWorkflow({ name: "wf", mainJob: job }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/no-sdk-import/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/no-sdk-import/input.ts new file mode 100644 index 0000000000..157c91325a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/no-sdk-import/input.ts @@ -0,0 +1,5 @@ +// defineWaitPoints and defineWaitPoint used locally — not from @tailor-platform/sdk +function defineWaitPoints() {} +function defineWaitPoint() {} +defineWaitPoints(); +defineWaitPoint(); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/optional-param-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/optional-param-shadow/expected.ts new file mode 100644 index 0000000000..204ce53e8e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/optional-param-shadow/expected.ts @@ -0,0 +1,13 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// Optional parameter shadows the import — should NOT rename inside +function processAll(defineWaitPoints?: unknown[]) { + return defineWaitPoints?.length; +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/optional-param-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/optional-param-shadow/input.ts new file mode 100644 index 0000000000..8e77ef8afb --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/optional-param-shadow/input.ts @@ -0,0 +1,13 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// Optional parameter shadows the import — should NOT rename inside +function processAll(defineWaitPoints?: unknown[]) { + return defineWaitPoints?.length; +} + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/param-shadow/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/param-shadow/expected.ts new file mode 100644 index 0000000000..b2383ff650 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/param-shadow/expected.ts @@ -0,0 +1,18 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// Function parameter shadows the import — should NOT rename inside +function processAll(defineWaitPoints: unknown[]) { + return defineWaitPoints.length; +} + +// Arrow function parameter shadows the import — should NOT rename inside +const processEach = (defineWaitPoints: string) => { + return defineWaitPoints.trim(); +}; + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/param-shadow/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/param-shadow/input.ts new file mode 100644 index 0000000000..18dbade5e5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/param-shadow/input.ts @@ -0,0 +1,18 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Top-level SDK usage — should be renamed +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); + +// Function parameter shadows the import — should NOT rename inside +function processAll(defineWaitPoints: unknown[]) { + return defineWaitPoints.length; +} + +// Arrow function parameter shadows the import — should NOT rename inside +const processEach = (defineWaitPoints: string) => { + return defineWaitPoints.trim(); +}; + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/shorthand-property/expected.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/shorthand-property/expected.ts new file mode 100644 index 0000000000..e9f7bf3d06 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/shorthand-property/expected.ts @@ -0,0 +1,6 @@ +import { createWorkflow, createWaitPoints } from "@tailor-platform/sdk"; + +// Object shorthand usage — should be renamed alongside the import +const api = { createWaitPoints }; + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/shorthand-property/input.ts b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/shorthand-property/input.ts new file mode 100644 index 0000000000..6263adc92f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/wait-point-rename/tests/shorthand-property/input.ts @@ -0,0 +1,6 @@ +import { createWorkflow, defineWaitPoints } from "@tailor-platform/sdk"; + +// Object shorthand usage — should be renamed alongside the import +const api = { defineWaitPoints }; + +export default createWorkflow({ name: "wf", mainJob: {} as never }); diff --git a/packages/sdk-codemod/package.json b/packages/sdk-codemod/package.json index f2c8af5ef6..2361967fbe 100644 --- a/packages/sdk-codemod/package.json +++ b/packages/sdk-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@tailor-platform/sdk-codemod", - "version": "0.3.4", + "version": "0.3.0-next.2", "description": "Codemod runner for Tailor Platform SDK upgrades", "license": "MIT", "repository": { @@ -40,6 +40,7 @@ "@types/node": "24.13.2", "@types/picomatch": "4.0.3", "@types/semver": "7.7.1", + "eslint-plugin-zod": "4.7.0", "oxlint": "1.72.0", "tsdown": "0.22.3", "typescript": "6.0.3", diff --git a/packages/sdk-codemod/scripts/sync-codemod-docs.ts b/packages/sdk-codemod/scripts/sync-codemod-docs.ts new file mode 100644 index 0000000000..b6df519331 --- /dev/null +++ b/packages/sdk-codemod/scripts/sync-codemod-docs.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env tsx +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderMigrationDoc } from "../src/migration-doc"; +import { allCodemods } from "../src/registry"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +// packages/sdk-codemod/scripts -> packages/sdk/docs/migration/v2.md +const docPath = resolve(scriptDir, "../../sdk/docs/migration/v2.md"); + +function parseMode(args: string[]): "write" | "check" { + const modes = args.filter((arg) => arg === "--check" || arg === "--write"); + if (modes.length !== 1 || modes.length !== args.length) { + process.stderr.write("Usage: tsx scripts/sync-codemod-docs.ts --check|--write\n"); + process.exit(2); + } + return modes[0] === "--write" ? "write" : "check"; +} + +const mode = parseMode(process.argv.slice(2)); +const expected = renderMigrationDoc(allCodemods); + +if (mode === "write") { + await writeFile(docPath, expected, "utf-8"); + process.stderr.write(`Wrote ${docPath}\n`); +} else { + const actual = await readFile(docPath, "utf-8").catch(() => null); + if (actual !== expected) { + process.stderr.write( + "Migration doc is out of date. Run `pnpm codemod:docs:update` to regenerate it.\n", + ); + process.exit(1); + } +} diff --git a/packages/sdk-codemod/src/ast-grep-helpers.ts b/packages/sdk-codemod/src/ast-grep-helpers.ts new file mode 100644 index 0000000000..007a6d051f --- /dev/null +++ b/packages/sdk-codemod/src/ast-grep-helpers.ts @@ -0,0 +1,241 @@ +import type { Edit, SgNode } from "@ast-grep/napi"; + +const DECLARATION_KINDS = [ + "function_declaration", + "function_expression", + "class_declaration", + "class", + "enum_declaration", + "interface_declaration", + "type_alias_declaration", + "internal_module", + "import_alias", +]; + +export interface ImportSpecifierNames { + importedName: string; + localName: string; + typeOnly: boolean; +} + +export interface ImportBinding { + localName: string; + importedName?: string; + source: string; + typeOnly: boolean; +} + +export interface AddNamedImportEditOptions { + importName: string; + imports: SgNode[]; + insertionIndex: (root: SgNode, imports: SgNode[], source: string) => number; + moduleName: string; + root: SgNode; + source: string; +} + +function isBindingLeafKind(kind: ReturnType): boolean { + return ( + kind === "identifier" || + kind === "type_identifier" || + kind === "shorthand_property_identifier_pattern" + ); +} + +function isBindingPatternKind(kind: ReturnType): boolean { + return ( + isBindingLeafKind(kind) || + kind === "object_pattern" || + kind === "array_pattern" || + kind === "rest_pattern" + ); +} + +export function stringValue(node: SgNode | null): string | null { + return node?.text().replace(/^['"]|['"]$/g, "") ?? null; +} + +export function importSource(importStmt: SgNode): string | null { + return stringValue(importStmt.find({ rule: { kind: "string" } }) ?? null); +} + +export function isTypeOnlyImport(importStmt: SgNode): boolean { + return importStmt.children().some((child) => child.kind() === "type"); +} + +export function namedImportsNode(importStmt: SgNode): SgNode | null { + return importStmt.find({ rule: { kind: "named_imports" } }) ?? null; +} + +export function importSpecNames(spec: SgNode): ImportSpecifierNames | null { + const ids = spec.children().filter((child) => child.kind() === "identifier"); + if (ids.length === 0) return null; + return { + importedName: ids[0]!.text(), + localName: ids[1]?.text() ?? ids[0]!.text(), + typeOnly: spec.children().some((child) => child.kind() === "type"), + }; +} + +export function findImportStatements(root: SgNode): SgNode[] { + return root + .findAll({ rule: { kind: "import_statement" } }) + .filter((stmt) => stmt.parent()?.kind() === "program") + .toSorted((a, b) => a.range().start.index - b.range().start.index); +} + +export function importBindings(importStmt: SgNode): ImportBinding[] { + const source = importSource(importStmt); + if (!source) return []; + + const requireClause = importStmt + .children() + .find((child) => child.kind() === "import_require_clause"); + if (requireClause) { + const local = requireClause.children().find((child) => child.kind() === "identifier"); + return local ? [{ localName: local.text(), source, typeOnly: false }] : []; + } + + const typeOnly = isTypeOnlyImport(importStmt); + const clause = importStmt.children().find((child) => child.kind() === "import_clause"); + if (!clause) return []; + + const bindings: ImportBinding[] = []; + for (const child of clause.children()) { + if (child.kind() === "identifier") { + bindings.push({ localName: child.text(), source, typeOnly }); + continue; + } + + if (child.kind() === "namespace_import") { + const local = child.children().find((grandchild) => grandchild.kind() === "identifier"); + if (local) bindings.push({ localName: local.text(), source, typeOnly }); + continue; + } + + if (child.kind() !== "named_imports") continue; + for (const spec of child.findAll({ rule: { kind: "import_specifier" } })) { + const names = importSpecNames(spec); + if (!names) continue; + bindings.push({ ...names, source, typeOnly: typeOnly || names.typeOnly }); + } + } + + return bindings; +} + +export function buildAddNamedImportEdit(options: AddNamedImportEditOptions): Edit { + const { importName, imports, insertionIndex, moduleName, root, source } = options; + const existingImport = + imports.find( + (importStmt) => + importSource(importStmt) === moduleName && + !isTypeOnlyImport(importStmt) && + namedImportsNode(importStmt), + ) ?? null; + const namedImports = existingImport ? namedImportsNode(existingImport) : null; + if (namedImports) { + const specTexts = namedImports.findAll({ rule: { kind: "import_specifier" } }).map((spec) => { + const names = importSpecNames(spec); + return names?.importedName === importName && names.localName === importName + ? importName + : spec.text(); + }); + const nextSpecTexts = specTexts.includes(importName) ? specTexts : [...specTexts, importName]; + return namedImports.replace(`{ ${nextSpecTexts.join(", ")} }`); + } + + const pos = insertionIndex(root, imports, source); + const insertedText = + pos === 0 || source[pos - 1] === "\n" + ? `import { ${importName} } from "${moduleName}";\n\n` + : `\nimport { ${importName} } from "${moduleName}";`; + return { startPos: pos, endPos: pos, insertedText }; +} + +export function collectBindingNames(node: SgNode, names: Set): void { + if (isBindingLeafKind(node.kind())) { + names.add(node.text()); + return; + } + + for (const child of node.children()) { + if (child.kind() === "property_identifier") continue; + if (child.kind() === "=") break; + collectBindingNames(child, names); + } +} + +function firstDeclaratorChild(node: SgNode): SgNode | null { + return node.children().find((child) => child.kind() !== "=") ?? null; +} + +function collectDirectBindingChildren(node: SgNode, names: Set): void { + for (const child of node.children()) { + if (child.kind() === "=") break; + if (isBindingPatternKind(child.kind())) collectBindingNames(child, names); + } +} + +function collectParameters(root: SgNode, names: Set): void { + for (const param of root.findAll({ + rule: { any: [{ kind: "required_parameter" }, { kind: "optional_parameter" }] }, + })) { + collectDirectBindingChildren(param, names); + } +} + +function collectDeclarationNames(root: SgNode, names: Set): void { + for (const decl of root.findAll({ rule: { any: DECLARATION_KINDS.map((kind) => ({ kind })) } })) { + const name = decl + .children() + .find((child) => child.kind() === "identifier" || child.kind() === "type_identifier"); + if (name) names.add(name.text()); + } +} + +function collectArrowParameters(root: SgNode, names: Set): void { + for (const arrow of root.findAll({ rule: { kind: "arrow_function" } })) { + const children = arrow.children(); + const arrowIndex = children.findIndex((child) => child.kind() === "=>"); + if (arrowIndex === -1) continue; + for (const child of children.slice(0, arrowIndex)) { + if (child.kind() === "=") break; + if (isBindingPatternKind(child.kind())) collectBindingNames(child, names); + } + } +} + +function collectForInBindings(root: SgNode, names: Set): void { + for (const loop of root.findAll({ rule: { kind: "for_in_statement" } })) { + const children = loop.children(); + const keywordIndex = children.findIndex( + (child) => child.kind() === "in" || child.kind() === "of", + ); + if (keywordIndex === -1) continue; + for (const child of children.slice(0, keywordIndex)) { + collectBindingNames(child, names); + } + } +} + +export function localDeclarationNames(root: SgNode): Set { + const names = new Set(); + + for (const decl of root.findAll({ rule: { kind: "variable_declarator" } })) { + const binding = firstDeclaratorChild(decl); + if (binding) collectBindingNames(binding, names); + } + + collectParameters(root, names); + collectDeclarationNames(root, names); + + for (const catchClause of root.findAll({ rule: { kind: "catch_clause" } })) { + collectDirectBindingChildren(catchClause, names); + } + + collectArrowParameters(root, names); + collectForInBindings(root, names); + + return names; +} diff --git a/packages/sdk-codemod/src/index.ts b/packages/sdk-codemod/src/index.ts index b79af31f4c..7b650d562f 100644 --- a/packages/sdk-codemod/src/index.ts +++ b/packages/sdk-codemod/src/index.ts @@ -5,44 +5,140 @@ import * as path from "pathe"; import { readPackageJSON } from "pkg-types"; import { arg, defineCommand, runMain } from "politty"; import { z } from "zod"; -import { getApplicableCodemods, resolveCodemodScript } from "./registry"; +import { automationLevel } from "./migration-doc"; +import { allCodemods, getApplicableCodemods, resolveCodemodScript } from "./registry"; import { runCodemods } from "./runner"; -import type { RunOutput } from "./types"; +import { createRunnerMetadata } from "./runner-metadata"; +import type { LlmReview, RunOutput } from "./types"; -const packageJson = await readPackageJSON(path.dirname(fileURLToPath(import.meta.url)) + "/.."); +const packageRoot = path.dirname(fileURLToPath(import.meta.url)) + "/.."; +const packageJson = await readPackageJSON(packageRoot); +const packageName = packageJson.name ?? "sdk-codemod"; +const packageVersion = packageJson.version ?? "0.0.0"; + +/** One rule in the `list` command output. */ +interface RuleSummary { + id: string; + name: string; + /** Automatic / Partially automatic / Manual, or "Notice" for behavioral changes. */ + kind: string; + since: string; + until: string; +} + +const listCommand = defineCommand({ + name: "list", + description: "List the available codemod rules (id, name, kind, version range).", + args: z.strictObject({}), + run: () => { + const rules: RuleSummary[] = allCodemods.map((codemod) => ({ + id: codemod.id, + name: codemod.name, + kind: codemod.notice ? "Notice" : automationLevel(codemod), + since: codemod.since, + until: codemod.until, + })); + // Human-readable table to stderr; machine-readable JSON to stdout. + for (const rule of rules) { + process.stderr.write(` ${rule.id} [${rule.kind}] ${rule.name}\n`); + } + process.stdout.write(JSON.stringify(rules) + "\n"); + }, +}); + +/** + * Print an LLM-assisted review task to stderr: the flagged files plus the + * codemod's migration prompt, ready to hand to an LLM for the cases the + * deterministic transform could not complete on its own. + * @param review - The review task (codemod id, prompt, files) + */ +function printLlmReview(review: LlmReview): void { + const scope = + review.files.length > 0 + ? "the codemod cannot safely migrate these automatically" + : "review the project for this manual change"; + process.stderr.write(`\n🤖 LLM-assisted review suggested (${review.codemodId}) — ${scope}:\n`); + const findingsByFile = new Map>(); + for (const finding of review.findings ?? []) { + let findings = findingsByFile.get(finding.file); + if (!findings) { + findings = []; + findingsByFile.set(finding.file, findings); + } + findings.push(finding); + } + for (const file of review.files) { + process.stderr.write(` - ${file}\n`); + for (const finding of findingsByFile.get(file) ?? []) { + process.stderr.write(` - line ${finding.line}: ${finding.message}\n`); + process.stderr.write(` ${finding.excerpt}\n`); + } + } + process.stderr.write(`\nPrompt for an LLM:\n${review.prompt.trim()}\n`); +} const main = defineCommand({ - name: packageJson.name ?? "sdk-codemod", + name: packageName, description: packageJson.description ?? "Codemod runner for Tailor Platform SDK upgrades", - args: z - .object({ - from: arg(z.string(), { - description: "Source SDK version (the version before upgrade)", - }), - to: arg(z.string(), { - description: "Target SDK version (the version after upgrade)", - }), - target: arg(z.string().default("."), { - description: "Project directory to transform", - }), - "dry-run": arg(z.boolean().default(false), { - alias: "d", - description: "Preview changes without modifying files", - }), - }) - .strict(), + subCommands: { list: listCommand }, + notes: `Applies the codemods matching the \`--from\`/\`--to\` version range to the +\`--target\` directory, then writes a JSON summary to \`stdout\`: + +- \`filesModified\`: files a codemod changed +- \`warnings\`: files that may still need manual migration +- \`llmReviews\`: changes the codemods could not fully migrate on their own. Each + entry has the affected \`files\`, optional file-local \`findings\`, and a + \`prompt\` — hand the prompt and files to an LLM (or follow it yourself) to + finish those cases. +- \`runner\`: exact codemod runner identity. Local source builds include the + repository commit and the build command used to produce \`dist/index.js\`. + +Progress, warnings, and the LLM-review prompts are also printed to \`stderr\` in +human-readable form, so \`stdout\` stays pure JSON for piping.`, + examples: [ + { + cmd: "--from 1.64.0 --to 2.0.0", + desc: "Apply every codemod for the 1.64.0 -> 2.0.0 upgrade to the current project", + }, + { + cmd: "--from 1.64.0 --to 2.0.0 --dry-run", + desc: "Preview the changes and any LLM-review prompts without writing files", + }, + ], + args: z.strictObject({ + from: arg(z.string(), { + description: "Source SDK version (the version before upgrade)", + }), + to: arg(z.string(), { + description: "Target SDK version (the version after upgrade)", + }), + target: arg(z.string().default("."), { + description: "Project directory to transform", + }), + "dry-run": arg(z.boolean().default(false), { + alias: "d", + description: "Preview changes without modifying files", + }), + }), run: async (args) => { const targetPath = path.resolve(args.target); const dryRun = args["dry-run"]; + const runner = createRunnerMetadata({ + packageName, + packageVersion, + packageRoot, + }); const codemods = getApplicableCodemods(args.from, args.to); const output: RunOutput = { + runner, codemodsApplied: 0, codemodsSkipped: 0, filesModified: [], warnings: [], errors: [], + llmReviews: [], }; if (codemods.length === 0) { @@ -50,10 +146,10 @@ const main = defineCommand({ return; } - // Resolve script paths for all applicable codemods + // Resolve script paths for all applicable codemods (manual entries have none) const codemodEntries = codemods.map((codemod) => ({ codemod, - scriptPath: resolveCodemodScript(codemod.scriptPath), + scriptPath: codemod.scriptPath ? resolveCodemodScript(codemod.scriptPath) : undefined, })); for (const { codemod } of codemodEntries) { @@ -67,12 +163,17 @@ const main = defineCommand({ output.codemodsSkipped = codemods.length - result.appliedCodemodIds.size; output.filesModified = result.filesModified; output.warnings = result.warnings; + output.llmReviews = result.llmReviews; if (result.changed) { process.stderr.write(` ${result.filesModified.length} file(s) modified\n`); } else { process.stderr.write(" No changes needed\n"); } + + for (const review of output.llmReviews) { + printLlmReview(review); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); output.errors.push({ codemodId: "pipeline", message }); @@ -88,4 +189,4 @@ const main = defineCommand({ }, }); -void runMain(main, { version: packageJson.version }); +void runMain(main, { version: packageVersion }); diff --git a/packages/sdk-codemod/src/migration-doc.test.ts b/packages/sdk-codemod/src/migration-doc.test.ts new file mode 100644 index 0000000000..783ecf56a0 --- /dev/null +++ b/packages/sdk-codemod/src/migration-doc.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "vitest"; +import { automationLevel, renderMigrationDoc } from "./migration-doc"; +import type { CodemodPackage } from "./types"; + +function makeCodemod(overrides: Partial): CodemodPackage { + return { + id: "v2/example", + name: "Example", + description: "Example change.", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/example/scripts/transform.js", + ...overrides, + }; +} + +describe("automationLevel", () => { + test("transform with no residual signals is Automatic", () => { + expect(automationLevel(makeCodemod({}))).toBe("Automatic"); + }); + + test("transform with legacyPatterns/suspiciousPatterns/prompt is Partially automatic", () => { + expect(automationLevel(makeCodemod({ legacyPatterns: ["x"] }))).toBe("Partially automatic"); + expect(automationLevel(makeCodemod({ suspiciousPatterns: ["x"], prompt: "do it" }))).toBe( + "Partially automatic", + ); + }); + + test("no scriptPath is Manual", () => { + expect(automationLevel(makeCodemod({ scriptPath: undefined }))).toBe("Manual"); + }); +}); + +describe("renderMigrationDoc", () => { + test("renders heading, automation level, examples, and prompt", () => { + const doc = renderMigrationDoc([ + makeCodemod({ + name: "executeScript arg", + description: "Pass a value, not a string.", + suspiciousPatterns: ["executeScript"], + prompt: "Unwrap JSON.stringify in the arg.", + examples: [{ before: "arg: JSON.stringify(x)", after: "arg: x" }], + }), + ]); + + expect(doc).toContain("# Migrating to v2"); + expect(doc).toContain("## executeScript arg"); + expect(doc).toContain("**Migration:** Partially automatic"); + expect(doc).toContain("Before:\n\n```ts\narg: JSON.stringify(x)\n```"); + expect(doc).toContain("After:\n\n```ts\narg: x\n```"); + expect(doc).toContain("Prompt for an AI agent"); + expect(doc).toContain("```text\nUnwrap JSON.stringify in the arg.\n```"); + }); + + test("omits the prompt block for Automatic entries", () => { + const doc = renderMigrationDoc([makeCodemod({ name: "Auto", description: "Auto change." })]); + expect(doc).toContain("**Migration:** Automatic"); + expect(doc).not.toContain("
"); + }); + + test("renders notices in a separate section without a migration label", () => { + const doc = renderMigrationDoc([ + makeCodemod({ name: "Real migration", description: "Edit your code." }), + makeCodemod({ + name: "Keyring storage", + description: "Tokens move to the OS keyring.", + scriptPath: undefined, + notice: true, + }), + ]); + + expect(doc).toContain("## Behavioral changes (no migration required)"); + // The notice is a sub-section, not labelled as a migration. + expect(doc).toContain("### Keyring storage"); + expect(doc.split("### Keyring storage")[1]).not.toContain("**Migration:**"); + // A real migration keeps its migration label and stays out of the notices section. + expect(doc).toContain("## Real migration"); + expect(doc.indexOf("## Real migration")).toBeLessThan(doc.indexOf("## Behavioral changes")); + }); +}); diff --git a/packages/sdk-codemod/src/migration-doc.ts b/packages/sdk-codemod/src/migration-doc.ts new file mode 100644 index 0000000000..02ab5c0c29 --- /dev/null +++ b/packages/sdk-codemod/src/migration-doc.ts @@ -0,0 +1,120 @@ +import type { CodemodPackage } from "./types"; + +export type AutomationLevel = "Automatic" | "Partially automatic" | "Manual"; + +/** + * Classify how much of a migration the codemod automates. + * - `Automatic`: a transform fully covers it, with no residual to flag. + * - `Partially automatic`: a transform covers the common cases but flags + * residuals (via `legacyPatterns`/`sourceStringLegacyPatterns`/ + * `sourceTextLegacyPatterns`/`suspiciousPatterns`/ + * `sourceStringSuspiciousPatterns`/`prompt`) to finish. + * - `Manual`: no transform; the change is migrated by hand (optionally guided + * by a `prompt`). Whether a person or an LLM does it does not matter here. + * @param codemod - The codemod registry entry + * @returns The automation level + */ +export function automationLevel(codemod: CodemodPackage): AutomationLevel { + if (!codemod.scriptPath) return "Manual"; + const flagsResidual = + (codemod.legacyPatterns?.length ?? 0) > 0 || + (codemod.sourceStringLegacyPatterns?.length ?? 0) > 0 || + (codemod.sourceTextLegacyPatterns?.length ?? 0) > 0 || + (codemod.suspiciousPatterns?.length ?? 0) > 0 || + (codemod.sourceStringSuspiciousPatterns?.length ?? 0) > 0 || + codemod.prompt != null; + return flagsResidual ? "Partially automatic" : "Automatic"; +} + +function renderEntry(codemod: CodemodPackage): string { + const level = automationLevel(codemod); + const lines: string[] = [`## ${codemod.name}`, "", `**Migration:** ${level}`, ""]; + lines.push(codemod.description, ""); + + for (const example of codemod.examples ?? []) { + const fence = "```" + (example.lang ?? "ts"); + if (example.caption) lines.push(example.caption, ""); + lines.push( + "Before:", + "", + fence, + example.before, + "```", + "", + "After:", + "", + fence, + example.after, + "```", + "", + ); + } + + if (level !== "Automatic" && codemod.prompt != null) { + const summary = + level === "Manual" + ? "Prompt for an AI agent (to perform this migration)" + : "Prompt for an AI agent (to finish the cases the codemod could not migrate)"; + lines.push( + "
", + `${summary}`, + "", + "```text", + codemod.prompt.trimEnd(), + "```", + "", + "
", + "", + ); + } + + return lines.join("\n"); +} + +/** Render an informational behavioral-change notice (no migration). */ +function renderNotice(codemod: CodemodPackage): string { + return [`### ${codemod.name}`, "", codemod.description, ""].join("\n"); +} + +/** + * Render the v2 migration guide from the codemod registry. The registry is the + * single source of truth; missing detail is added to the codemod definitions. + * @param codemods - All registered codemods, in registration order + * @returns The migration guide as Markdown + */ +export function renderMigrationDoc(codemods: CodemodPackage[]): string { + // NOTE: This generator is currently v1→v2-specific: the title, `v2.md` output + // path, and `v2/*` rule ids / `until: "2.0.0"` ranges are all hardcoded. + // Supporting future major migrations would require parameterizing the target + // version, output path, and rule namespace. + const header = [ + "# Migrating to v2", + "", + "", + "", + "Run the codemods, then finish anything reported as not migrated automatically:", + "", + "```sh", + "npx @tailor-platform/sdk-codemod --from --to ", + "```", + "", + ].join("\n"); + + const migrations = codemods.filter((c) => !c.notice); + const notices = codemods.filter((c) => c.notice); + + const sections = [header, migrations.map(renderEntry).join("\n")]; + if (notices.length > 0) { + sections.push( + [ + "## Behavioral changes (no migration required)", + "", + "These v2 changes alter runtime or CLI behavior; no source change is needed.", + "", + notices.map(renderNotice).join("\n"), + ].join("\n"), + ); + } + + return `${sections.join("\n")}`.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; +} diff --git a/packages/sdk-codemod/src/principal-unify-review.test.ts b/packages/sdk-codemod/src/principal-unify-review.test.ts new file mode 100644 index 0000000000..761b1c442d --- /dev/null +++ b/packages/sdk-codemod/src/principal-unify-review.test.ts @@ -0,0 +1,480 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "pathe"; +import { afterEach, describe, expect, test } from "vitest"; +import { allCodemods } from "./registry"; +import { runCodemods } from "./runner"; + +const CODEMODS_DIR = path.resolve(__dirname, "../codemods"); + +const principalUnify = allCodemods.find((codemod) => codemod.id === "v2/principal-unify"); + +if (!principalUnify?.scriptPath) { + throw new Error("v2/principal-unify codemod is not registered with a script"); +} + +const principalUnifyEntry = { + codemod: principalUnify, + scriptPath: path.join(CODEMODS_DIR, principalUnify.scriptPath.replace(/\.js$/, ".ts")), +}; + +describe("principal-unify review findings", () => { + let tmpDir: string | undefined; + + afterEach(async () => { + if (tmpDir) { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + async function writeProjectFile(relative: string, source: string): Promise { + tmpDir ??= await fs.promises.mkdtemp(path.join(os.tmpdir(), "principal-review-test-")); + const file = path.join(tmpDir, relative); + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + await fs.promises.writeFile(file, source, "utf-8"); + } + + test("reports nullable caller values passed to non-null-looking calls", async () => { + await writeProjectFile( + "resolvers/order.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "declare const db: any;", + "declare function publishAudit(userId: string): Promise;", + "", + "export const resolver = createResolver({", + " body: async (context) => {", + ' await db.selectFrom("orders").where("createdBy", "=", context.user.id).execute();', + " await publishAudit(context.user.id);", + " const maybeId = context.user.id;", + " return maybeId;", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/order.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/order.ts", + line: 8, + message: expect.stringContaining("Kysely predicate"), + excerpt: expect.stringContaining("context.caller?.id"), + }), + expect.objectContaining({ + file: "resolvers/order.ts", + line: 9, + message: expect.stringContaining("non-null argument"), + excerpt: expect.stringContaining("publishAudit(context.caller?.id)"), + }), + ], + }), + ]); + }); + + test("reports context.user helper adapters called with resolver contexts", async () => { + await writeProjectFile( + "resolvers/customer.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "function createContext(context: any) {", + " return {", + " userId: context.user.id,", + " userType: context.user.type,", + " };", + "}", + "", + "export const resolver = createResolver({", + " body: async (context) => createContext(context),", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/customer.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/customer.ts", + line: 3, + message: expect.stringContaining("createContext"), + excerpt: expect.stringContaining("function createContext"), + }), + expect.objectContaining({ + file: "resolvers/customer.ts", + line: 11, + message: expect.stringContaining("createContext"), + excerpt: expect.stringContaining("createContext(context)"), + }), + ], + }), + ]); + }); + + test("reports helper adapters that destructure context.user", async () => { + await writeProjectFile( + "resolvers/destructured-helper.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "function createContext(context: any) {", + " const { user } = context;", + " return { userId: user.id };", + "}", + "", + "export const resolver = createResolver({", + " body: async (context) => createContext(context),", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/destructured-helper.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/destructured-helper.ts", + line: 3, + message: expect.stringContaining("createContext"), + }), + expect.objectContaining({ + file: "resolvers/destructured-helper.ts", + line: 9, + message: expect.stringContaining("createContext"), + }), + ], + }), + ]); + }); + + test("reports helper adapters with destructured context parameters", async () => { + await writeProjectFile( + "resolvers/destructured-param-helper.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "function createContext({ user }: any) {", + " return { userId: user.id };", + "}", + "", + "export const resolver = createResolver({", + " body: async (context) => createContext(context),", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/destructured-param-helper.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/destructured-param-helper.ts", + line: 3, + message: + "Helper adapter createContext reads user and needs v2 caller/invoker semantics.", + }), + expect.objectContaining({ + file: "resolvers/destructured-param-helper.ts", + line: 8, + message: + "createContext(context) passes an SDK resolver context into a helper that reads user.", + }), + ], + }), + ]); + }); + + test("reports helper adapters with aliased destructured context parameters", async () => { + await writeProjectFile( + "resolvers/aliased-destructured-param-helper.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "function createContext({ user: currentUser }: any) {", + " return { userId: currentUser.id };", + "}", + "", + "export const resolver = createResolver({", + " body: async (context) => createContext(context),", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/aliased-destructured-param-helper.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/aliased-destructured-param-helper.ts", + line: 3, + message: + "Helper adapter createContext reads currentUser and needs v2 caller/invoker semantics.", + }), + expect.objectContaining({ + file: "resolvers/aliased-destructured-param-helper.ts", + line: 8, + message: + "createContext(context) passes an SDK resolver context into a helper that reads currentUser.", + }), + ], + }), + ]); + }); + + test("keeps file-level suspicious-pattern fallback without precise findings", async () => { + await writeProjectFile( + "resolvers/context-type.ts", + [ + 'import type { ResolverContext } from "@tailor-platform/sdk";', + "", + "export type AdapterContext = ResolverContext;", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/context-type.ts"], + }), + ]); + expect(result.llmReviews[0]).not.toHaveProperty("findings"); + }); + + test("reports nullable aliased caller values passed to non-null-looking calls", async () => { + await writeProjectFile( + "resolvers/aliased.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "declare function publishAudit(userId: string): Promise;", + "", + "export const resolver = createResolver({", + " body: async ({ user: currentUser }) => {", + " await publishAudit(currentUser.id);", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/aliased.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/aliased.ts", + line: 7, + message: expect.stringContaining("non-null argument"), + excerpt: expect.stringContaining("publishAudit(currentUser?.id)"), + }), + ], + }), + ]); + }); + + test("reports nullable caller objects passed directly to non-null-looking calls", async () => { + await writeProjectFile( + "resolvers/direct-caller.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "declare function publishAudit(user: { id: string }): Promise;", + "", + "export const contextResolver = createResolver({", + " body: async (context) => {", + " await publishAudit(context.user);", + " },", + "});", + "", + "export const destructuredResolver = createResolver({", + " body: async ({ user }) => {", + " await publishAudit(user);", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/direct-caller.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/direct-caller.ts", + line: 7, + message: expect.stringContaining("non-null argument"), + excerpt: expect.stringContaining("publishAudit(context.caller)"), + }), + expect.objectContaining({ + file: "resolvers/direct-caller.ts", + line: 13, + message: expect.stringContaining("non-null argument"), + excerpt: expect.stringContaining("publishAudit(caller)"), + }), + ], + }), + ]); + }); + + test("reports aliases assigned from nullable caller values", async () => { + await writeProjectFile( + "resolvers/assigned-alias.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "declare function publishAudit(userId: string): Promise;", + "", + "export const resolver = createResolver({", + " body: async (context) => {", + " const user = context.user;", + " await publishAudit(user.id);", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([ + expect.objectContaining({ + codemodId: "v2/principal-unify", + files: ["resolvers/assigned-alias.ts"], + findings: [ + expect.objectContaining({ + file: "resolvers/assigned-alias.ts", + line: 8, + message: expect.stringContaining("non-null argument"), + excerpt: expect.stringContaining("publishAudit(user.id)"), + }), + ], + }), + ]); + }); + + test("does not report unrelated caller properties as nullable principals", async () => { + await writeProjectFile( + "resolvers/domain-caller.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "declare function publishAudit(userId: string): Promise;", + "", + "export const resolver = createResolver({", + " body: async ({ user }) => {", + " const event = { caller: { id: user.id } };", + " await publishAudit(event.caller.id);", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews.flatMap((review) => review.findings ?? [])).toEqual([]); + }); + + test("does not report SDK field parse invoker arguments", async () => { + await writeProjectFile( + "resolvers/parse-invoker.ts", + [ + 'import { createResolver, t } from "@tailor-platform/sdk";', + "", + "const nameField = t.string();", + "", + "export const resolver = createResolver({", + " body: async ({ input, user }) => {", + " return nameField.parse({ value: input.name, user });", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews.flatMap((review) => review.findings ?? [])).toEqual([]); + }); + + test("does not report nested SDK field parse invoker arguments", async () => { + await writeProjectFile( + "resolvers/nested-parse-invoker.ts", + [ + 'import { createResolver, t } from "@tailor-platform/sdk";', + "", + "const nameField = t.string();", + "declare function wrap(value: unknown): unknown;", + "", + "export const resolver = createResolver({", + " body: async ({ input, user }) => {", + " return wrap(nameField.parse({ value: input.name, invoker: user.id }));", + " },", + "});", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews.flatMap((review) => review.findings ?? [])).toEqual([]); + }); + + test("does not report matching alias names outside the resolver scope", async () => { + await writeProjectFile( + "resolvers/scoped-alias.ts", + [ + 'import { createResolver } from "@tailor-platform/sdk";', + "", + "declare function publishAudit(userId: string | undefined): Promise;", + "", + "export const resolver = createResolver({", + " body: async ({ user: currentUser }) => currentUser.id,", + "});", + "", + "async function audit(currentUser?: { id: string }) {", + " await publishAudit(currentUser?.id);", + "}", + "", + ].join("\n"), + ); + + const result = await runCodemods([principalUnifyEntry], tmpDir!, false); + + expect(result.llmReviews).toEqual([]); + }); +}); diff --git a/packages/sdk-codemod/src/registry.test.ts b/packages/sdk-codemod/src/registry.test.ts index 23da4125f4..fb80aee1f3 100644 --- a/packages/sdk-codemod/src/registry.test.ts +++ b/packages/sdk-codemod/src/registry.test.ts @@ -1,5 +1,8 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import picomatch from "picomatch"; import { describe, expect, test } from "vitest"; -import { getApplicableCodemods } from "./registry"; +import { allCodemods, getApplicableCodemods } from "./registry"; describe("getApplicableCodemods", () => { test("returns codemods when upgrading across their version boundary", () => { @@ -8,10 +11,97 @@ describe("getApplicableCodemods", () => { expect(codemods[0]!.id).toBe("v2/define-generators-to-plugins"); }); + test("returns all v2 codemods when upgrading to the stable boundary", () => { + expect(getApplicableCodemods("1.67.1", "2.0.0").map((codemod) => codemod.id)).toEqual( + allCodemods.map((codemod) => codemod.id), + ); + }); + + test("bundles every registered transform script", () => { + const tsdownConfig = fs.readFileSync(path.resolve(__dirname, "../tsdown.config.ts"), "utf-8"); + const missing = allCodemods + .flatMap((codemod) => (codemod.scriptPath ? [codemod.scriptPath] : [])) + .map((scriptPath) => `codemods/${scriptPath.replace(/\.js$/, ".ts")}`) + .filter((scriptPath) => !tsdownConfig.includes(scriptPath)); + + expect(missing).toEqual([]); + }); + + test("returns codemods when upgrading to a prerelease at their version boundary", () => { + const prereleaseCodemods = getApplicableCodemods("1.67.1", "2.0.0-next.2"); + const prereleaseIds = prereleaseCodemods.map((codemod) => codemod.id); + + expect(prereleaseIds).toEqual( + allCodemods + .filter( + (codemod) => + codemod.prereleaseUntil === "2.0.0-next.1" || + codemod.prereleaseUntil === "2.0.0-next.2", + ) + .map((codemod) => codemod.id), + ); + expect(prereleaseIds).not.toContain("v2/auth-attributes-rename"); + expect(prereleaseIds).not.toContain("v2/env-var-rename"); + expect(prereleaseIds).not.toContain("v2/rename-bin"); + expect(prereleaseIds).not.toContain("v2/node-minimum-22-15-0"); + }); + test("returns empty when both versions are before the codemod boundary", () => { expect(getApplicableCodemods("1.0.0", "1.5.0")).toEqual([]); }); + test("uses each codemod's prerelease boundary", () => { + const ids = getApplicableCodemods("1.67.1", "2.0.0-next.1").map((codemod) => codemod.id); + const authInvokerCallUnwrap = getApplicableCodemods("1.67.1", "2.0.0-next.1").find( + (codemod) => codemod.id === "v2/auth-invoker-call-unwrap", + ); + + expect(ids).toContain("v2/test-run-arg-input"); + expect(ids).toContain("v2/auth-invoker-call-unwrap"); + expect(authInvokerCallUnwrap?.suspiciousPatterns).toEqual(["auth.invoker"]); + expect(authInvokerCallUnwrap?.reviewSupersededBy).toEqual(["v2/auth-invoker-unwrap"]); + expect(ids).not.toContain("v2/execute-script-arg"); + expect(ids).not.toContain("v2/principal-unify"); + }); + + test("throws when a prerelease boundary is not a prerelease version", () => { + allCodemods.push({ + id: "v2/invalid-prerelease-boundary", + name: "Invalid prerelease boundary", + description: "Invalid prerelease boundary", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: "2.0.0", + }); + + try { + expect(() => getApplicableCodemods("1.0.0", "2.0.0-next.1")).toThrow( + "Codemod v2/invalid-prerelease-boundary prereleaseUntil must be a prerelease version: 2.0.0", + ); + } finally { + allCodemods.pop(); + } + }); + + test("returns empty when the source prerelease already reached the codemod boundary", () => { + expect(getApplicableCodemods("2.0.0-next.2", "2.0.0-next.2")).toEqual([]); + }); + + test("runs stable-only codemods when upgrading from a prerelease to stable", () => { + const ids = getApplicableCodemods("2.0.0-next.2", "2.0.0").map((codemod) => codemod.id); + + expect(ids).toContain("v2/auth-attributes-rename"); + expect(ids).toContain("v2/env-var-rename"); + expect(ids).toContain("v2/rename-bin"); + expect(ids).toContain("v2/node-minimum-22-15-0"); + expect(ids).not.toContain("v2/principal-unify"); + expect(ids).not.toContain("v2/auth-invoker-unwrap"); + }); + + test("returns empty when the target prerelease is before the codemod boundary", () => { + expect(getApplicableCodemods("1.67.1", "1.99.0-next.1")).toEqual([]); + }); + test("returns empty when both versions are after the codemod boundary", () => { expect(getApplicableCodemods("2.0.0", "3.0.0")).toEqual([]); }); @@ -24,4 +114,202 @@ describe("getApplicableCodemods", () => { expect(() => getApplicableCodemods("invalid", "2.0.0")).toThrow("Invalid fromVersion"); expect(() => getApplicableCodemods("1.0.0", "invalid")).toThrow("Invalid toVersion"); }); + + test("apply-to-deploy scans source files with embedded CLI strings", () => { + const applyToDeploy = getApplicableCodemods("1.67.1", "2.0.0").find( + (codemod) => codemod.id === "v2/apply-to-deploy", + ); + + expect(applyToDeploy?.filePatterns).toEqual( + expect.arrayContaining(["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"]), + ); + }); + + test("env-var-rename scans env files, CI configs, and source files", () => { + const envVarRename = getApplicableCodemods("1.67.1", "2.0.0").find( + (codemod) => codemod.id === "v2/env-var-rename", + ); + + expect(envVarRename?.filePatterns).toEqual( + expect.arrayContaining([ + "**/.env", + "**/.env.*", + "**/*.{env,sh,bash,zsh,yml,yaml,json,md}", + "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + ]), + ); + expect(envVarRename?.legacyPatterns).toContain("TAILOR_PLATFORM_SDK_CONFIG_PATH"); + expect(envVarRename?.legacyPatterns).toContain("TAILOR_TOKEN"); + expect(envVarRename?.sourceStringLegacyPatterns).toEqual( + expect.arrayContaining(["PLATFORM_URL", "PLATFORM_OAUTH2_CLIENT_ID", "LOG_LEVEL"]), + ); + }); + + test("rename-bin scans source files and declaration comments", () => { + const sourcePattern = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"; + const renameBin = getApplicableCodemods("1.67.1", "2.0.0").find( + (codemod) => codemod.id === "v2/rename-bin", + ); + + expect(renameBin?.filePatterns).toEqual(expect.arrayContaining([sourcePattern])); + expect(renameBin?.sourceStringLegacyPatterns).toHaveLength(3); + expect(renameBin?.sourceTextLegacyPatterns).toHaveLength(3); + const sourceStringPatterns = renameBin?.sourceStringLegacyPatterns as RegExp[]; + const matchesSourceStringPattern = (value: string) => + sourceStringPatterns.some((pattern) => pattern.test(value)); + expect(matchesSourceStringPattern("tailor-sdk deploy")).toBe(true); + expect(matchesSourceStringPattern("tailor-sdk apply")).toBe(true); + expect(matchesSourceStringPattern('sh -c "tailor-sdk apply"')).toBe(true); + expect(matchesSourceStringPattern('bash -lc "tailor-sdk crash-report list"')).toBe(true); + expect(matchesSourceStringPattern('Run "tailor-sdk crash-report list" manually')).toBe(true); + expect(matchesSourceStringPattern("tailor-sdk.cmd crash-report list")).toBe(true); + expect(matchesSourceStringPattern("tailor --profile tailor-sdk deploy")).toBe(false); + expect(matchesSourceStringPattern("tailor --name tailor-sdk deploy")).toBe(false); + expect(matchesSourceStringPattern('tailor --arg "tailor-sdk deploy" deploy')).toBe(false); + expect(matchesSourceStringPattern('tailor --arg "tailor-sdk apply" deploy')).toBe(false); + expect(matchesSourceStringPattern("tailor --name 'tailor-sdk crash-report list' deploy")).toBe( + false, + ); + const matches = picomatch(renameBin?.filePatterns ?? [], { dot: true }); + expect(matches("packages/app/frontend/e2e/global-setup.ts")).toBe(true); + expect(matches("tailor.d.ts")).toBe(true); + }); + + test("flags source files for runtime globals review", () => { + const codemod = allCodemods.find((entry) => entry.id === "v2/runtime-globals-opt-in"); + + expect(codemod?.scriptPath).toBe("v2/runtime-globals-opt-in/scripts/transform.js"); + expect(codemod?.filePatterns).toContain("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"); + expect(codemod?.suspiciousPatterns).toContain("tailor.idp"); + expect(codemod?.suspiciousPatterns).toContain("tailor.secretmanager"); + expect(codemod?.suspiciousPatterns).toContain("tailor.authconnection"); + expect(codemod?.sourceStringSuspiciousPatterns).toContain("new tailor.idp.Client"); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => pattern instanceof RegExp && pattern.test("const C = tailor.idp.Client;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && pattern.test("await tailor.secretmanager.getSecret();"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && pattern.test("const { getSecret } = tailor.secretmanager;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && + pattern.test("const getInvoker = tailor.context.getInvoker;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => pattern instanceof RegExp && pattern.test("const { upload } = tailordb.file;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => pattern instanceof RegExp && pattern.test("const e: TailorErrors = err;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && pattern.test("type U = Promise;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && pattern.test("type Ctor = typeof tailordb.Client;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => pattern instanceof RegExp && pattern.test("return tailordb.Client;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => pattern instanceof RegExp && pattern.test("foo(tailordb.Client);"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && pattern.test("type F = () => tailordb.QueryResult;"), + ), + ).toBe(true); + expect( + codemod?.sourceStringSuspiciousPatterns?.some( + (pattern) => + pattern instanceof RegExp && + pattern.test("type R = Promise>;"), + ), + ).toBe(true); + expect(codemod?.prompt).toContain("@tailor-platform/sdk/runtime/globals"); + }); + + test("leads runtime globals migration with the typed wrappers", () => { + const codemod = allCodemods.find((entry) => entry.id === "v2/runtime-globals-opt-in"); + + expect(codemod?.prompt).toContain("new idp.Client(...)"); + expect(codemod?.examples?.[0]?.after).toContain( + 'import { idp } from "@tailor-platform/sdk/runtime"', + ); + }); + + test("execute-script-arg reviews unresolved arg stringification patterns", () => { + const executeScriptArg = getApplicableCodemods("1.67.1", "2.0.0").find( + (codemod) => codemod.id === "v2/execute-script-arg", + ); + const argPattern = executeScriptArg?.suspiciousPatterns?.find( + (pattern): pattern is [string, string, RegExp] => + Array.isArray(pattern) && pattern[2] instanceof RegExp, + )?.[2]; + + expect(argPattern?.test("arg: value")).toBe(true); + expect(argPattern?.test("arg : value")).toBe(true); + expect(argPattern?.test("arg = value")).toBe(true); + expect(argPattern?.test('"arg" : value')).toBe(true); + expect(argPattern?.test('["arg"] = value')).toBe(true); + }); + + test("flags principal migration follow-ups for review", () => { + const codemod = allCodemods.find((entry) => entry.id === "v2/principal-unify"); + + expect(codemod?.suspiciousPatterns).toContain("context.user"); + expect(codemod?.suspiciousPatterns).toContain("caller?."); + expect(codemod?.prompt).toContain("anonymous callers"); + }); + + test("open-download-stream review is scoped to deprecated API names", () => { + const openDownloadStream = getApplicableCodemods("1.67.1", "2.0.0").find( + (codemod) => codemod.id === "v2/open-download-stream", + ); + + expect(openDownloadStream?.suspiciousPatterns).toEqual( + expect.arrayContaining(["openDownloadStream", "openFileDownloadStream"]), + ); + }); + + test("auth connection token helper review is scoped to deprecated helper calls", () => { + const codemod = getApplicableCodemods("1.67.1", "2.0.0-next.2").find( + (entry) => entry.id === "v2/auth-connection-token-helper", + ); + const pattern = codemod?.suspiciousPatterns?.[0]; + + expect(codemod?.scriptPath).toBe("v2/auth-connection-token-helper/scripts/transform.js"); + expect(codemod?.filePatterns).toContain("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"); + expect(pattern).toBeUndefined(); + expect(codemod?.prompt).toContain("@tailor-platform/sdk/runtime"); + expect(codemod?.prompt).toContain("non-call"); + expect(codemod?.prompt).toContain("destructuring"); + }); }); diff --git a/packages/sdk-codemod/src/registry.ts b/packages/sdk-codemod/src/registry.ts index 5cff28cda8..bdbcf9ab89 100644 --- a/packages/sdk-codemod/src/registry.ts +++ b/packages/sdk-codemod/src/registry.ts @@ -1,11 +1,105 @@ import * as url from "node:url"; import * as path from "pathe"; -import { lt, gte, valid } from "semver"; +import { gte, lt, parse, valid } from "semver"; import type { CodemodPackage } from "./types"; const CODEMODS_ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "codemods"); +const RENAME_BIN_SOURCE_VALUE_FLAGS = [ + "--env-file-if-exists", + "--env-file", + "--profile", + "--config", + "--workspace-id", + "--arg", + "--query", + "--file", + "--name", + "--namespace", + "--dir", + "-e", + "-p", + "-c", + "-w", + "-a", + "-q", + "-f", + "-n", +]; +const RENAME_BIN_SOURCE_COMMANDS = [ + "api", + "apply", + "authconnection", + "completion", + "crash-report", + "crashreport", + "deploy", + "executor", + "function", + "generate", + "init", + "login", + "logout", + "machineuser", + "oauth2client", + "open", + "organization", + "profile", + "query", + "remove", + "secret", + "setup", + "show", + "skills", + "staticwebsite", + "tailordb", + "upgrade", + "user", + "workflow", + "workspace", +]; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} -const allCodemods: CodemodPackage[] = [ +const RENAME_BIN_SOURCE_VALUE_GUARDS = RENAME_BIN_SOURCE_VALUE_FLAGS.flatMap((flag) => { + const escaped = escapeRegExp(flag); + return [`(? subpath.", + ].join("\n"), }, { id: "v2/plugin-cli-import", @@ -23,17 +142,32 @@ const allCodemods: CodemodPackage[] = [ "Rewrite deprecated plugin re-export imports (kyselyTypePlugin, enumConstantsPlugin, fileUtilsPlugin, seedPlugin) from `@tailor-platform/sdk/cli` to their dedicated plugin subpaths", since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_1, scriptPath: "v2/plugin-cli-import/scripts/transform.js", + examples: [ + { + before: 'import { kyselyTypePlugin } from "@tailor-platform/sdk/cli";', + after: 'import { kyselyTypePlugin } from "@tailor-platform/sdk/plugin/kysely-type";', + }, + ], }, { id: "v2/test-run-arg-input", name: "function test-run --arg input unwrap", description: - "Strip the deprecated {input: ...} wrapper from `tailor-sdk function test-run --arg` JSON in scripts and docs", + "Strip the deprecated {input: ...} wrapper from `tailor function test-run --arg` JSON in scripts and docs", since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_1, scriptPath: "v2/test-run-arg-input/scripts/transform.js", filePatterns: ["**/package.json", "**/*.{sh,bash,zsh}", "**/*.md"], + examples: [ + { + lang: "sh", + before: 'tailor function test-run resolvers/add.ts --arg \'{"input":{"a":1}}\'', + after: "tailor function test-run resolvers/add.ts --arg '{\"a\":1}'", + }, + ], }, { id: "v2/sdk-skills-shim", @@ -42,59 +176,809 @@ const allCodemods: CodemodPackage[] = [ "Replace deprecated `tailor-sdk-skills` invocations with `tailor-sdk skills install`", since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_1, scriptPath: "v2/sdk-skills-shim/scripts/transform.js", filePatterns: ["**/package.json", "**/*.{sh,bash,zsh,yml,yaml}", "**/*.md"], legacyPatterns: ["tailor-sdk-skills"], + examples: [ + { + lang: "sh", + before: "npx tailor-sdk-skills", + after: "tailor-sdk skills install", + }, + ], + prompt: [ + "The standalone tailor-sdk-skills binary is removed in v2; call the skills install", + "subcommand on the main tailor-sdk CLI instead. Replace any remaining", + "tailor-sdk-skills invocations the codemod did not rewrite with", + "`tailor-sdk skills install`.", + ].join("\n"), }, { id: "v2/principal-unify", - name: "Unify TailorUser/TailorActor/TailorInvoker → TailorPrincipal", + name: "Unify TailorUser/TailorActor/TailorActorType/TailorInvoker → TailorPrincipal", description: - "Rename TailorUser/TailorActor/TailorInvoker to TailorPrincipal, drop unauthenticatedTailorUser, and rename resolver body `user` to `caller`", + "Rename TailorUser/TailorActor/TailorActorType/TailorInvoker to TailorPrincipal, drop unauthenticatedTailorUser, rename resolver body `user` to `caller`, and rename TailorDB callback `user` to `invoker`", since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_2, scriptPath: "v2/principal-unify/scripts/transform.js", - legacyPatterns: ["TailorUser", "TailorActor", "TailorInvoker", "unauthenticatedTailorUser"], + legacyPatterns: [ + "TailorUser", + "TailorActor", + "TailorActorType", + "TailorInvoker", + "unauthenticatedTailorUser", + ], + suspiciousPatterns: [ + "caller?.", + "context.user", + "context.invoker ?? context.user", + "ResolverContext", + ], + examples: [ + { + caption: "Type references unify under `TailorPrincipal`:", + before: 'import type { TailorUser } from "@tailor-platform/sdk";', + after: 'import type { TailorPrincipal } from "@tailor-platform/sdk";', + }, + { + caption: "The resolver body `user` becomes `caller`:", + before: "body: ({ input, user }) => user.id,", + after: "body: ({ input, caller }) => caller.id,", + }, + ], + prompt: [ + "Finish the cases the codemod left for manual migration:", + "- Rename user -> caller in resolver bodies the codemod skipped because a `caller`", + " binding already exists or renaming would shadow/collide with another value.", + "- Replace member-access on the removed unauthenticatedTailorUser (e.g.", + " unauthenticatedTailorUser.id); the codemod only replaced standalone references", + " with null and left member access to surface a type error.", + "- Review helper adapters that still accept or read `context.user`; v2 resolver", + " context uses nullable `caller` and `invoker`, so project-specific helper", + " semantics for anonymous callers and command invokers must be chosen explicitly.", + "- Review `caller?.` values passed to APIs that require non-null values. If the", + " resolver requires authentication, throw or otherwise narrow before the call;", + " if anonymous callers are allowed, keep the nullable flow explicit.", + "Use TailorPrincipal for the unified user/actor/invoker type.", + ].join("\n"), + }, + { + id: "v2/auth-attributes-rename", + name: "AttributeMap → Attributes", + description: + "Rename auth attribute module augmentation and related SDK type names from `AttributeMap` to `Attributes`", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/auth-attributes-rename/scripts/transform.js", + legacyPatterns: [ + "AttributeMap", + "interface AttributeMap", + "UserAttributeMap", + "InferredAttributeMap", + ], + examples: [ + { + caption: "Module augmentation uses `Attributes`:", + before: + 'declare module "@tailor-platform/sdk" {\n interface AttributeMap {\n role: string;\n }\n}', + after: + 'declare module "@tailor-platform/sdk" {\n interface Attributes {\n role: string;\n }\n}', + }, + ], + prompt: [ + "In Tailor SDK v2, the auth attribute type API is renamed from `AttributeMap`", + "to `Attributes`; related SDK types are renamed to `UserAttributes` and", + "`InferredAttributes`. The codemod rewrites SDK imports, re-exports,", + "namespace-qualified references, import() type references, and module", + "augmentations. Review any remaining matches manually and leave unrelated", + "local names or deploy/proto wire field names unchanged.", + ].join("\n"), }, { id: "v2/apply-to-deploy", name: "tailor-sdk apply → tailor-sdk deploy", description: - "Rewrite `tailor-sdk apply` invocations in package.json scripts, shell scripts, CI configs, and docs to the v2-recommended `tailor-sdk deploy` alias", + "Rewrite `tailor-sdk apply` invocations in package.json scripts, shell scripts, CI configs, and docs to the canonical v2 `tailor-sdk deploy` command", since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_1, scriptPath: "v2/apply-to-deploy/scripts/transform.js", - filePatterns: ["**/package.json", "**/*.{sh,bash,zsh,yml,yaml}", "**/*.md"], + filePatterns: [ + "**/package.json", + "**/*.{sh,bash,zsh,yml,yaml}", + "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + "**/*.md", + ], + examples: [ + { + lang: "sh", + before: "tailor-sdk apply --profile prod", + after: "tailor-sdk deploy --profile prod", + }, + ], }, { id: "v2/cli-rename", - name: "v2 CLI rename (single-word commands)", + name: "v2 CLI rename", description: - "Rewrite `tailor-sdk crash-report` invocations to the v2 single-word `tailor-sdk crashreport` form across package.json scripts, shell scripts, CI configs, and docs", + "Rewrite `tailor-sdk crash-report` to `tailor-sdk crashreport` and `--machineuser` to `--machine-user` across package.json scripts, shell scripts, CI configs, and docs", since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_1, scriptPath: "v2/cli-rename/scripts/transform.js", filePatterns: ["**/package.json", "**/*.{sh,bash,zsh,yml,yaml}", "**/*.md"], + legacyPatterns: ["tailor-sdk crash-report", "--machineuser"], + examples: [ + { + lang: "sh", + before: "tailor-sdk crash-report list\ntailor-sdk login --machineuser", + after: "tailor-sdk crashreport list\ntailor-sdk login --machine-user", + }, + ], + prompt: [ + "Apply the v2 CLI renames the codemod did not reach (only `tailor-sdk`-prefixed", + "invocations are rewritten): `tailor-sdk crash-report` -> `tailor-sdk crashreport`", + "and the `--machineuser` option -> `--machine-user`. Leave unrelated commands that", + "happen to use `--machineuser` alone.", + ].join("\n"), }, { - id: "v2/auth-invoker-unwrap", + id: "v2/env-var-rename", + name: "SDK environment variable rename", + description: + "Rewrite unambiguous removed SDK environment variable names to their v2 `TAILOR_*` names and flag generic names for manual review", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/env-var-rename/scripts/transform.js", + filePatterns: [ + "**/package.json", + "**/.env", + "**/.env.*", + "**/*.{env,sh,bash,zsh,yml,yaml,json,md}", + "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + ], + legacyPatterns: [ + "TAILOR_PLATFORM_SDK_CONFIG_PATH", + "TAILOR_PLATFORM_SDK_DTS_PATH", + "TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION", + "TAILOR_PLATFORM_SDK_BUILD_ONLY", + "TAILOR_SDK_OUTPUT_DIR", + "TAILOR_SDK_SKILLS_SOURCE", + "TAILOR_SDK_VERSION", + "PLATFORM_URL", + "PLATFORM_OAUTH2_CLIENT_ID", + "TAILOR_ENABLE_INLINE_SOURCEMAP", + "TAILOR_PLATFORM_QUERY_NEWLINE_ON_ENTER", + "LOG_LEVEL", + "TAILOR_TOKEN", + ], + sourceStringLegacyPatterns: ["PLATFORM_URL", "PLATFORM_OAUTH2_CLIENT_ID", "LOG_LEVEL"], + examples: [ + { + lang: "sh", + before: "TAILOR_PLATFORM_SDK_BUILD_ONLY=true tailor-sdk deploy", + after: "TAILOR_DEPLOY_BUILD_ONLY=true tailor-sdk deploy", + }, + { + before: "const token = process.env.TAILOR_TOKEN;", + after: "const token = process.env.TAILOR_PLATFORM_TOKEN;", + }, + ], + prompt: [ + "Review any remaining removed SDK environment variable names after the codemod", + "runs. The codemod intentionally leaves generic names such as `LOG_LEVEL`,", + "`PLATFORM_URL`, and `PLATFORM_OAUTH2_CLIENT_ID` for manual review because", + "they can configure non-SDK tools. Replace only actual SDK usages with their", + "v2 names. If a remaining match is an unrelated local identifier, fixture", + "label, or historical documentation that intentionally does not configure the", + "SDK, leave it unchanged.", + ].join("\n"), + }, + { + id: "v2/auth-invoker-call-unwrap", name: 'auth.invoker("name") → "name"', description: - 'Replace `auth.invoker("name")` calls with the bare `"name"` string and drop the `auth` import when no other reference remains. The `auth.invoker()` helper is deprecated in v2 because importing `auth` from `tailor.config.ts` into runtime files pulls Node-only modules into the bundle.', + 'Replace statically identified SDK `auth.invoker("name")` option values with the bare `"name"` string while preserving the `authInvoker` key for SDK versions before the option rename.', + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_1, + scriptPath: "v2/auth-invoker-call-unwrap/scripts/transform.js", + suspiciousPatterns: ["auth.invoker"], + reviewSupersededBy: ["v2/auth-invoker-unwrap"], + prompt: [ + "In Tailor SDK v2 the auth.invoker() helper is removed; an invoker is now the", + "machine user name passed directly as a string. The codemod already rewrote the", + 'statically identified SDK option form authInvoker: auth.invoker("name") to authInvoker: "name". These files still contain', + "auth.invoker(...) calls that need manual review.", + "", + "For each remaining auth.invoker() call:", + "1. Replace the whole call with only where the target option expects a", + " machine user name string; platform/runtime authInvoker payloads still expect", + " the object form.", + "2. Keep the authInvoker key when targeting SDK versions before the invoker", + " option rename; later v2 targets run a separate codemod for that key rename.", + "3. After removing every auth.invoker usage in a file, delete the now-unused auth", + " import (keeping it pulls Node-only config modules into runtime bundles); leave", + " the import if auth is still referenced elsewhere.", + "", + "Do not change behavior beyond the auth.invoker() removal.", + ].join("\n"), + examples: [ + { + before: 'createResolver({ authInvoker: auth.invoker("manager") });', + after: 'createResolver({ authInvoker: "manager" });', + }, + ], + }, + { + id: "v2/auth-invoker-unwrap", + name: 'auth.invoker("name") → invoker: "name"', + description: + 'Rename statically identified SDK `authInvoker` options to `invoker`, replace `auth.invoker("name")` there with the bare `"name"` string, and drop the `auth` import when no other reference remains. Ambiguous workflow `.trigger()` calls are left for manual review. The `auth.invoker()` helper is removed in v2 because importing `auth` from `tailor.config.ts` into runtime files pulls Node-only modules into the bundle.', since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_2, scriptPath: "v2/auth-invoker-unwrap/scripts/transform.js", - legacyPatterns: ["auth.invoker"], + suspiciousPatterns: [ + "auth.invoker", + "authInvoker:", + "authInvoker :", + "authInvoker?", + "{ authInvoker", + ", authInvoker", + "\n authInvoker", + "\n authInvoker", + "\n authInvoker", + '"authInvoker":', + '"authInvoker" :', + '"authInvoker"?', + "'authInvoker':", + "'authInvoker' :", + "'authInvoker'?", + ], + prompt: [ + "In Tailor SDK v2 the auth.invoker() helper is removed; an invoker is now the", + "machine user name passed directly as a string. The codemod already rewrote the", + 'statically identified SDK option form authInvoker: auth.invoker("name") to invoker: "name" and renamed supported authInvoker option keys. These files still contain', + "auth.invoker(...) calls or authInvoker keys that need manual review.", + "", + "For each remaining auth.invoker() call:", + "1. Replace the whole call with only where the target option expects a", + " machine user name string; platform/runtime authInvoker payloads still expect", + " the object form.", + "2. Rename remaining authInvoker option keys to invoker only for SDK resolver,", + " executor, workflow.trigger(), or startWorkflow() options. Keep platform/runtime", + " payload keys such as tailor.workflow.triggerWorkflow(..., { authInvoker: ... }).", + "3. After removing every auth.invoker usage in a file, delete the now-unused auth", + " import (keeping it pulls Node-only config modules into runtime bundles); leave", + " the import if auth is still referenced elsewhere.", + "", + "Do not change behavior beyond the SDK option rename and auth.invoker() removal.", + ].join("\n"), + examples: [ + { + before: 'createResolver({ invoker: auth.invoker("manager") });', + after: 'createResolver({ invoker: "manager" });', + }, + ], + }, + { + id: "v2/auth-connection-token-helper", + name: "auth.getConnectionToken() → runtime authconnection", + description: + "The deprecated `auth.getConnectionToken()` helper returned by `defineAuth()` is removed in v2. Use `authconnection.getConnectionToken(...)` from `@tailor-platform/sdk/runtime` in resolvers, executors, and workflows instead.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_2, + scriptPath: "v2/auth-connection-token-helper/scripts/transform.js", + filePatterns: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], + examples: [ + { + before: + 'import { auth } from "../tailor.config";\n\nconst token = await auth.getConnectionToken("google");', + after: + 'import { authconnection } from "@tailor-platform/sdk/runtime";\n\nconst token = await authconnection.getConnectionToken("google");', + }, + ], + prompt: [ + "In Tailor SDK v2 the auth.getConnectionToken() helper returned by defineAuth()", + "is removed. Runtime code should call authconnection.getConnectionToken(...) from", + "@tailor-platform/sdk/runtime instead of importing auth from tailor.config.ts.", + "", + "For each getConnectionToken usage where is a defineAuth() result", + "imported from tailor.config.ts:", + "1. Replace .getConnectionToken() calls with", + " authconnection.getConnectionToken().", + "2. Update non-call references, including .getConnectionToken,", + ' ["getConnectionToken"], and destructuring from , to', + " reference authconnection instead.", + '3. Add or reuse `import { authconnection } from "@tailor-platform/sdk/runtime"`.', + "4. Remove the auth import from tailor.config.ts only when no other auth reference", + " remains in the file.", + "", + "Leave usages unchanged when the receiver is already the runtime authconnection", + "wrapper or global tailor.authconnection.", + ].join("\n"), }, { id: "v2/tailordb-namespace", name: "Tailordb → tailordb (lowercase ambient namespace)", description: - "Rewrite references to the deprecated capital-cased `Tailordb` ambient namespace (`Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, `typeof Tailordb.Client`) to the new lowercase `tailordb.*` namespace re-published by the SDK in place of `@tailor-platform/function-types`.", + 'Rewrite references to the removed capital-cased `Tailordb` ambient namespace (`Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, `typeof Tailordb.Client`) to the lowercase `tailordb.*` namespace exposed by `@tailor-platform/sdk/runtime/globals`. Because v2 no longer activates ambient declarations automatically, each file that contains `tailordb.*` references after the rewrite must also add `import "@tailor-platform/sdk/runtime/globals"`.', since: "1.0.0", until: "2.0.0", + prereleaseUntil: V2_NEXT_1, scriptPath: "v2/tailordb-namespace/scripts/transform.js", legacyPatterns: ["Tailordb."], + examples: [ + { + before: 'const command: Tailordb.CommandType = "SELECT";', + after: + 'import "@tailor-platform/sdk/runtime/globals";\nconst command: tailordb.CommandType = "SELECT";', + }, + ], + prompt: [ + "The capital-cased Tailordb ambient namespace is removed in v2; use the lowercase", + "tailordb.* namespace from @tailor-platform/sdk/runtime/globals. The codemod rewrites", + "the known members (QueryResult, CommandType, Client). Rewrite any other remaining", + "Tailordb.* reference to its tailordb.* equivalent (and confirm the member still", + "exists on the lowercase namespace).", + 'Also add `import "@tailor-platform/sdk/runtime/globals"` at the top of each file', + "that contains any tailordb.* type reference — v2 no longer activates ambient", + "declarations automatically on SDK import.", + ].join("\n"), + }, + { + id: "v2/execute-script-arg", + name: "executeScript arg JSON.stringify → value", + description: + "Unwrap `JSON.stringify(...)` passed as the `executeScript` `arg` option. In v2 `arg` takes a JSON-serializable value and is serialized internally, so a pre-stringified argument double-encodes.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_2, + scriptPath: "v2/execute-script-arg/scripts/transform.js", + filePatterns: ["**/*.{ts,tsx,mts,cts,mjs,cjs,js}"], + suspiciousPatterns: [ + ["executeScript", "JSON.stringify", /\barg\s*[:=]|["']arg["']\s*(?::|\]\s*[:=])/], + ], + prompt: [ + "In Tailor SDK v2 the executeScript() arg option takes a JSON-serializable value", + "and is serialized internally, so a pre-stringified argument double-encodes. The", + "codemod already rewrote the direct form arg: JSON.stringify(X) to arg: X. Review", + "the executeScript calls in these files for cases it could not rewrite — where the", + "arg value is reached indirectly, for example:", + "- a variable holding a JSON.stringify(...) result (const s = JSON.stringify(x); ... arg: s)", + "- JSON.stringify(x, null, 2) or another multi-argument form", + "- an options object built or spread dynamically", + "", + "For each such call, pass the underlying value directly as arg (drop the", + "JSON.stringify wrapper) so executeScript serializes it once. Leave calls that", + "already pass a plain value unchanged.", + ].join("\n"), + examples: [ + { + before: "await executeScript({ ...opts, arg: JSON.stringify({ a: 1 }) });", + after: "await executeScript({ ...opts, arg: { a: 1 } });", + }, + ], + }, + { + id: "v2/wait-point-rename", + name: "defineWaitPoint/defineWaitPoints → createWaitPoint/createWaitPoints", + description: + "Rename `defineWaitPoint` and `defineWaitPoints` to `createWaitPoint` and `createWaitPoints`. The functions create runtime instances with `.wait()` / `.resolve()` methods, so the `create*` prefix is used consistently.", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/wait-point-rename/scripts/transform.js", + legacyPatterns: ["defineWaitPoint", "defineWaitPoints"], + examples: [ + { + before: + 'import { defineWaitPoints } from "@tailor-platform/sdk";\n\nexport const { approval } = defineWaitPoints((define) => ({\n approval: define<{ message: string }, { approved: boolean }>(),\n}));', + after: + 'import { createWaitPoints } from "@tailor-platform/sdk";\n\nexport const { approval } = createWaitPoints((define) => ({\n approval: define<{ message: string }, { approved: boolean }>(),\n}));', + }, + ], + }, + { + id: "v2/open-download-stream", + name: "openDownloadStream → downloadStream", + description: + "The deprecated `openDownloadStream` file-streaming API is removed in v2. Use `downloadStream` for streamed file downloads. The generated file utilities now emit `downloadFileStream` (which calls `downloadStream` and returns `FileDownloadStreamResponse`) instead of the removed `openFileDownloadStream` helper.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_2, + // No scriptPath: this is a codemod-less ("manual") migration. + filePatterns: ["**/*.{ts,tsx,mts,cts,mjs,cjs,js}"], + suspiciousPatterns: ["openDownloadStream", "openFileDownloadStream"], + examples: [ + { + before: "const res = await openDownloadStream(namespace, typeName, fieldName, recordId);", + after: "const res = await downloadStream(namespace, typeName, fieldName, recordId);", + }, + ], + prompt: [ + "The openDownloadStream file-streaming API is removed in v2. Replace every call to", + "openDownloadStream with downloadStream (same arguments). If you used the generated", + "openFileDownloadStream helper, switch to downloadFileStream, which calls", + "downloadStream and returns FileDownloadStreamResponse.", + ].join("\n"), + }, + { + id: "v2/runtime-globals-opt-in", + name: "Ambient runtime globals are opt-in", + description: + 'Importing `@tailor-platform/sdk` no longer activates the ambient `tailor.*` / `tailordb.*` global declarations. The codemod rewrites simple direct `new tailor.idp.Client(...)` calls to the typed `idp.Client` wrapper from `@tailor-platform/sdk/runtime`; broader runtime global usage remains review-only. Only if you relied on the ambient globals directly, add `import "@tailor-platform/sdk/runtime/globals"`. (The capital-cased `Tailordb.*` namespace is removed separately — see the `Tailordb → tailordb` codemod.)', + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_1, + scriptPath: "v2/runtime-globals-opt-in/scripts/transform.js", + filePatterns: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], + suspiciousPatterns: [ + "tailor.context", + "tailor.iconv", + "tailor.idp", + "tailor.secretmanager", + "tailor.authconnection", + "tailor.workflow", + "tailor[", + "tailordb.Client", + "tailordb.CommandType", + "tailordb.QueryResult", + "tailordb.file", + "tailordb[", + "TailorDBFileError", + "TailorErrorItem", + "TailorErrorMessage", + "TailorErrors", + ], + sourceStringSuspiciousPatterns: [ + "new tailor.idp.Client", + /[=(:,[]\s*tailor\.idp\.Client\b/, + /(?:(?:=>|[=(:,<{]|\[)\s*|\b(?:return|await|typeof)\s+)tailor\.(?:authconnection|context|iconv|idp|secretmanager|workflow)(?:\.[A-Za-z_$][\w$]*)?\b/, + /\btailor\.(?:authconnection|context|iconv|idp|secretmanager|workflow)\.[A-Za-z_$][\w$]*\s*\(/, + "tailor[", + /\btailordb\.file\.[A-Za-z_$][\w$]*\s*\(/, + /(?:(?:=>|[=(:,<{]|\[)\s*|\b(?:return|await|typeof)\s+)tailordb\.file\b/, + /(?:\bnew\s+|(?:=>|[=(:,<{]|\[)\s*|\b(?:return|await|typeof)\s+)tailordb\.(?:Client|CommandType|QueryResult)\b/, + /<\s*tailordb\.(?:Client|CommandType|QueryResult)\b/, + "tailordb[", + /(?:\bnew\s+|\bthrow\s+|\binstanceof\s+)Tailor(?:DBFileError|Errors|ErrorMessage)\b/, + /(?:[:=<]\s*|\bas\s+)Tailor(?:DBFileError|Errors|ErrorMessage|ErrorItem)\b/, + /[:<]\s*TailorErrorItem\b/, + ], + examples: [ + { + caption: + "Preferred: switch to the typed wrappers from `@tailor-platform/sdk/runtime` and drop the ambient globals:", + before: "const client = new tailor.idp.Client();", + after: + 'import { idp } from "@tailor-platform/sdk/runtime";\nconst client = new idp.Client({ namespace: "my-namespace" });', + }, + { + caption: + "Fallback: only if you must keep referencing the bare `tailor.*` names, opt into the global declarations:", + before: "const client = new tailor.idp.Client();", + after: + 'import "@tailor-platform/sdk/runtime/globals";\nconst client = new tailor.idp.Client();', + }, + ], + prompt: [ + "The v2 SDK no longer enables ambient Tailor runtime globals from", + "`@tailor-platform/sdk`. For each flagged file that uses `tailor.*`,", + "`tailordb.*`, or Tailor runtime error globals, prefer migrating to the", + "typed wrappers from `@tailor-platform/sdk/runtime`. The codemod already", + "rewrites direct `new tailor.idp.Client(...)` calls to `new idp.Client(...)`", + "when the file has no conflicting `tailor` or `idp` binding. For any remaining", + "`tailor.idp.Client` references, either resolve the binding collision and use", + "`idp.Client`, or keep the ambient global deliberately.", + "", + "Only when the file must keep referencing the bare `tailor.*` names directly,", + "opt into the global declarations instead by adding one of these:", + '- per-file: `import "@tailor-platform/sdk/runtime/globals";`', + '- project-wide: `"types": ["@tailor-platform/sdk/runtime/globals"]` in', + " the relevant tsconfig compilerOptions", + "", + "Leave files unchanged when the matching name is local, imported from another", + "module, or appears only in comments or prose strings. Embedded code strings", + "that use runtime globals are review-only findings; do not insert imports inside", + "string literals.", + ].join("\n"), + }, + { + id: "v2/workflow-trigger-dispatch", + name: "Workflow .trigger() and trigger tests", + description: + "Workflow job `.trigger()` now aligns with the platform runtime: it returns the job result directly instead of a Promise wrapper, and tests no longer run job bodies locally. Mock trigger responses with `mockWorkflow()` (`setJobHandler` / `enqueueResult`, assert via `triggeredJobs`), or use `runWorkflowLocally()` for a full-chain local run.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_1, + suspiciousPatterns: [".trigger("], + examples: [ + { + caption: "Tests must mock the workflow runtime instead of running bodies locally:", + before: + 'const result = await orderJob.trigger({ id });\nexpect(result.status).toBe("done");', + after: + 'using wf = mockWorkflow();\nwf.setJobHandler((jobName) => (jobName === "order-job" ? { status: "done" } : null));\nconst result = await orderJob.trigger({ id });\nexpect(result.status).toBe("done");', + }, + ], + prompt: [ + "Workflow job .trigger() now uses the platform workflow runtime instead of running", + "the job body locally. In tests, acquire `using wf = mockWorkflow()` and provide", + "trigger responses (setJobHandler / enqueueResult), or use runWorkflowLocally() for a", + "full-chain local run; an unmocked trigger now throws. Outside tests, treat the", + "trigger result as the job output directly (no Promise wrapper to unwrap).", + ].join("\n"), + }, + { + id: "v2/strict-scalar-strings", + name: "Strict scalar string types for UUID/date/datetime/time/decimal fields", + description: + "Tailor field outputs infer strict string shapes instead of plain `string`: UUID fields are `UUIDString`, date fields `DateString`, datetime fields `DateTimeString | Date`, time fields `TimeString`, and decimal fields `DecimalString` (all exported from `@tailor-platform/sdk`). Generated Kysely types, migration DB helper types, auth `tailor.d.ts` attributes, and runtime principal / IdP user ids use the same aliases, and the Kysely `Timestamp` columns now select as `Date | DateTimeString` instead of `Date`. String values that already match a shape keep typechecking unchanged; plain `string` values must be narrowed with the new `is*String` / `parse*String` / `assert*String` helpers or have their source types tightened.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_3, + // No scriptPath and no scoping patterns: the migration is type-driven, so + // the prompt is surfaced as project-wide guidance instead of per-file. + examples: [ + { + caption: + "Tighten the source type — or narrow at an untyped boundary with the scalar helpers — instead of passing plain `string` to a strict scalar API:", + before: + 'async function loadCustomer(customerId: string) {\n return getDB("tailordb")\n .selectFrom("Customer")\n .where("id", "=", customerId)\n .selectAll()\n .executeTakeFirstOrThrow();\n}', + after: + 'import { type UUIDString, parseUUIDString } from "@tailor-platform/sdk";\n\nasync function loadCustomer(customerId: UUIDString) {\n return getDB("tailordb")\n .selectFrom("Customer")\n .where("id", "=", customerId)\n .selectAll()\n .executeTakeFirstOrThrow();\n}\n\n// At an untyped boundary:\nawait loadCustomer(parseUUIDString(value, "customerId"));', + }, + { + caption: + "Kysely `Timestamp` columns now select as `Date | DateTimeString`, so guard `Date` methods:", + before: "return { createdAt: customer.createdAt.toISOString() };", + after: + "return {\n createdAt:\n customer.createdAt instanceof Date\n ? customer.createdAt.toISOString()\n : customer.createdAt,\n};", + }, + { + caption: + "Record and principal ids are `UUIDString`, so test fixtures need UUID-shaped literals:", + before: 'const payload = { newRecord: { id: "user-1" } };', + after: 'const payload = { newRecord: { id: "11111111-1111-4111-8111-111111111111" } };', + }, + ], + prompt: [ + "The v2 SDK types UUID, date, datetime, time, and decimal Tailor field values as", + "strict string shapes (UUIDString, DateString, DateTimeString, TimeString,", + "DecimalString from @tailor-platform/sdk) instead of plain string. Generated", + "Kysely types, auth attributes, and runtime principal / IdP user ids use the same", + "aliases, and Kysely Timestamp columns now select as Date | DateTimeString.", + "Regenerate the generated types (tailor generate), then typecheck the project and", + "fix the remaining errors:", + "- String literals that already match a shape typecheck unchanged.", + "- For string or unknown values, tighten the source type (e.g. declare the", + " parameter as UUIDString, use t.uuid() for resolver inputs that carry record", + " ids), or narrow with the isUUIDString / parseUUIDString / assertUUIDString", + " helper families (same variants exist for date, datetime, time, and decimal).", + "- Guard Date methods on selected timestamp columns:", + " value instanceof Date ? value.toISOString() : value.", + '- Give test fixtures UUID-shaped ids (e.g. "11111111-1111-4111-8111-111111111111").', + ' The mockIdp default user id changed from "mock-id" to', + ' "123e4567-e89b-12d3-a456-426614174000".', + "- Existing migration db.ts snapshots keep compiling; leave them unchanged. New", + " migrations are generated with the strict shapes automatically.", + ].join("\n"), + }, + { + id: "v2/cli-token-keyring-storage", + name: "CLI tokens stored in the OS keyring", + description: + "CLI login tokens are stored in the OS keyring by default when available, falling back to the platform config file when it is not. No source change is required; re-login if you need tokens moved into the keyring.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_2, + notice: true, + }, + { + id: "v2/cli-users-by-subject", + name: "CLI users keyed by subject ID", + description: + "The CLI stores human users by their stable subject ID instead of email (email is kept for display). Legacy email-keyed entries are migrated automatically on the next login or token refresh. No source change is required.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_1, + notice: true, + }, + { + id: "v2/function-logs-content-hash", + name: "function logs require a content hash for source mapping", + description: + "`tailor function logs` maps stack traces against the function bundle only when the execution recorded a `contentHash`. Executions without one now show raw stack traces instead of mapped frames. No source change is required.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_1, + notice: true, + }, + { + id: "v2/rename-bin", + name: "tailor-sdk binary → tailor", + description: + "Rename the CLI binary from `tailor-sdk` to `tailor` in package.json scripts, shell scripts, CI workflows, source files, generated declaration comments, and documentation. Does not rename `.tailor-sdk` directory paths or the `create-tailor-sdk` scaffolding package. Note: v2 also changes the default generated output directory from `.tailor-sdk/` to `.tailor/` and the setup lock file from `.github/tailor-sdk.lock` to `.github/tailor.lock`. Run `mv .tailor-sdk .tailor` to migrate the generated output directory (preserves auth connection state and other local files). Run `git mv .github/tailor-sdk.lock .github/tailor.lock` if the old lock file exists; without it `tailor setup check` will treat all managed workflows as missing. Exact ignore-file entries for `.tailor-sdk/` are handled by the generated-output ignore codemod.", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/rename-bin/scripts/transform.js", + filePatterns: [ + "**/package.json", + "**/*.{sh,bash,zsh,yml,yaml}", + "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + "**/*.md", + ], + legacyPatterns: ["tailor-sdk"], + sourceStringLegacyPatterns: [ + RENAME_BIN_SOURCE_LEGACY_PATTERN, + RENAME_BIN_QUOTED_SOURCE_LEGACY_PATTERN, + RENAME_BIN_QUOTED_LEGACY_COMMAND_PATTERN, + ], + sourceTextLegacyPatterns: [ + RENAME_BIN_SOURCE_LEGACY_PATTERN, + RENAME_BIN_QUOTED_SOURCE_LEGACY_PATTERN, + RENAME_BIN_QUOTED_LEGACY_COMMAND_PATTERN, + ], + examples: [ + { + lang: "sh", + before: "tailor-sdk deploy\nnpx tailor-sdk@latest login", + after: "tailor deploy\nnpx @tailor-platform/sdk@latest login", + }, + ], + prompt: [ + "Rename any remaining `tailor-sdk` binary invocations to `tailor`. Only rewrite", + "the binary name — leave `.tailor-sdk` directory paths and `create-tailor-sdk`", + "package references unchanged.", + ].join("\n"), + }, + { + id: "v2/tailor-output-ignore-dir", + name: ".tailor-sdk ignore entries → .tailor", + description: + "Rewrite exact ignore-file entries for the v1 generated output directory from `.tailor-sdk` to the v2 `.tailor` directory. Other `.tailor-sdk` paths and prose are left unchanged.", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/tailor-output-ignore-dir/scripts/transform.js", + filePatterns: [ + "**/.gitignore", + "**/.npmignore", + "**/.dockerignore", + "**/gitignore", + "**/npmignore", + "**/dockerignore", + "**/_gitignore", + "**/_npmignore", + "**/_dockerignore", + "**/__dot__gitignore", + "**/__dot__npmignore", + "**/__dot__dockerignore", + "**/*.gitignore", + "**/*.npmignore", + "**/*.dockerignore", + ], + examples: [ + { + lang: "gitignore", + before: ".tailor-sdk/", + after: ".tailor/", + }, + ], + }, + { + id: "v2/tailordb-validate-simplify", + name: "ValidateFn simplification and type-level validate", + description: + "Field-level `ValidateFn` is simplified from `(args: { value, data, invoker }) => boolean` to `(args: { value }) => string | void` — the function now returns the error message directly instead of a separate `[fn, message]` tuple. The `ValidateConfig` tuple form and `Validators` record syntax on `db.type().validate()` are removed. Type-level validation uses `db.type().validate((args, issues) => void)` with `{ newRecord, oldRecord, invoker }` args and an `issues(field, message)` callback for cross-field rules.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_3, + suspiciousPatterns: ["ValidateConfig", "Validators<", "ValidatorsBase"], + examples: [ + { + caption: + "Field-level validate: return an error message string instead of a boolean (tuple form removed):", + before: + '.validate(\n [({ value }) => value.length > 5, "Name must be longer than 5 characters"],\n)', + after: + '.validate(({ value }) =>\n value.length <= 5 ? "Name must be longer than 5 characters" : undefined,\n)', + }, + { + caption: + "Type-level validate: per-field record syntax replaced by a single function with `issues()` callback:", + before: + '.validate({\n name: [({ value }) => value.length > 5, "Name must be longer than 5"],\n})', + after: + '.validate(({ newRecord }, issues) => {\n if (newRecord.name && newRecord.name.length <= 5) {\n issues("name", "Name must be longer than 5");\n }\n})', + }, + ], + prompt: [ + "The v2 SDK simplifies field validation and introduces type-level validation.", + "", + "Field-level `.validate()` changes:", + "- Signature: `(args: { value, data, invoker }) => boolean` → `(args: { value }) => string | void`", + "- The function now returns the error message string directly (or undefined/void to pass)", + " instead of returning a boolean with the message in a separate tuple.", + "- The `[fn, errorMessage]` tuple form (`ValidateConfig`) is removed.", + "- `data` and `invoker` are no longer available in field-level validators.", + " Use type-level `.validate()` for cross-field or invoker-dependent rules.", + "", + "Type-level `.validate()` on `db.type()` changes:", + "- Old: `.validate({ fieldName: fn | [fn, msg] | fn[] })` (per-field record, `Validators` type)", + "- New: `.validate((args, issues) => void)` (single function, `TypeValidateFn` type)", + "- Args: `{ newRecord, oldRecord, invoker }` — `newRecord` is the record after hooks run", + "- Call `issues(field, message)` to report validation errors; `field` supports dotted paths", + "- Move per-field validators that need `data`/`invoker` to the type-level function", + "", + "For each remaining `ValidateConfig`, `Validators<`, or old-signature `.validate()` usage:", + "1. Rewrite field-level validators to return the error string directly", + "2. Move cross-field / invoker-dependent validators to the type-level function", + "3. Remove unused `ValidateConfig` / `Validators` type imports", + ].join("\n"), + }, + { + id: "v2/tailordb-hook-redesign", + name: "TailorDB hook redesign: field-level args and type-level hooks", + description: + "Field-level `HookFn` args change from `{ value, data, invoker }` to `{ value, oldValue, invoker, now }` — `data` (the full record) is replaced by `oldValue` (the previous field value) and `now` (operation timestamp). Type-level hooks on `db.type().hooks()` change from per-field mapping `{ fieldName: { create, update } }` (`Hooks`) to a single `{ create, update }` object (`TypeHook`) where each function takes `{ input, oldRecord, invoker, now }` and returns partial field overrides.", + since: "1.0.0", + until: "2.0.0", + prereleaseUntil: V2_NEXT_3, + suspiciousPatterns: ["Hooks<", "HookFn<"], + examples: [ + { + caption: + "Field-level hooks: `data` replaced by `oldValue` and `now`; use `now` instead of `new Date()`:", + before: + "db.datetime().hooks({\n create: ({ value }) => value ?? new Date(),\n update: () => new Date(),\n})", + after: + "db.datetime().hooks({\n create: ({ value, now }) => value ?? now,\n update: ({ now }) => now,\n})", + }, + { + caption: "Type-level hooks: per-field mapping replaced by single create/update functions:", + before: + ".hooks({\n fullAddress: {\n create: ({ data }) => `${data.postalCode} ${data.address}`,\n update: ({ data }) => `${data.postalCode} ${data.address}`,\n },\n})", + after: + ".hooks({\n create: ({ input }) => ({\n fullAddress: `${input.postalCode} ${input.address}`,\n }),\n update: ({ input }) => ({\n fullAddress: `${input.postalCode} ${input.address}`,\n }),\n})", + }, + ], + prompt: [ + "The v2 SDK redesigns TailorDB hooks at both field and type levels.", + "", + "Field-level `.hooks()` on individual fields:", + "- Args: `{ value, data, invoker }` → `{ value, oldValue, invoker, now }`", + "- `data` (full record) is removed; use `oldValue` (previous field value) instead", + "- `now` provides the operation timestamp — use `now` instead of `new Date()`", + "- If a field-level hook needs the full record (other fields), move it to a type-level hook", + "", + "Type-level `.hooks()` on `db.type()`:", + "- Old: `.hooks({ fieldName: { create: fn, update: fn } })` (per-field mapping, `Hooks` type)", + "- New: `.hooks({ create: fn, update: fn })` (single object, `TypeHook` type)", + "- Each function: `({ input, oldRecord, invoker, now }) => ({ fieldName: value, ... })`", + "- `input` is the pre-hook input (may have nullish values for optional/defaulted fields)", + "- `oldRecord` is null on create, the previous record on update", + "- Return an object with only the fields to override; unmentioned fields are unchanged", + "", + "Migration steps for each `.hooks()` call on a `db.type()`:", + "1. If the old per-field hooks only use `value`/`invoker` and don't reference `data`,", + " convert them to field-level hooks with the new args (`oldValue`, `now`)", + "2. If the old hooks reference `data` (cross-field access), convert to a type-level hook", + " using `input`/`oldRecord`", + "3. Remove unused `Hooks` / `HookFn<>` type imports", + ].join("\n"), + }, + { + id: "v2/node-minimum-22-15-0", + name: "Node.js minimum version raised to 22.15.0", + description: + "v2 requires Node.js **22.15.0** or later. This is the first version that includes `module.registerHooks()`, which the SDK uses to register its TypeScript loader hook synchronously in the main thread. No source change is required; ensure your environment runs Node.js 22.15.0+.", + since: "1.0.0", + until: "2.0.0", + notice: true, }, ]; @@ -107,9 +991,71 @@ export function resolveCodemodScript(scriptPath: string): string { return path.resolve(CODEMODS_ROOT, scriptPath); } +function reachesCodemodBoundary(toVersion: string, codemod: CodemodPackage): boolean { + if (gte(toVersion, codemod.until)) { + return true; + } + if (codemod.prereleaseUntil === undefined || !gte(toVersion, codemod.prereleaseUntil)) { + return false; + } + + const target = parse(toVersion)!; + const boundary = parse(codemod.until)!; + + return ( + target.prerelease.length > 0 && + target.major === boundary.major && + target.minor === boundary.minor && + target.patch === boundary.patch + ); +} + +function effectiveCodemodBoundary(codemod: CodemodPackage): string { + return codemod.prereleaseUntil ?? codemod.until; +} + +function assertCodemodBoundaries(codemods: CodemodPackage[]): void { + for (const codemod of codemods) { + const boundary = parse(codemod.until); + if (boundary === null) { + throw new Error( + `Codemod ${codemod.id} until must be a valid semver version: ${codemod.until}`, + ); + } + if (boundary.prerelease.length > 0) { + throw new Error(`Codemod ${codemod.id} until must be a stable version: ${codemod.until}`); + } + if (codemod.prereleaseUntil === undefined) { + continue; + } + + const prereleaseBoundary = parse(codemod.prereleaseUntil); + if (prereleaseBoundary === null) { + throw new Error( + `Codemod ${codemod.id} prereleaseUntil must be a valid semver version: ${codemod.prereleaseUntil}`, + ); + } + if (prereleaseBoundary.prerelease.length === 0) { + throw new Error( + `Codemod ${codemod.id} prereleaseUntil must be a prerelease version: ${codemod.prereleaseUntil}`, + ); + } + if ( + prereleaseBoundary.major !== boundary.major || + prereleaseBoundary.minor !== boundary.minor || + prereleaseBoundary.patch !== boundary.patch + ) { + throw new Error( + `Codemod ${codemod.id} prereleaseUntil must target the same version as until: ${codemod.prereleaseUntil}`, + ); + } + } +} + /** * Get codemod packages applicable for a version range. - * A codemod applies when: since <= fromVersion < until <= toVersion + * A codemod applies when: since <= fromVersion < boundary <= toVersion. + * A target prerelease reaches `until` only when the codemod declares `prereleaseUntil`. * @param fromVersion - Current SDK version (semver) * @param toVersion - Target SDK version (semver) * @returns Array of applicable codemod packages in registration order @@ -121,11 +1067,12 @@ export function getApplicableCodemods(fromVersion: string, toVersion: string): C if (!valid(toVersion)) { throw new Error(`Invalid toVersion: ${toVersion}`); } + assertCodemodBoundaries(allCodemods); return allCodemods.filter( (codemod) => gte(fromVersion, codemod.since) && - lt(fromVersion, codemod.until) && - gte(toVersion, codemod.until), + lt(fromVersion, effectiveCodemodBoundary(codemod)) && + reachesCodemodBoundary(toVersion, codemod), ); } diff --git a/packages/sdk-codemod/src/runner-metadata.test.ts b/packages/sdk-codemod/src/runner-metadata.test.ts new file mode 100644 index 0000000000..83df0d32c3 --- /dev/null +++ b/packages/sdk-codemod/src/runner-metadata.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; +import { createRunnerMetadata } from "./runner-metadata"; + +const packageInfo = { + packageName: "@tailor-platform/sdk-codemod", + packageVersion: "0.3.0-next.2", +}; + +describe("createRunnerMetadata", () => { + test("includes the exact package identity", () => { + const metadata = createRunnerMetadata({ + ...packageInfo, + packageRoot: "/repo/packages/sdk-codemod", + readGit: () => undefined, + realpath: (value) => value, + }); + + expect(metadata).toEqual({ + packageName: "@tailor-platform/sdk-codemod", + packageVersion: "0.3.0-next.2", + }); + }); + + test("includes the branch commit and local build command for a source checkout", () => { + const metadata = createRunnerMetadata({ + ...packageInfo, + packageRoot: "/repo/packages/sdk-codemod", + readGit: (_cwd, args) => { + if (args.join(" ") === "rev-parse --show-toplevel") return "/repo"; + if (args.join(" ") === "rev-parse --verify HEAD") return "abc123"; + return undefined; + }, + realpath: (value) => value, + }); + + expect(metadata).toEqual({ + packageName: "@tailor-platform/sdk-codemod", + packageVersion: "0.3.0-next.2", + gitCommit: "abc123", + localBuildCommand: "pnpm --dir packages/sdk-codemod build", + }); + }); + + test("does not report the consuming project's commit for an installed package", () => { + const metadata = createRunnerMetadata({ + ...packageInfo, + packageRoot: "/project/node_modules/@tailor-platform/sdk-codemod", + readGit: (_cwd, args) => { + if (args.join(" ") === "rev-parse --show-toplevel") return "/project"; + if (args.join(" ") === "rev-parse --verify HEAD") return "project-commit"; + return undefined; + }, + realpath: (value) => value, + }); + + expect(metadata).toEqual({ + packageName: "@tailor-platform/sdk-codemod", + packageVersion: "0.3.0-next.2", + }); + }); +}); diff --git a/packages/sdk-codemod/src/runner-metadata.ts b/packages/sdk-codemod/src/runner-metadata.ts new file mode 100644 index 0000000000..d77901ccb4 --- /dev/null +++ b/packages/sdk-codemod/src/runner-metadata.ts @@ -0,0 +1,68 @@ +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import * as path from "pathe"; + +export interface RunnerMetadata { + packageName: string; + packageVersion: string; + gitCommit?: string; + localBuildCommand?: string; +} + +interface CreateRunnerMetadataOptions { + packageName: string; + packageVersion: string; + packageRoot: string; + readGit?: (cwd: string, args: string[]) => string | undefined; + realpath?: (value: string) => string; +} + +const SOURCE_PACKAGE_PATH = "packages/sdk-codemod"; +const LOCAL_BUILD_COMMAND = "pnpm --dir packages/sdk-codemod build"; + +export function createRunnerMetadata({ + packageName, + packageVersion, + packageRoot, + readGit = readGitOutput, + realpath = safeRealpath, +}: CreateRunnerMetadataOptions): RunnerMetadata { + const metadata: RunnerMetadata = { packageName, packageVersion }; + const gitRoot = readGit(packageRoot, ["rev-parse", "--show-toplevel"]); + if (!gitRoot) return metadata; + + const packagePathFromRoot = path + .normalize(path.relative(realpath(gitRoot), realpath(packageRoot))) + .replaceAll("\\", "/"); + + if (packagePathFromRoot !== SOURCE_PACKAGE_PATH) return metadata; + + const gitCommit = readGit(packageRoot, ["rev-parse", "--verify", "HEAD"]); + if (!gitCommit) return metadata; + + return { + ...metadata, + gitCommit, + localBuildCommand: LOCAL_BUILD_COMMAND, + }; +} + +function readGitOutput(cwd: string, args: string[]): string | undefined { + try { + const output = execFileSync("git", ["-C", cwd, ...args], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return output || undefined; + } catch { + return undefined; + } +} + +function safeRealpath(value: string): string { + try { + return realpathSync(value); + } catch { + return path.resolve(value); + } +} diff --git a/packages/sdk-codemod/src/runner.test.ts b/packages/sdk-codemod/src/runner.test.ts index 9e658cf583..7e426414f9 100644 --- a/packages/sdk-codemod/src/runner.test.ts +++ b/packages/sdk-codemod/src/runner.test.ts @@ -2,9 +2,20 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "pathe"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { allCodemods } from "./registry"; import { runCodemods } from "./runner"; import type { CodemodPackage } from "./types"; +type TestCodemodExtra = Pick< + CodemodPackage, + | "sourceStringLegacyPatterns" + | "sourceTextLegacyPatterns" + | "suspiciousPatterns" + | "sourceStringSuspiciousPatterns" + | "prompt" + | "reviewSupersededBy" +>; + /** * Create a temporary directory with a test file for codemod testing. * @param fileName - Name of the test file @@ -21,7 +32,13 @@ async function createTestProject( return { tmpDir, filePath }; } -function makeCodemod(id: string, scriptPath: string, filePatterns?: string[]): CodemodPackage { +function makeCodemod( + id: string, + scriptPath?: string, + filePatterns?: string[], + legacyPatterns?: Array, + extra?: TestCodemodExtra, +): CodemodPackage { return { id, name: id, @@ -30,6 +47,8 @@ function makeCodemod(id: string, scriptPath: string, filePatterns?: string[]): C until: "2.0.0", scriptPath, filePatterns, + legacyPatterns, + ...extra, }; } @@ -164,6 +183,7 @@ describe("runCodemods", () => { describe("filePatterns filtering", () => { const transformPath = path.join(os.tmpdir(), "transform-upper.ts"); + const throwingTransformPath = path.join(os.tmpdir(), "transform-throw.ts"); beforeEach(async () => { await fs.promises.writeFile( @@ -173,10 +193,18 @@ describe("runCodemods", () => { }`, "utf-8", ); + await fs.promises.writeFile( + throwingTransformPath, + `export default function transform() { + throw new Error("nonmatching transform should not run"); + }`, + "utf-8", + ); }); afterEach(async () => { await fs.promises.rm(transformPath, { force: true }); + await fs.promises.rm(throwingTransformPath, { force: true }); }); test("should only apply transform to files matching filePatterns", async () => { @@ -184,6 +212,7 @@ describe("runCodemods", () => { tmpDir = dir; await fs.promises.writeFile(path.join(dir, "config.ts"), "hello", "utf-8"); await fs.promises.writeFile(path.join(dir, "data.json"), "world", "utf-8"); + using readFileSpy = vi.spyOn(fs.promises, "readFile"); const result = await runCodemods( [ @@ -199,6 +228,7 @@ describe("runCodemods", () => { // Only JSON file should be modified expect(result.filesModified).toHaveLength(1); expect(result.filesModified[0]).toContain("data.json"); + expect(readFileSpy).not.toHaveBeenCalledWith(path.join(dir, "config.ts"), "utf-8"); // TS file should be unchanged const tsContent = await fs.promises.readFile(path.join(dir, "config.ts"), "utf-8"); @@ -208,5 +238,1428 @@ describe("runCodemods", () => { const jsonContent = await fs.promises.readFile(path.join(dir, "data.json"), "utf-8"); expect(jsonContent).toBe("WORLD"); }); + + test("should not run transforms whose filePatterns do not match a matched file", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-pattern-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "data.json"), "world", "utf-8"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/upper", transformPath, ["**/*.json"]), + scriptPath: transformPath, + }, + { + codemod: makeCodemod("test/throw", throwingTransformPath, ["**/*.ts"]), + scriptPath: throwingTransformPath, + }, + ], + dir, + false, + ); + + expect(result.filesModified).toEqual([path.join(dir, "data.json")]); + await expect(fs.promises.readFile(path.join(dir, "data.json"), "utf-8")).resolves.toBe( + "WORLD", + ); + }); + + test("should apply transforms to matching files under dot directories", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-dot-test-")); + tmpDir = dir; + const workflowPath = path.join(dir, ".github/workflows/test.yml"); + await fs.promises.mkdir(path.dirname(workflowPath), { recursive: true }); + await fs.promises.writeFile(workflowPath, "hello", "utf-8"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/upper", transformPath, ["**/*.yml"]), + scriptPath: transformPath, + }, + ], + dir, + false, + ); + + expect(result.filesModified).toEqual([workflowPath]); + await expect(fs.promises.readFile(workflowPath, "utf-8")).resolves.toBe("HELLO"); + }); + + test("should skip unapproved tool dot directories", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-dot-test-")); + tmpDir = dir; + const workflowPath = path.join(dir, ".github/workflows/test.yml"); + const agentPackagePath = path.join(dir, ".agent/worktrees/demo/package.json"); + const nextYamlPath = path.join(dir, ".next/cache/workflow.yml"); + await fs.promises.mkdir(path.dirname(workflowPath), { recursive: true }); + await fs.promises.mkdir(path.dirname(agentPackagePath), { recursive: true }); + await fs.promises.mkdir(path.dirname(nextYamlPath), { recursive: true }); + await fs.promises.writeFile(workflowPath, "hello", "utf-8"); + await fs.promises.writeFile( + agentPackagePath, + '{"scripts":{"deploy":"tailor apply"}}', + "utf-8", + ); + await fs.promises.writeFile(nextYamlPath, "hello", "utf-8"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/upper", transformPath, ["**/*.yml", "**/package.json"]), + scriptPath: transformPath, + }, + ], + dir, + false, + ); + + expect(result.filesModified).toEqual([workflowPath]); + await expect(fs.promises.readFile(workflowPath, "utf-8")).resolves.toBe("HELLO"); + await expect(fs.promises.readFile(agentPackagePath, "utf-8")).resolves.toBe( + '{"scripts":{"deploy":"tailor apply"}}', + ); + await expect(fs.promises.readFile(nextYamlPath, "utf-8")).resolves.toBe("hello"); + }); + }); + + describe("legacy pattern warnings", () => { + const partialTransformPath = path.join(os.tmpdir(), "transform-partial.ts"); + + beforeEach(async () => { + await fs.promises.writeFile( + partialTransformPath, + `export default function transform(source) { + return source.replaceAll("tailor crash-report", "tailor crashreport"); + }`, + "utf-8", + ); + }); + + afterEach(async () => { + await fs.promises.rm(partialTransformPath, { force: true }); + }); + + test("warns when legacy patterns remain after a partial migration", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "README.md"), + "Run `tailor crash-report list`.\nRun tailor login --machineuser.\n", + "utf-8", + ); + + using _stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + + const result = await runCodemods( + [ + { + codemod: makeCodemod( + "test/partial", + partialTransformPath, + ["**/*.md"], + ["tailor crash-report", "--machineuser"], + ), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.filesModified).toEqual([path.join(dir, "README.md")]); + expect(result.warnings).toEqual([ + "README.md: contains --machineuser but was not migrated automatically (rule: test/partial). Manual migration may be needed.", + ]); + }); + + test("ignores source comments, strings, and identifier substrings for legacy warnings", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "createContext.ts"), + [ + "// Matches SDK's unauthenticatedTailorUser.id", + 'const note = "TailorUser";', + "const unauthenticatedTailorUserId = caller?.id;", + ].join("\n"), + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod( + "test/principal", + partialTransformPath, + ["**/*.ts"], + ["TailorUser", "unauthenticatedTailorUser"], + ), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("ignores JSX text for legacy warnings in JavaScript files", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "docs.js"), + "export const docs =

package tailor-sdk is installed

;", + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", undefined, ["**/*.js"], ["tailor-sdk"]), + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("keeps legacy warnings for source identifiers", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "resolver.ts"), + [ + 'import type { TailorUser } from "@tailor-platform/sdk";', + "const fallback = unauthenticatedTailorUser;", + ].join("\n"), + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod( + "test/principal", + partialTransformPath, + ["**/*.ts"], + ["TailorUser", "unauthenticatedTailorUser"], + ), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([ + "resolver.ts: contains TailorUser, unauthenticatedTailorUser but was not migrated automatically (rule: test/principal). Manual migration may be needed.", + ]); + }); + + test("keeps legacy warnings for process.env bracket keys in source files", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "env.ts"), + [ + 'const platformUrl = process.env["PLATFORM_URL"];', + "const logLevel = process.env[`LOG_LEVEL`];", + 'const unrelated = "LOG_LEVEL";', + ].join("\n"), + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod( + "test/env", + partialTransformPath, + ["**/*.ts"], + ["PLATFORM_URL", "LOG_LEVEL"], + ), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([ + "env.ts: contains PLATFORM_URL, LOG_LEVEL but was not migrated automatically (rule: test/env). Manual migration may be needed.", + ]); + }); + + test("keeps opt-in legacy warnings for source string fragments", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "env.ts"), + [ + 'import { execSync } from "node:child_process";', + 'execSync("PLATFORM_URL=https://api.test LOG_LEVEL=DEBUG tailor-sdk login");', + "// PLATFORM_URL in a comment stays ignored.", + ].join("\n"), + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/env", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: ["PLATFORM_URL", "LOG_LEVEL"], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([ + "env.ts: contains PLATFORM_URL, LOG_LEVEL but was not migrated automatically (rule: test/env). Manual migration may be needed.", + ]); + }); + + test("keeps source string residual checks inside each literal", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + [ + 'const packageName = "tailor-sdk";', + 'const command = "deploy";', + 'spawn("tailor", ["--arg", "tailor-sdk deploy", "deploy"]);', + 'spawn("npx", ["@tailor-platform/sdk", "--arg", "tailor-sdk deploy", "deploy"]);', + ].join("\n"), + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: [/tailor-sdk(?=\s+deploy)/], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("keeps generic source string residual checks out of comments", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "env.ts"), + "// PLATFORM_URL is documented here\nconst value = 1;", + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/env", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: ["PLATFORM_URL"], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("keeps escaped quoted Tailor values out of rename-bin residual warnings", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + 'const command = "tailor --arg \\"tailor-sdk deploy\\" deploy";', + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("keeps split Tailor option values out of rename-bin residual warnings", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + 'spawn("tailor", ["tailordb", "migration", "generate", "--name", "tailor-sdk deploy"]);', + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("keeps shim and path Tailor option values out of rename-bin residual warnings", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + [ + 'spawn("tailor.cmd", ["--arg", "tailor-sdk deploy", "deploy"]);', + 'spawn("./node_modules/.bin/tailor", ["--arg", "tailor-sdk deploy", "deploy"]);', + ].join("\n"), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("warns for split argv rename-bin residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + [ + 'spawn("tailor-sdk", ["apply"]);', + 'spawn("tailor-sdk", ["crash-report", "list"]);', + 'spawn("npx", ["-p", "@tailor-platform/sdk", "tailor-sdk", "crash-report"]);', + ].join("\n"), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for variable-backed rename-bin source residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + ['const bin = "tailor-sdk";', 'spawn(bin, ["deploy"]);'].join("\n"), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for source comment and JSX rename-bin residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.tsx"), + ["// tailor-sdk apply", "const docs = tailor-sdk crash-report list;"].join( + "\n", + ), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.tsx"], [], { + sourceTextLegacyPatterns: renameBin?.sourceTextLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.tsx: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for quoted shell rename-bin residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + [ + 'const note = "unrelated";', + "const command = 'bash -lc \"tailor-sdk crash-report list\"';", + ].join("\n"), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for quoted legacy CLI residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + "const message = 'Run \"tailor-sdk crash-report list\" manually';", + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for source command residuals with shadowed aliases", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + [ + 'const bin = "tailor-sdk";', + 'spawn(bin, ["apply"]);', + "function shadow() {", + ' const bin = "tailor";', + " return bin;", + "}", + ].join("\n"), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for source command residuals before later fragments", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + ["const command = `${runner} tailor-sdk ${subcommand}`;", 'const later = "deploy";'].join( + "\n", + ), + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("keeps multiple-spaced Tailor option values out of rename-bin residual warnings", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + 'const command = "tailor --name tailor-sdk deploy";', + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([]); + }); + + test("warns for dynamic template rename-bin residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + "const command = `${runner} tailor-sdk ${subcommand}`;", + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("warns for dynamic package flag rename-bin residuals", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "commands.ts"), + "const command = `npx -p ${pkg} tailor-sdk login`;", + "utf-8", + ); + const renameBin = allCodemods.find((codemod) => codemod.id === "v2/rename-bin"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/rename-bin", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: renameBin?.sourceStringLegacyPatterns, + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("commands.ts: contains"); + expect(result.warnings[0]).toContain("rule: test/rename-bin"); + }); + + test("keeps source string residual checks in non-Tailor option values", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "env.ts"), + 'spawn("node", ["-e", "process.env.LOG_LEVEL"]);', + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/env", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: ["LOG_LEVEL"], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([ + "env.ts: contains LOG_LEVEL but was not migrated automatically (rule: test/env). Manual migration may be needed.", + ]); + }); + + test("keeps non-rename-bin source string residual checks in Tailor option values", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-warning-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "env.ts"), + 'spawn("tailor", ["--arg", "LOG_LEVEL=debug", "deploy"]);', + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/env", partialTransformPath, ["**/*.ts"], [], { + sourceStringLegacyPatterns: ["LOG_LEVEL"], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([ + "env.ts: contains LOG_LEVEL but was not migrated automatically (rule: test/env). Manual migration may be needed.", + ]); + }); + + test("flags files matching a suspicious pattern for LLM review", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "a.ts"), "executeScript({ arg: payload });\n"); + await fs.promises.writeFile(path.join(dir, "b.ts"), "const x = 1;\n"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["a.ts"], + }, + ]); + }); + + test("flags unresolved auth connection token helper usages for LLM review", async () => { + const codemod = allCodemods.find((entry) => entry.id === "v2/auth-connection-token-helper"); + if (!codemod?.scriptPath) throw new Error("auth connection token codemod missing script"); + const scriptPath = path.resolve( + __dirname, + "../codemods", + codemod.scriptPath.replace(/\.js$/, ".ts"), + ); + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-auth-token-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "migrated.ts"), + [ + 'import { auth } from "../tailor.config";', + "", + "export async function run() {", + ' return auth.getConnectionToken("google");', + "}", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "default-import.ts"), + [ + 'import config from "../tailor.config";', + "", + "export async function run() {", + ' return config.auth.getConnectionToken("google");', + "}", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "reexported-config.ts"), + [ + 'import { auth } from "../app-config";', + "", + "export async function run() {", + ' return auth.getConnectionToken("google");', + "}", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "computed.ts"), + [ + 'import { auth } from "../tailor.config";', + "", + 'export const token = await auth["getConnectionToken"]("google");', + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "destructure.ts"), + [ + 'import { auth } from "../tailor.config";', + "", + "export const { getConnectionToken } = auth;", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "cjs-require.js"), + [ + 'const { auth } = require("../tailor.config");', + "", + 'exports.token = auth.getConnectionToken("google");', + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "collision.ts"), + [ + 'import { auth } from "../tailor.config";', + "", + "const authconnection = createClient();", + "", + "export async function run() {", + ' return auth.getConnectionToken("google");', + "}", + "", + ].join("\n"), + ); + await fs.promises.writeFile( + path.join(dir, "shadowed.ts"), + [ + 'import { auth } from "../tailor.config";', + "", + "export async function run(auth: { getConnectionToken(name: string): Promise }) {", + ' return auth.getConnectionToken("google");', + "}", + "", + ].join("\n"), + ); + + using _stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + + const result = await runCodemods([{ codemod, scriptPath }], dir, true); + + expect(result.changed).toBe(true); + expect(result.llmReviews).toEqual([ + { + codemodId: "v2/auth-connection-token-helper", + prompt: codemod.prompt, + files: [ + "cjs-require.js", + "collision.ts", + "computed.ts", + "default-import.ts", + "destructure.ts", + "reexported-config.ts", + "shadowed.ts", + ], + findings: [ + expect.objectContaining({ + file: "cjs-require.js", + line: 3, + excerpt: 'exports.token = auth.getConnectionToken("google");', + }), + expect.objectContaining({ + file: "collision.ts", + line: 6, + excerpt: 'return auth.getConnectionToken("google");', + }), + expect.objectContaining({ + file: "computed.ts", + line: 3, + excerpt: 'export const token = await auth["getConnectionToken"]("google");', + }), + expect.objectContaining({ + file: "default-import.ts", + line: 4, + excerpt: 'return config.auth.getConnectionToken("google");', + }), + expect.objectContaining({ + file: "destructure.ts", + line: 3, + excerpt: "export const { getConnectionToken } = auth;", + }), + expect.objectContaining({ + file: "reexported-config.ts", + line: 4, + excerpt: 'return auth.getConnectionToken("google");', + }), + expect.objectContaining({ + file: "shadowed.ts", + line: 4, + excerpt: 'return auth.getConnectionToken("google");', + }), + ], + }, + ]); + }); + + test("suppresses LLM review when a superseding codemod is selected", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-superseded-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "a.ts"), + "createResolver({ authInvoker: auth.invoker(machineUserName) });\n", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/helper", undefined, ["**/*.ts"], undefined, { + suspiciousPatterns: ["auth.invoker"], + prompt: "Keep authInvoker and unwrap auth.invoker.", + reviewSupersededBy: ["test/rename"], + }), + }, + { + codemod: makeCodemod("test/rename", undefined, ["**/*.ts"], undefined, { + suspiciousPatterns: ["auth.invoker"], + prompt: "Rename authInvoker to invoker and unwrap auth.invoker.", + }), + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/rename", + prompt: "Rename authInvoker to invoker and unwrap auth.invoker.", + files: ["a.ts"], + }, + ]); + }); + + test("AND-group suspicious pattern flags only when every substring co-occurs", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-and-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "unresolved.ts"), + "const serialized = JSON.stringify(payload);\nawait executeScript({ arg: serialized });\n", + ); + await fs.promises.writeFile( + path.join(dir, "already-plain.ts"), + "await executeScript({ arg: payload });\n", + ); + await fs.promises.writeFile( + path.join(dir, "non-arg-json.ts"), + "await executeScript({ code: JSON.stringify(meta) });\n", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: [["executeScript", "JSON.stringify", "arg:"]], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["unresolved.ts"], + }, + ]); + }); + + test("AND-group suspicious pattern supports regex members", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-regex-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "spaced-colon.ts"), + "const serialized = JSON.stringify(payload);\nawait executeScript({ arg : serialized });\n", + ); + await fs.promises.writeFile( + path.join(dir, "already-plain.ts"), + "await executeScript({ arg : payload });\n", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: [["executeScript", "JSON.stringify", /\barg\s*:/]], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["spaced-colon.ts"], + }, + ]); + }); + + test("does not flag for LLM review without a prompt", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-noprompt-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "a.ts"), "executeScript({ arg: payload });\n"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([]); + }); + + test("ignores source comments and strings for LLM review patterns", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "a.ts"), + [ + "// executeScript({ arg: payload });", + 'const name = "executeScript";', + "const template = `executeScript`;", + ].join("\n"), + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([]); + }); + + test("flags source strings matching source-string suspicious patterns for LLM review", async () => { + const dir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "runner-llm-source-string-test-"), + ); + tmpDir = dir; + const embeddedCode = [ + 'const client = new tailor.idp.Client({ namespace: "default" });', + "const C = tailor.idp.Client;", + 'await tailor.secretmanager.getSecret("vault", "key");', + "const { getSecret } = tailor.secretmanager;", + "const getInvoker = tailor.context.getInvoker;", + "const { upload } = tailordb.file;", + "const e: TailorErrors = err;", + "type R = Promise>;", + ].join("\\n"); + const typeOnlyEmbeddedCode = [ + "type U = Promise;", + "type Ctor = typeof tailordb.Client;", + "return tailordb.Client;", + "foo(tailordb.Client);", + "type F = () => tailordb.QueryResult;", + ].join("\\n"); + const seedSource = [ + `const code = \`${embeddedCode}\`;`, + 'const note = "tailor.idp.Client is mentioned in prose";', + ].join("\n"); + await fs.promises.writeFile(path.join(dir, "seed.mjs"), seedSource); + await fs.promises.writeFile( + path.join(dir, "escaped.mjs"), + 'const code = "const C =\\n tailor.idp.Client;";', + ); + await fs.promises.writeFile( + path.join(dir, "types.mjs"), + `const code = \`${typeOnlyEmbeddedCode}\`;`, + ); + await fs.promises.writeFile( + path.join(dir, "prose.mjs"), + ['const separator = "=";', 'const note = "tailor.idp.Client is mentioned in prose";'].join( + "\n", + ), + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod( + "test/llm-source-string", + partialTransformPath, + ["**/*.{ts,js,mjs,cjs}"], + undefined, + { + sourceStringSuspiciousPatterns: [ + "new tailor.idp.Client", + /[=(:,[]\s*tailor\.idp\.Client\b/, + /(?:(?:=>|[=(:,<{]|\[)\s*|\b(?:return|await|typeof)\s+)tailor\.(?:context|idp|secretmanager)(?:\.[A-Za-z_$][\w$]*)?\b/, + /\btailor\.(?:idp|secretmanager)\.[A-Za-z_$][\w$]*\s*\(/, + /(?:(?:[=(:,{]|\[)\s*|\b(?:return|await)\s+)tailordb\.file\b/, + /(?:\bnew\s+|(?:=>|[=(:,<{]|\[)\s*|\b(?:return|await|typeof)\s+)tailordb\.(?:Client|QueryResult)\b/, + /<\s*tailordb\.(?:QueryResult)\b/, + /(?:[:=<]\s*|\bas\s+)Tailor(?:Errors)\b/, + ], + prompt: "Review embedded runtime global usage by hand.", + }, + ), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toHaveLength(1); + expect(result.llmReviews[0]).toMatchObject({ + codemodId: "test/llm-source-string", + prompt: "Review embedded runtime global usage by hand.", + }); + expect(result.llmReviews[0]?.files).toEqual( + expect.arrayContaining(["escaped.mjs", "seed.mjs", "types.mjs"]), + ); + expect(result.llmReviews[0]?.files).toHaveLength(3); + }); + + test("keeps LLM review patterns inside template substitutions", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "a.ts"), + "const message = `${executeScript({ arg: payload })}`;\n", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["a.ts"], + }, + ]); + }); + + test("keeps LLM review patterns after nested template substitutions", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "a.ts"), + "const message = `${`prefix ${foo}`.toString(executeScript())}`;\n", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["a.ts"], + }, + ]); + }); + + test("keeps LLM review patterns after regex literals with escaped slashes", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-source-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "a.ts"), + "const re = /https?:\\/\\//; executeScript({ arg: payload });\n", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["a.ts"], + }, + ]); + }); + + test("emits a blanket LLM review for a codemod-less manual entry", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-manual-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "a.ts"), "const x = 1;\n"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/manual", undefined, ["**/*.ts"], undefined, { + prompt: "Do the manual change.", + }), + scriptPath: undefined, + }, + ], + dir, + true, + ); + + expect(result.changed).toBe(false); + expect(result.llmReviews).toEqual([ + { codemodId: "test/manual", prompt: "Do the manual change.", files: [] }, + ]); + }); + + test("does not emit a blanket review for a legacy-pattern entry with a prompt", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-legacy-prompt-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "a.ts"), "const x = 1;\n"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/legacy", partialTransformPath, ["**/*.ts"], ["needle"], { + prompt: "Finish the residual.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + // legacy-pattern entries surface via warnings, not a blanket llmReview. + expect(result.llmReviews).toEqual([]); + }); + + test("AND-group legacy pattern warns only when every substring co-occurs", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-and-group-test-")); + tmpDir = dir; + await fs.promises.writeFile( + path.join(dir, "both.ts"), + "executeScript({ arg: payload });\nconst s = JSON.stringify(x);\n", + "utf-8", + ); + await fs.promises.writeFile( + path.join(dir, "only-stringify.ts"), + "const s = JSON.stringify(x);\n", + "utf-8", + ); + + const result = await runCodemods( + [ + { + codemod: makeCodemod( + "test/and-group", + partialTransformPath, + ["**/*.ts"], + [["executeScript", "JSON.stringify"]], + ), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.warnings).toEqual([ + "both.ts: contains executeScript + JSON.stringify but was not migrated automatically (rule: test/and-group). Manual migration may be needed.", + ]); + }); + + test("flags files matching a suspicious pattern for LLM review", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "a.ts"), "executeScript({ arg: payload });\n"); + await fs.promises.writeFile(path.join(dir, "b.ts"), "const x = 1;\n"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + prompt: "Rewrite remaining executeScript usages by hand.", + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([ + { + codemodId: "test/llm", + prompt: "Rewrite remaining executeScript usages by hand.", + files: ["a.ts"], + }, + ]); + }); + + test("does not flag for LLM review without a prompt", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-llm-noprompt-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "a.ts"), "executeScript({ arg: payload });\n"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/llm", partialTransformPath, ["**/*.ts"], undefined, { + suspiciousPatterns: ["executeScript"], + }), + scriptPath: partialTransformPath, + }, + ], + dir, + true, + ); + + expect(result.llmReviews).toEqual([]); + }); }); }); diff --git a/packages/sdk-codemod/src/runner.ts b/packages/sdk-codemod/src/runner.ts index fb55fb2a7b..7bc299c74f 100644 --- a/packages/sdk-codemod/src/runner.ts +++ b/packages/sdk-codemod/src/runner.ts @@ -1,11 +1,19 @@ import * as fs from "node:fs"; -import { glob } from "node:fs/promises"; import * as url from "node:url"; +import { parse, Lang } from "@ast-grep/napi"; import chalk from "chalk"; import { structuredPatch } from "diff"; import * as path from "pathe"; import picomatch from "picomatch"; -import type { CodemodPackage } from "./types"; +import type { + CodemodPackage, + CodemodPattern, + CodemodPatternGroup, + LlmReview, + LlmReviewFinding, + ReviewFindingsFn, +} from "./types"; +import type { SgNode } from "@ast-grep/napi"; /** * A transform function that receives source text and file path, @@ -41,6 +49,8 @@ export interface CodemodRunResult { warnings: string[]; /** IDs of codemods that actually produced changes in at least one file. */ appliedCodemodIds: Set; + /** Files flagged for LLM-assisted review, grouped by codemod. */ + llmReviews: LlmReview[]; } /** Default file patterns for TypeScript files. */ @@ -48,6 +58,65 @@ const DEFAULT_FILE_PATTERNS = ["**/*.{ts,tsx,mts,cts}"]; /** Directory names always excluded from file scanning. */ const EXCLUDE_DIRS = new Set(["node_modules", "dist", ".git"]); +const ALLOWED_DOT_DIRS = new Set([".github", ".circleci"]); +const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]); +const SOURCE_STRING_FRAGMENT_SEPARATOR = "\0"; +const MASKED_SOURCE_NODE_KINDS: ReadonlySet> = new Set([ + "comment", + "string", + "regex", + "string_fragment", + "jsx_text", +]); +const SOURCE_VALUE_FLAGS = new Set([ + "--env-file-if-exists", + "--env-file", + "--profile", + "--config", + "--workspace-id", + "--arg", + "--query", + "--file", + "--name", + "--namespace", + "--dir", + "-e", + "-p", + "-c", + "-w", + "-a", + "-q", + "-f", + "-n", +]); +const SOURCE_CLI_BINARY_RE = + /^(?:(?:.*[\\/])?tailor(?:\.(?:cmd|ps1|exe))?|(?:.*[\\/])?tailor-sdk(?:@[^\s'"`;|&)]+)?(?:\.(?:cmd|ps1|exe))?|@tailor-platform\/sdk(?:@[^\s'"`;|&)]+)?)$/; + +function shouldSkipDirectory(name: string): boolean { + return EXCLUDE_DIRS.has(name) || (name.startsWith(".") && !ALLOWED_DOT_DIRS.has(name)); +} + +async function* walkFiles(root: string, relativeDir = ""): AsyncGenerator { + const absoluteDir = path.join(root, relativeDir); + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(absoluteDir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const relative = relativeDir ? path.join(relativeDir, entry.name) : entry.name; + if (entry.isDirectory()) { + if (shouldSkipDirectory(entry.name)) continue; + yield* walkFiles(root, relative); + continue; + } + if (entry.isFile()) { + yield relative; + } + } +} /** * Print a colorized unified diff for a single file to stderr. @@ -82,22 +151,468 @@ function printDiff(filePath: string, before: string, after: string): void { * Load a transform module from a TypeScript file path. * Expects the module to have a default export that is a TransformFn. * @param scriptPath - Absolute path to the transform script - * @returns The transform function + * @returns The transform function and optional review detector */ -async function loadTransform(scriptPath: string): Promise { +async function loadTransformModule( + scriptPath: string, +): Promise<{ transform: TransformFn; reviewFindings?: ReviewFindingsFn }> { const mod = await import(url.pathToFileURL(scriptPath).href); if (typeof mod.default !== "function") { throw new Error(`Transform at ${scriptPath} does not have a default export function`); } - return mod.default as TransformFn; + return { + transform: mod.default as TransformFn, + reviewFindings: + typeof mod.reviewFindings === "function" + ? (mod.reviewFindings as ReviewFindingsFn) + : undefined, + }; } /** A loaded transform with its file matcher. */ interface LoadedTransform { id: string; - transform: TransformFn; + /** Undefined for codemod-less ("manual") entries that ship only guidance. */ + transform?: TransformFn; + reviewFindings?: ReviewFindingsFn; matches: (relativePath: string) => boolean; - legacyPatterns: string[]; + legacyPatterns: CodemodPatternGroup[]; + sourceStringLegacyPatterns: CodemodPatternGroup[]; + sourceTextLegacyPatterns: CodemodPatternGroup[]; + suspiciousPatterns: CodemodPatternGroup[]; + sourceStringSuspiciousPatterns: CodemodPatternGroup[]; + prompt?: string; + reviewSupersededBy: string[]; +} + +function contentForResidualMatching(relative: string, content: string): string { + const ext = path.extname(relative).toLowerCase(); + return SOURCE_EXTENSIONS.has(ext) ? maskSourceNonCode(relative, content) : content; +} + +function sourceStringFragmentGapForResidualMatching(gap: string): string { + if (/^\\["']$/.test(gap)) return gap.slice(1); + return /^(?:\\(?:[nrtvf]|\r\n|\r|\n)|\s)+$/.test(gap) ? " " : SOURCE_STRING_FRAGMENT_SEPARATOR; +} + +function sourceStringNodeContentForResidualMatching(node: SgNode, content: string): string | null { + const parts: string[] = []; + let previousFragmentEnd: number | null = null; + + for (const child of node.children()) { + if (child.kind() !== "string_fragment") continue; + + const range = child.range(); + if (previousFragmentEnd != null && range.start.index > previousFragmentEnd) { + parts.push( + sourceStringFragmentGapForResidualMatching( + content.slice(previousFragmentEnd, range.start.index), + ), + ); + } + parts.push(child.text()); + previousFragmentEnd = range.end.index; + } + + return parts.length === 0 ? null : parts.join(""); +} + +function sourceStringContentForResidualMatching(relative: string, content: string): string | null { + const ext = path.extname(relative).toLowerCase(); + if (!SOURCE_EXTENSIONS.has(ext)) return null; + + let root: SgNode; + try { + root = parse(sourceLang(relative), content).root(); + } catch { + return null; + } + + const sourceStrings: string[] = []; + const visit = (node: SgNode): void => { + if (node.kind() === "arguments") { + const value = sourceArgumentsCommandContent(node, content); + if (value != null) sourceStrings.push(value); + } + if (node.kind() === "array") { + const value = sourceArrayCommandContent(node, content); + if (value != null) sourceStrings.push(value); + } + const kind = node.kind(); + if (kind === "string" || kind === "template_string") { + if (isSourceTailorSdkValueArgument(node, content)) return; + const sourceString = sourceStringNodeContentForResidualMatching(node, content); + if (sourceString != null) sourceStrings.push(sourceString); + } + for (const child of node.children()) { + if (child.kind() === "string_fragment") continue; + visit(child); + } + }; + visit(root); + return sourceStrings.join(SOURCE_STRING_FRAGMENT_SEPARATOR); +} + +function isConstVariableDeclarator(node: SgNode): boolean { + return ( + node + .parent() + ?.children() + .some((child) => child.kind() === "const") ?? false + ); +} + +function sourceTextContentForResidualMatching(relative: string, content: string): string | null { + const ext = path.extname(relative).toLowerCase(); + if (!SOURCE_EXTENSIONS.has(ext)) return null; + + let root: SgNode; + try { + root = parse(sourceLang(relative), content).root(); + } catch { + return null; + } + + const fragments: string[] = []; + const visit = (node: SgNode): void => { + if (node.kind() === "comment" || node.kind() === "jsx_text") { + fragments.push(node.text()); + return; + } + for (const child of node.children()) { + visit(child); + } + }; + visit(root); + return fragments.join(SOURCE_STRING_FRAGMENT_SEPARATOR); +} + +function sourceArgumentsCommandContent(node: SgNode, source: string): string | null { + const args = sourceArrayElements(node); + const executable = args[0] == null ? null : sourceStringLikeNodeContent(args[0]!, source); + const argv = args[1]; + if (executable == null || argv?.kind() !== "array") return null; + + const values = sourceArrayCommandValues(argv, source); + return values.length === 0 ? null : [executable, ...values].join(" "); +} + +function sourceArrayCommandContent(node: SgNode, source: string): string | null { + const values = sourceArrayCommandValues(node, source); + return values.length < 2 ? null : values.join(" "); +} + +function sourceArrayCommandValues(node: SgNode, source: string): string[] { + const values: string[] = []; + for (const element of sourceArrayElements(node)) { + if (isSourceValueArgument(element, source)) continue; + const value = sourceStringLikeNodeContent(element, source); + if (value != null) values.push(value); + } + return values; +} + +function isSourceTailorSdkValueArgument(fragment: SgNode, source: string): boolean { + const text = + fragment.kind() === "string_fragment" + ? fragment.text() + : sourceStringLikeNodeContent(fragment, source); + return text != null && text.includes("tailor-sdk") && isSourceValueArgument(fragment, source); +} + +function isSyntaxOnlyNode(node: SgNode): boolean { + const kind = node.kind(); + return ( + kind === "[" || + kind === "]" || + kind === "(" || + kind === ")" || + kind === "," || + kind === "comment" + ); +} + +function sourceArrayElements(node: SgNode): SgNode[] { + return node.children().filter((child: SgNode) => !isSyntaxOnlyNode(child)); +} + +function nodeRangeKey(node: SgNode): string { + const range = node.range(); + return `${range.start.index}:${range.end.index}`; +} + +function sourceStringLikeNodeContent(node: SgNode, source: string): string | null { + const directValue = sourceStringNodeContent(node, source); + if (directValue != null) return directValue; + return node.kind() === "identifier" ? sourceScopedStringVariableContent(node, source) : null; +} + +function sourceScopedStringVariableContent(identifier: SgNode, source: string): string | null { + const name = identifier.text(); + const before = identifier.range().start.index; + let current = identifier.parent(); + while (current != null) { + if (isSourceScopeNode(current)) { + const value = findSourceStringVariableInScope(current, name, before, source); + if (value != null) return value; + } + current = current.parent(); + } + return null; +} + +function findSourceStringVariableInScope( + scope: SgNode, + name: string, + before: number, + source: string, +): string | null { + let value: string | null = null; + const visit = (node: SgNode): void => { + if (node !== scope && isSourceScopeNode(node)) return; + if (node.kind() === "variable_declarator" && node.range().end.index < before) { + const declarationValue = sourceStringVariableDeclarationValue(node, name, source); + if (declarationValue != null) value = declarationValue; + return; + } + for (const child of node.children()) { + visit(child); + } + }; + visit(scope); + return value; +} + +function sourceStringVariableDeclarationValue( + node: SgNode, + name: string, + source: string, +): string | null { + if (!isConstVariableDeclarator(node)) return null; + const children = node.children(); + const identifier = children.find((child) => child.kind() === "identifier"); + if (identifier?.text() !== name) return null; + const initializer = children.findLast( + (child) => sourceConstInitializerContent(child, source) != null, + ); + return initializer == null ? null : sourceConstInitializerContent(initializer, source); +} + +function sourceConstInitializerContent(node: SgNode, source: string): string | null { + const directValue = sourceStringNodeContent(node, source); + if (directValue != null) return directValue; + if ( + node.kind() !== "as_expression" && + node.kind() !== "satisfies_expression" && + node.kind() !== "parenthesized_expression" + ) { + return null; + } + for (const child of node.children()) { + const childValue = sourceConstInitializerContent(child, source); + if (childValue != null) return childValue; + } + return null; +} + +function isSourceScopeNode(node: SgNode): boolean { + const kind = node.kind(); + return ( + kind === "program" || + kind === "statement_block" || + kind === "function_declaration" || + kind === "arrow_function" || + kind === "method_definition" + ); +} + +function sourceStringNodeContent(node: SgNode, source: string): string | null { + const kind = node.kind(); + if (kind !== "string" && kind !== "template_string") return null; + if ( + kind === "template_string" && + node.children().some((child: SgNode) => child.kind() === "template_substitution") + ) { + return null; + } + const range = node.range(); + return source.slice(range.start.index + 1, range.end.index - 1); +} + +function isSourceValueArgument(fragment: SgNode, source: string): boolean { + const stringNode = fragment.kind() === "string_fragment" ? fragment.parent() : fragment; + if (stringNode == null) return false; + const parent = stringNode.parent(); + if (parent?.kind() !== "array") return false; + + const elements = sourceArrayElements(parent); + const index = elements.findIndex((element) => nodeRangeKey(element) === nodeRangeKey(stringNode)); + if (index <= 0) return false; + if (!isTailorCliArgumentArray(parent, index, source)) return false; + + const previous = sourceStringLikeNodeContent(elements[index - 1]!, source); + return ( + previous != null && + SOURCE_VALUE_FLAGS.has(previous.split("=", 1)[0]!) && + !previous.includes("=") + ); +} + +function isTailorCliArgumentArray(arrayNode: SgNode, index: number, source: string): boolean { + const argumentsNode = arrayNode.parent(); + if (argumentsNode?.kind() === "arguments") { + const callArgs = sourceArrayElements(argumentsNode); + const executable = + callArgs[0] == null ? null : sourceStringLikeNodeContent(callArgs[0]!, source); + if (executable != null && SOURCE_CLI_BINARY_RE.test(executable)) return true; + } + + const elements = sourceArrayElements(arrayNode); + return elements.slice(0, index).some((element) => { + const value = sourceStringLikeNodeContent(element, source); + return value != null && SOURCE_CLI_BINARY_RE.test(value); + }); +} + +function sourceLang(relative: string): Lang { + const ext = path.extname(relative).toLowerCase(); + return ext === ".tsx" || ext === ".jsx" || ext === ".js" ? Lang.Tsx : Lang.TypeScript; +} + +function isProcessEnvSubscriptKey(node: SgNode): boolean { + const stringNode = node.kind() === "string_fragment" ? node.parent() : node; + if (stringNode == null) return false; + const stringNodeKind = stringNode.kind(); + if (stringNodeKind !== "string" && stringNodeKind !== "template_string") { + return false; + } + const parent = stringNode.parent(); + return parent?.kind() === "subscript_expression" && /^process\.env\s*\[/.test(parent.text()); +} + +function collectMaskedRanges(root: SgNode): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + const visit = (node: SgNode): void => { + if (MASKED_SOURCE_NODE_KINDS.has(node.kind())) { + if (isProcessEnvSubscriptKey(node)) return; + const range = node.range(); + ranges.push([range.start.index, range.end.index]); + return; + } + for (const child of node.children()) { + visit(child); + } + }; + visit(root); + return ranges; +} + +function maskSourceNonCode(relative: string, content: string): string { + let ranges: Array<[number, number]>; + try { + const root = parse(sourceLang(relative), content).root(); + ranges = collectMaskedRanges(root); + } catch { + return content; + } + + ranges = ranges.toSorted(([a], [b]) => a - b); + const chars = content.split(""); + for (const [start, end] of ranges) { + for (let i = start; i < end && i < chars.length; i++) { + if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " "; + } + } + return chars.join(""); +} + +function isIdentifierChar(char: string | undefined): boolean { + return char != null && /^[A-Za-z0-9_$]$/.test(char); +} + +function matchesPattern(content: string, pattern: CodemodPattern): boolean { + if (typeof pattern === "string") { + const checkLeft = isIdentifierChar(pattern[0]); + const checkRight = isIdentifierChar(pattern.at(-1)); + let index = content.indexOf(pattern); + while (index !== -1) { + const before = index > 0 ? content[index - 1] : undefined; + const after = content[index + pattern.length]; + if ((!checkLeft || !isIdentifierChar(before)) && (!checkRight || !isIdentifierChar(after))) { + return true; + } + index = content.indexOf(pattern, index + 1); + } + return false; + } + pattern.lastIndex = 0; + return pattern.test(content); +} + +function patternLabel(pattern: CodemodPattern): string { + return typeof pattern === "string" ? pattern : pattern.toString(); +} + +/** Resolve a residual pattern against content, returning its label when matched. */ +function matchResidualPattern(content: string, pattern: CodemodPatternGroup): string | null { + if (!Array.isArray(pattern)) { + return matchesPattern(content, pattern) ? patternLabel(pattern) : null; + } + return pattern.every((p) => matchesPattern(content, p)) + ? pattern.map((p) => patternLabel(p)).join(" + ") + : null; +} + +function matchResidualPatternFragment( + content: string, + pattern: CodemodPatternGroup, +): string | null { + for (const fragment of content.split(SOURCE_STRING_FRAGMENT_SEPARATOR)) { + const label = matchResidualPattern(fragment, pattern); + if (label != null) return label; + } + return null; +} + +function legacyPatternWarnings( + relative: string, + content: string, + sourceStringContent: string | null, + sourceTextContent: string | null, + transforms: LoadedTransform[], +): string[] { + return transforms.flatMap((lt) => { + const found = new Set( + lt.legacyPatterns + .map((p) => matchResidualPattern(content, p)) + .filter((label): label is string => label !== null), + ); + if (sourceStringContent != null) { + for (const pattern of lt.sourceStringLegacyPatterns) { + const label = matchResidualPatternFragment(sourceStringContent, pattern); + if (label != null) found.add(label); + } + } + if (sourceTextContent != null) { + for (const pattern of lt.sourceTextLegacyPatterns) { + const label = matchResidualPatternFragment(sourceTextContent, pattern); + if (label != null) found.add(label); + } + } + if (found.size === 0) return []; + return [ + `${relative}: contains ${Array.from(found).join(", ")} but was not migrated automatically (rule: ${lt.id}). Manual migration may be needed.`, + ]; + }); +} + +function compareReviewFindings(a: LlmReviewFinding, b: LlmReviewFinding): number { + return ( + a.file.localeCompare(b.file) || + a.line - b.line || + a.message.localeCompare(b.message) || + a.excerpt.localeCompare(b.excerpt) + ); } /** @@ -112,7 +627,7 @@ interface LoadedTransform { * @returns Combined result of all codemod executions */ export async function runCodemods( - codemods: Array<{ codemod: CodemodPackage; scriptPath: string }>, + codemods: Array<{ codemod: CodemodPackage; scriptPath?: string }>, targetPath: string, dryRun: boolean, ): Promise { @@ -120,75 +635,139 @@ export async function runCodemods( const loaded: LoadedTransform[] = []; for (const { codemod, scriptPath } of codemods) { const patterns = codemod.filePatterns ?? DEFAULT_FILE_PATTERNS; + const loadedModule = scriptPath ? await loadTransformModule(scriptPath) : undefined; loaded.push({ id: codemod.id, - transform: await loadTransform(scriptPath), - matches: picomatch(patterns), + transform: loadedModule?.transform, + reviewFindings: loadedModule?.reviewFindings, + matches: picomatch(patterns, { dot: true }), legacyPatterns: codemod.legacyPatterns ?? [], + sourceStringLegacyPatterns: codemod.sourceStringLegacyPatterns ?? [], + sourceTextLegacyPatterns: codemod.sourceTextLegacyPatterns ?? [], + suspiciousPatterns: codemod.suspiciousPatterns ?? [], + sourceStringSuspiciousPatterns: codemod.sourceStringSuspiciousPatterns ?? [], + prompt: codemod.prompt, + reviewSupersededBy: codemod.reviewSupersededBy ?? [], }); } - // Collect all unique file patterns for glob scanning - const allPatterns = new Set(); - for (const { codemod } of codemods) { - for (const p of codemod.filePatterns ?? DEFAULT_FILE_PATTERNS) { - allPatterns.add(p); - } - } - const filesModified: string[] = []; const warnings: string[] = []; const appliedCodemodIds = new Set(); const seen = new Set(); + const suspiciousByCodemod = new Map>(); + const findingsByCodemod = new Map(); + + for await (const relative of walkFiles(targetPath)) { + const absolute = path.resolve(targetPath, relative); + if (seen.has(absolute)) continue; + seen.add(absolute); + + const matchedTransforms = loaded.filter((lt) => lt.matches(relative)); + if (matchedTransforms.length === 0) continue; + + let original: string; + try { + original = await fs.promises.readFile(absolute, "utf-8"); + } catch { + continue; + } - // Iterate over all matching files (deduplicate across patterns) - for (const pattern of allPatterns) { - for await (const relative of glob(pattern, { - cwd: targetPath, - exclude: (name) => EXCLUDE_DIRS.has(name), - })) { - const absolute = path.resolve(targetPath, relative); - if (seen.has(absolute)) continue; - seen.add(absolute); - - let original: string; - try { - original = await fs.promises.readFile(absolute, "utf-8"); - } catch { - continue; + let current = original; + for (const lt of matchedTransforms) { + if (!lt.transform) continue; + const result = await lt.transform(current, absolute); + if (result != null) { + current = result; + appliedCodemodIds.add(lt.id); } + } - // Chain only transforms whose filePatterns match this file - let current = original; - const matchedTransforms: LoadedTransform[] = []; - for (const lt of loaded) { - if (!lt.matches(relative)) continue; - matchedTransforms.push(lt); - const result = await lt.transform(current, absolute); - if (result != null) { - current = result; - appliedCodemodIds.add(lt.id); - } + if (current !== original) { + filesModified.push(absolute); + if (dryRun) { + printDiff(absolute, original, current); + } else { + await fs.promises.writeFile(absolute, current, "utf-8"); } + } - if (current !== original) { - filesModified.push(absolute); - if (dryRun) { - printDiff(absolute, original, current); - } else { - await fs.promises.writeFile(absolute, current, "utf-8"); + const residualContent = contentForResidualMatching(relative, current); + const sourceStringContent = sourceStringContentForResidualMatching(relative, current); + const sourceTextContent = sourceTextContentForResidualMatching(relative, current); + warnings.push( + ...legacyPatternWarnings( + relative, + residualContent, + sourceStringContent, + sourceTextContent, + matchedTransforms, + ), + ); + + for (const lt of matchedTransforms) { + if (!lt.prompt) continue; + const filesForReview = (): Set => { + let files = suspiciousByCodemod.get(lt.id); + if (!files) { + files = new Set(); + suspiciousByCodemod.set(lt.id, files); } - } else { - // Check each matched codemod's legacyPatterns for unmodified files - for (const lt of matchedTransforms) { - const found = lt.legacyPatterns.filter((p) => original.includes(p)); - if (found.length > 0) { - warnings.push( - `${relative}: contains ${found.join(", ")} but was not migrated automatically (rule: ${lt.id}). Manual migration may be needed.`, - ); + return files; + }; + if (lt.reviewFindings) { + const findings = await lt.reviewFindings(current, absolute, relative); + if (findings.length > 0) { + const files = filesForReview(); + for (const finding of findings) { + files.add(finding.file); } + let existing = findingsByCodemod.get(lt.id); + if (!existing) { + existing = []; + findingsByCodemod.set(lt.id, existing); + } + existing.push(...findings); } } + const matchesSource = + lt.suspiciousPatterns.some((p) => matchResidualPattern(residualContent, p) !== null) || + (sourceStringContent != null && + lt.sourceStringSuspiciousPatterns.some( + (p) => matchResidualPattern(sourceStringContent, p) !== null, + )); + if (matchesSource) { + filesForReview().add(relative); + } + } + } + + const llmReviews: LlmReview[] = []; + const loadedIds = new Set(loaded.map((lt) => lt.id)); + for (const lt of loaded) { + if (!lt.prompt) continue; + if (lt.reviewSupersededBy.some((id) => loadedIds.has(id))) continue; + if ( + lt.suspiciousPatterns.length > 0 || + lt.sourceStringSuspiciousPatterns.length > 0 || + lt.reviewFindings + ) { + // File-scoped: only surface when a suspicious pattern actually matched. + const files = suspiciousByCodemod.get(lt.id); + // Sort for deterministic output regardless of filesystem traversal order. + if (files) { + const findings = findingsByCodemod.get(lt.id)?.toSorted(compareReviewFindings); + llmReviews.push({ + codemodId: lt.id, + prompt: lt.prompt, + files: Array.from(files).toSorted(), + ...(findings && findings.length > 0 ? { findings } : {}), + }); + } + } else if (lt.legacyPatterns.length === 0) { + // Codemod-less manual change with no pattern to scope by: surface as + // project-wide guidance (legacyPattern-only entries warn instead). + llmReviews.push({ codemodId: lt.id, prompt: lt.prompt, files: [] }); } } @@ -197,5 +776,6 @@ export async function runCodemods( filesModified, warnings, appliedCodemodIds, + llmReviews, }; } diff --git a/packages/sdk-codemod/src/transform.test.ts b/packages/sdk-codemod/src/transform.test.ts index eda87d8363..7d9c859e56 100644 --- a/packages/sdk-codemod/src/transform.test.ts +++ b/packages/sdk-codemod/src/transform.test.ts @@ -79,6 +79,10 @@ describe("codemod transforms", () => { await expect(runFixtureCases("v2/principal-unify")).resolves.toBeUndefined(); }); + test("v2/auth-attributes-rename transforms correctly", async () => { + await expect(runFixtureCases("v2/auth-attributes-rename")).resolves.toBeUndefined(); + }); + test("v2/apply-to-deploy transforms correctly", async () => { await expect(runFixtureCases("v2/apply-to-deploy")).resolves.toBeUndefined(); }); @@ -87,11 +91,43 @@ describe("codemod transforms", () => { await expect(runFixtureCases("v2/cli-rename")).resolves.toBeUndefined(); }); + test("v2/env-var-rename transforms correctly", async () => { + await expect(runFixtureCases("v2/env-var-rename")).resolves.toBeUndefined(); + }); + + test("v2/auth-invoker-call-unwrap transforms correctly", async () => { + await expect(runFixtureCases("v2/auth-invoker-call-unwrap")).resolves.toBeUndefined(); + }); + test("v2/auth-invoker-unwrap transforms correctly", async () => { await expect(runFixtureCases("v2/auth-invoker-unwrap")).resolves.toBeUndefined(); }); + test("v2/auth-connection-token-helper transforms correctly", async () => { + await expect(runFixtureCases("v2/auth-connection-token-helper")).resolves.toBeUndefined(); + }); + test("v2/tailordb-namespace transforms correctly", async () => { await expect(runFixtureCases("v2/tailordb-namespace")).resolves.toBeUndefined(); }); + + test("v2/runtime-globals-opt-in transforms correctly", async () => { + await expect(runFixtureCases("v2/runtime-globals-opt-in")).resolves.toBeUndefined(); + }); + + test("v2/execute-script-arg transforms correctly", async () => { + await expect(runFixtureCases("v2/execute-script-arg")).resolves.toBeUndefined(); + }); + + test("v2/tailor-output-ignore-dir transforms correctly", async () => { + await expect(runFixtureCases("v2/tailor-output-ignore-dir")).resolves.toBeUndefined(); + }); + + test("v2/rename-bin transforms correctly", async () => { + await expect(runFixtureCases("v2/rename-bin")).resolves.toBeUndefined(); + }); + + test("v2/wait-point-rename transforms correctly", async () => { + await expect(runFixtureCases("v2/wait-point-rename")).resolves.toBeUndefined(); + }); }); diff --git a/packages/sdk-codemod/src/types.ts b/packages/sdk-codemod/src/types.ts index f4bcd32450..5795cd6519 100644 --- a/packages/sdk-codemod/src/types.ts +++ b/packages/sdk-codemod/src/types.ts @@ -1,3 +1,21 @@ +import type { RunnerMetadata } from "./runner-metadata"; + +/** A before/after code pair shown in the generated migration doc. */ +export interface CodemodExample { + /** Code as written before the migration. */ + before: string; + /** Code after the migration. */ + after: string; + /** Optional one-line caption explaining the example. */ + caption?: string; + /** Fenced-code-block language for the example (default: "ts"). */ + lang?: string; +} + +export type CodemodPattern = string | RegExp; + +export type CodemodPatternGroup = CodemodPattern | CodemodPattern[]; + /** * Metadata for a codemod package. */ @@ -12,23 +30,119 @@ export interface CodemodPackage { since: string; /** Target version this codemod upgrades to (semver, exclusive upper bound) */ until: string; - /** Path to the jssg transform script relative to the codemods root */ - scriptPath: string; + /** Earliest prerelease target that should apply this codemod before `until` is stable. */ + prereleaseUntil?: string; + /** + * Path to the jssg transform script relative to the codemods root. Omit for a + * codemod-less ("manual") migration that ships only guidance — `prompt`, + * `examples`, and/or `suspiciousPatterns` — with no automatic transform. + */ + scriptPath?: string; /** Target language for codemod CLI (default: "typescript") */ language?: string; /** Custom file glob patterns. Defaults to TypeScript patterns when omitted. */ filePatterns?: string[]; - /** Legacy patterns to detect in unmodified files for manual migration warnings. */ - legacyPatterns?: string[]; + /** + * Patterns to detect in post-transform file content for manual migration + * warnings. A plain string warns when that substring is present, a `RegExp` + * warns when it matches, and an array group warns only when every member is + * present (AND), letting a rule target a co-occurrence such as + * `executeScript` used together with `JSON.stringify`. In source files, + * comments and string literals are masked before matching, and identifier-like + * string patterns must match token boundaries. + */ + legacyPatterns?: CodemodPatternGroup[]; + /** + * Patterns to detect only inside string/template fragments of source files + * after a transform runs. Use this when a migration normally masks source + * strings for residual matching, but selected string content still needs a + * manual follow-up warning. + */ + sourceStringLegacyPatterns?: CodemodPatternGroup[]; + /** + * Patterns to detect only inside comments and JSX text of source files after + * a transform runs. Use this for source text that is intentionally masked + * from generic residual matching, but still contains user-facing command + * examples that need a manual follow-up warning. + */ + sourceTextLegacyPatterns?: CodemodPatternGroup[]; + /** + * Patterns that, when present in a file's post-transform content, mark it + * as a candidate for LLM-assisted review. Use this for migrations the + * deterministic transform cannot safely complete on its own (e.g. a value + * reached through a variable or a dynamic expression). An array group + * matches only when every pattern in the group is present (AND). Unlike + * `legacyPatterns`, these do not need to be exhaustive: a broad signal such + * as the API name is enough to point an LLM at the right files. Source files + * use the same comment/string and token-boundary matching as + * `legacyPatterns`. Has no effect unless `prompt` is also set. + */ + suspiciousPatterns?: CodemodPatternGroup[]; + /** + * Patterns to detect only inside string/template fragments of source files + * for LLM-assisted review. Use this when source strings normally remain + * masked for `suspiciousPatterns`, but embedded code snippets may still need + * manual migration. Has no effect unless `prompt` is also set. + */ + sourceStringSuspiciousPatterns?: CodemodPatternGroup[]; + /** + * Prompt that instructs an LLM how to finish the migration for files matched + * by `suspiciousPatterns` or `sourceStringSuspiciousPatterns`. + */ + prompt?: string; + /** Codemod ids whose LLM review prompt supersedes this prompt when both are selected. */ + reviewSupersededBy?: string[]; + /** Before/after examples shown in the generated migration doc. */ + examples?: CodemodExample[]; + /** + * Marks an informational behavioral change (a runtime/CLI change with no + * source to migrate), not a migration. Rendered in a separate "Behavioral + * changes" section, never on the automation-level axis. + */ + notice?: boolean; +} + +/** A specific location that needs manual or LLM-assisted migration review. */ +export interface LlmReviewFinding { + /** File path relative to the transformed project root. */ + file: string; + /** One-based line number in the post-transform file content. */ + line: number; + /** Short reason this location needs review. */ + message: string; + /** Trimmed source line or nearby expression for local context. */ + excerpt: string; +} + +/** Detector exported by a transform module for precise review locations. */ +export type ReviewFindingsFn = ( + source: string, + filePath: string, + relativePath: string, +) => Promise | LlmReviewFinding[]; + +/** A batch of files an LLM should review for one codemod, with its prompt. */ +export interface LlmReview { + /** Codemod id that flagged these files. */ + codemodId: string; + /** Prompt describing the migration for an LLM. */ + prompt: string; + /** Files (relative to the target) that matched a suspicious pattern. */ + files: string[]; + /** Optional file-local findings produced by the codemod script. */ + findings?: LlmReviewFinding[]; } /** * JSON output written to stdout by the sdk-codemod CLI. */ export interface RunOutput { + runner: RunnerMetadata; codemodsApplied: number; codemodsSkipped: number; filesModified: string[]; warnings: string[]; errors: Array<{ codemodId: string; message: string }>; + /** Files flagged for LLM-assisted review, grouped by codemod. */ + llmReviews: LlmReview[]; } diff --git a/packages/sdk-codemod/tsdown.config.ts b/packages/sdk-codemod/tsdown.config.ts index 1eb5e6c315..6548b5bf7e 100644 --- a/packages/sdk-codemod/tsdown.config.ts +++ b/packages/sdk-codemod/tsdown.config.ts @@ -23,12 +23,28 @@ export default defineConfig([ "codemods/v2/test-run-arg-input/scripts/transform.ts", "v2/sdk-skills-shim/scripts/transform": "codemods/v2/sdk-skills-shim/scripts/transform.ts", "v2/principal-unify/scripts/transform": "codemods/v2/principal-unify/scripts/transform.ts", + "v2/auth-attributes-rename/scripts/transform": + "codemods/v2/auth-attributes-rename/scripts/transform.ts", "v2/apply-to-deploy/scripts/transform": "codemods/v2/apply-to-deploy/scripts/transform.ts", "v2/cli-rename/scripts/transform": "codemods/v2/cli-rename/scripts/transform.ts", + "v2/env-var-rename/scripts/transform": "codemods/v2/env-var-rename/scripts/transform.ts", + "v2/auth-invoker-call-unwrap/scripts/transform": + "codemods/v2/auth-invoker-call-unwrap/scripts/transform.ts", "v2/auth-invoker-unwrap/scripts/transform": "codemods/v2/auth-invoker-unwrap/scripts/transform.ts", + "v2/auth-connection-token-helper/scripts/transform": + "codemods/v2/auth-connection-token-helper/scripts/transform.ts", "v2/tailordb-namespace/scripts/transform": "codemods/v2/tailordb-namespace/scripts/transform.ts", + "v2/runtime-globals-opt-in/scripts/transform": + "codemods/v2/runtime-globals-opt-in/scripts/transform.ts", + "v2/execute-script-arg/scripts/transform": + "codemods/v2/execute-script-arg/scripts/transform.ts", + "v2/tailor-output-ignore-dir/scripts/transform": + "codemods/v2/tailor-output-ignore-dir/scripts/transform.ts", + "v2/rename-bin/scripts/transform": "codemods/v2/rename-bin/scripts/transform.ts", + "v2/wait-point-rename/scripts/transform": + "codemods/v2/wait-point-rename/scripts/transform.ts", }, format: ["esm"], target: "node18", diff --git a/packages/sdk/.oxlintrc.json b/packages/sdk/.oxlintrc.json index 71a2dbfdb3..670e523884 100644 --- a/packages/sdk/.oxlintrc.json +++ b/packages/sdk/.oxlintrc.json @@ -11,7 +11,7 @@ "dist/", "src/types/*.generated.ts", "e2e/fixtures/", - ".tailor-sdk/", + ".tailor/", "tailor.d.ts", "plugin-defined.d.ts", "**/__test_fixtures__/dist/", @@ -89,7 +89,10 @@ "typescript/no-unnecessary-type-constraint": "error", "typescript/no-unsafe-function-type": "error", "local/no-deprecated-type-matcher": "error", - "local/require-param-strict": "error" + "local/require-param-strict": "error", + "tailor-zod/require-object-policy-comment": "error", + "zod/prefer-loose-object": "error", + "zod/prefer-strict-object": "error" }, "overrides": [ { @@ -466,5 +469,9 @@ "options": { "typeAware": true }, - "jsPlugins": ["./oxlint-plugins/index.js"] + "jsPlugins": [ + "./oxlint-plugins/index.js", + "eslint-plugin-zod", + "../../scripts/oxlint/tailor-zod-plugin.cjs" + ] } diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 38c0246fc0..83579146a1 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -1,5 +1,155 @@ # @tailor-platform/sdk +## 2.0.0-next.2 +### Major Changes + + + +- [#1476](https://github.com/tailor-platform/sdk/pull/1476) [`fa83075`](https://github.com/tailor-platform/sdk/commit/fa83075f5e0e91085c0ef0cb44b7058a28a79ec3) Thanks [@toiroakr](https://github.com/toiroakr)! - `executeScript` now takes its `arg` as a JSON-serializable value instead of a pre-serialized JSON string. Pass the value directly (e.g. `arg: { a: 1 }`) instead of `arg: JSON.stringify({ a: 1 })`. + + Add the `v2/execute-script-arg` codemod, which unwraps `JSON.stringify(...)` passed as the `executeScript` `arg` option. Indirect forms (a stringified value held in a variable, etc.) cannot be rewritten automatically and are surfaced as an LLM-assisted review task with a migration prompt. + + +- [#1509](https://github.com/tailor-platform/sdk/pull/1509) [`7cadaa7`](https://github.com/tailor-platform/sdk/commit/7cadaa7c4987b81130ca80ba80bc5d5b26276394) Thanks [@dqn](https://github.com/dqn)! - Rename resolver, executor, workflow trigger, and typed workflow start machine-user options from `authInvoker` to `invoker`. + + Update create-sdk templates and the v2 auth invoker codemod to generate the new `invoker` option. + + +- [#1492](https://github.com/tailor-platform/sdk/pull/1492) [`41774d1`](https://github.com/tailor-platform/sdk/commit/41774d175d6e42ecce9fd0be458e1fea199fe78a) Thanks [@dqn](https://github.com/dqn)! - Store CLI login tokens in the OS keyring by default when available. If the keyring is unavailable, tokens are stored in the platform config file. + + + +- [#1484](https://github.com/tailor-platform/sdk/pull/1484) [`a376dc8`](https://github.com/tailor-platform/sdk/commit/a376dc8cd053d20744c90104e8b44ed2729ffe8c) Thanks [@dqn](https://github.com/dqn)! - Remove the deprecated `openDownloadStream` file streaming API. Use `downloadStream` for streamed file downloads. + + The generated file utilities now emit `downloadFileStream`, which calls `downloadStream` and returns `FileDownloadStreamResponse`, instead of the removed `openFileDownloadStream` helper. + + +- [#1510](https://github.com/tailor-platform/sdk/pull/1510) [`41809c7`](https://github.com/tailor-platform/sdk/commit/41809c75ca0f52d0872e55be095e0b73d026c622) Thanks [@dqn](https://github.com/dqn)! - Remove the deprecated workflow test env fallback. `WORKFLOW_TEST_ENV_KEY` is no longer exported, and `TAILOR_TEST_WORKFLOW_ENV` is no longer read when running workflows locally. Use `mockWorkflow().setEnv(...)` or pass `{ env }` to `runWorkflowLocally(...)` instead. + + + +- [#1439](https://github.com/tailor-platform/sdk/pull/1439) [`c5b10d2`](https://github.com/tailor-platform/sdk/commit/c5b10d2841ded08927285bce538c05220cde5e4c) Thanks [@dqn](https://github.com/dqn)! - Unify function principal context around `TailorPrincipal`. + + Resolver contexts now use `caller` and `invoker` as `TailorPrincipal | null`, workflow and executor invokers also use `TailorPrincipal | null`, and event executor `actor` uses `TailorPrincipal | null` with `id`/`type` fields. The legacy `TailorUser`, `TailorInvoker`, `TailorActor`, `TailorActorType`, and `unauthenticatedTailorUser` exports are removed. + + +- [#1498](https://github.com/tailor-platform/sdk/pull/1498) [`83145db`](https://github.com/tailor-platform/sdk/commit/83145db9a0d243aa68c1b641c2b6026771a62188) Thanks [@dqn](https://github.com/dqn)! - Set `db.fields.timestamps()` `updatedAt` when records are created and make the generated field non-null. `createdAt` keeps its existing create-time behavior, while `updatedAt` keeps its update-time behavior and now also gets a create hook that preserves provided values and falls back to the current time. + + Update create-sdk templates so scaffolded projects use the new non-null `updatedAt` Kysely types and seed schemas. + + Existing TailorDB schemas that already use this helper will change `updatedAt` from optional to required. Backfill existing records that have `updatedAt: null` before applying the schema change. + +### Patch Changes + + + +- [#1495](https://github.com/tailor-platform/sdk/pull/1495) [`6234022`](https://github.com/tailor-platform/sdk/commit/6234022d7dc03813b8dade831b86f63a5f7a20e6) Thanks [@toiroakr](https://github.com/toiroakr)! - Generate the v2 migration guide (`packages/sdk/docs/migration/v2.md`) from the codemod registry, which is the single source of truth. Each entry renders its name, automation level (Automatic / Partially automatic / Manual), description, optional before/after `examples`, and — for changes the codemods cannot fully migrate on their own — the LLM/manual migration prompt. Run `pnpm codemod:docs:update` to regenerate and `pnpm codemod:docs:check` (wired into `pnpm check`) to verify it is in sync. + + `scriptPath` is now optional, so the registry can also describe codemod-less ("manual") migrations that ship only guidance (`examples` / `prompt` / `suspiciousPatterns`) with no automatic transform. A manual entry with a `prompt` but no scoping pattern is surfaced as a project-wide `llmReviews` entry at runtime. + + Add a `sdk-codemod list` command that prints every registered rule (id, name, kind, version range). + + +- [#1516](https://github.com/tailor-platform/sdk/pull/1516) [`ec752bd`](https://github.com/tailor-platform/sdk/commit/ec752bd38f7dec817f7e3b4d2d5468e7320050e0) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency undici to v8.5.0 [security] + + + +- [#1525](https://github.com/tailor-platform/sdk/pull/1525) [`425a19d`](https://github.com/tailor-platform/sdk/commit/425a19dd58da6e373b739d3b3e838c2ff3d1736a) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency semver to v7.8.5 + +## 2.0.0-next.1 +### Major Changes + + + +- [#1442](https://github.com/tailor-platform/sdk/pull/1442) [`07cc256`](https://github.com/tailor-platform/sdk/commit/07cc256b9f1d695694d67438e1b0cb6df096ece8) Thanks [@dqn](https://github.com/dqn)! - Remove the deprecated `auth.invoker("")` helper. Pass machine user names directly as `authInvoker` strings in resolver, executor, and workflow APIs. + + + +- [#1440](https://github.com/tailor-platform/sdk/pull/1440) [`f0895f4`](https://github.com/tailor-platform/sdk/commit/f0895f4231578e54229004d3aa5bac6bd24361e3) Thanks [@dqn](https://github.com/dqn)! - Remove `defineGenerators()` and legacy `generators` config support. Use `definePlugins()` with the built-in plugin packages for code generation. + + + +- [#1441](https://github.com/tailor-platform/sdk/pull/1441) [`7604ad5`](https://github.com/tailor-platform/sdk/commit/7604ad5fdf27b791e8f1db880faed156cd6554d5) Thanks [@dqn](https://github.com/dqn)! - Remove support for wrapping `tailor-sdk function test-run --arg` resolver input in an `input` object. Pass resolver input fields directly as the `--arg` JSON value. + + + +- [#1460](https://github.com/tailor-platform/sdk/pull/1460) [`f49c6d1`](https://github.com/tailor-platform/sdk/commit/f49c6d1b5a856969cb4e04ae7d3a87ed34aa020f) Thanks [@dqn](https://github.com/dqn)! - Remove the v1 runtime globals compatibility layer. Importing from `@tailor-platform/sdk` no longer activates the ambient `tailor.*` / `tailordb.*` declarations; opt into globals with `@tailor-platform/sdk/runtime/globals` or use the typed wrappers from `@tailor-platform/sdk/runtime`. + + The capital-cased `Tailordb.*` namespace is removed. If your project still references `Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, or `typeof Tailordb.Client`, migrate before upgrading: run `pnpm dlx @tailor-platform/sdk-codemod v2/tailordb-namespace` to rewrite them to lowercase `tailordb.*`, then add `import "@tailor-platform/sdk/runtime/globals"` so the rewritten references resolve. + + +- [#1459](https://github.com/tailor-platform/sdk/pull/1459) [`2c3d7ad`](https://github.com/tailor-platform/sdk/commit/2c3d7add213b171df2959b8a14e8dc2e3c3a7ec7) Thanks [@dqn](https://github.com/dqn)! - Remove the deprecated `tailor-sdk-skills` binary shim. Use `tailor-sdk skills install` to install the bundled Tailor SDK agent skill. + + + +- [#1457](https://github.com/tailor-platform/sdk/pull/1457) [`84325f8`](https://github.com/tailor-platform/sdk/commit/84325f8602a5631b7c323c997b1425235509920e) Thanks [@dqn](https://github.com/dqn)! - Remove deprecated CLI aliases for the v2 command surface. Use `tailor-sdk deploy` instead of `tailor-sdk apply`, `tailor-sdk crashreport` instead of `tailor-sdk crash-report`, and the hyphenated `--machine-user` option instead of the hidden `--machineuser` alias. + + Fix the v2 CLI rename codemod to migrate the hidden `--machineuser` option to `--machine-user`. + + +- [#1462](https://github.com/tailor-platform/sdk/pull/1462) [`77e7f45`](https://github.com/tailor-platform/sdk/commit/77e7f4512ddd141a8858ab37a3c48d8ad7e16543) Thanks [@dqn](https://github.com/dqn)! - Require `FunctionExecution.contentHash` for `function logs` stack trace source mapping. Executions without a content hash now show raw stack traces instead of mapping against the current function bundle. + + + +- [#1458](https://github.com/tailor-platform/sdk/pull/1458) [`ad04913`](https://github.com/tailor-platform/sdk/commit/ad049131d61dfc9f96bff8ffe7c5e5e261523b14) Thanks [@dqn](https://github.com/dqn)! - Store CLI human users by stable subject ID in the platform config instead of email, while preserving email for display and migrating legacy email-keyed entries on login or token refresh. + + + +- [#1438](https://github.com/tailor-platform/sdk/pull/1438) [`2c552cd`](https://github.com/tailor-platform/sdk/commit/2c552cd716f9abbe90ec4b22e51d0cde155c2bf9) Thanks [@dqn](https://github.com/dqn)! - Align workflow job `.trigger()` with the platform runtime. Job triggers now require a mocked workflow runtime in tests instead of running job bodies locally, and `trigger()` returns the job result directly instead of a Promise wrapper. Use `mockWorkflow()` to mock trigger results in tests, or `runWorkflowLocally()` for full-chain local workflow tests. + + +### Patch Changes + + + +- [#1425](https://github.com/tailor-platform/sdk/pull/1425) [`644dca8`](https://github.com/tailor-platform/sdk/commit/644dca8ee631ff18550646d3d82bad76fba6bc33) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency graphql to v16.14.2 + + + +- [#1429](https://github.com/tailor-platform/sdk/pull/1429) [`b933f29`](https://github.com/tailor-platform/sdk/commit/b933f291b99efb3668077cd7870abe979dc3b10b) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update rolldown to v1.1.1 + + + +- [#1433](https://github.com/tailor-platform/sdk/pull/1433) [`4d07f2f`](https://github.com/tailor-platform/sdk/commit/4d07f2fd814bda5886c8f0c9546f21128dcce74b) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update dependency es-toolkit to v1.47.1 + + + +- [#1448](https://github.com/tailor-platform/sdk/pull/1448) [`4ce01dd`](https://github.com/tailor-platform/sdk/commit/4ce01dd851dae7754103a918ba89afe073f942c6) Thanks [@renovate](https://github.com/apps/renovate)! - fix(deps): update @connectrpc to v2.1.2 + + + +- [#1390](https://github.com/tailor-platform/sdk/pull/1390) [`388f3d6`](https://github.com/tailor-platform/sdk/commit/388f3d69b722589cd5cca7bbbc3667a849ecaa3b) Thanks [@toiroakr](https://github.com/toiroakr)! - Guarantee that importing the SDK never loads zod in user projects — neither zod runtime code in bundled functions nor zod type computation in tsc. Internal type definitions are reorganized into per-layer pure type modules, and new CI checks verify every user-facing entry point stays zod-free at both the type and runtime level and that the internal module graph has no import cycles. + +## 2.0.0-next.0 +### Major Changes + + + +- [#1451](https://github.com/tailor-platform/sdk/pull/1451) [`3e6d582`](https://github.com/tailor-platform/sdk/commit/3e6d582a37d83a42302339cc4aea1d3dd11e8a81) Thanks [@tailor-platform-pr-trigger](https://github.com/apps/tailor-platform-pr-trigger)! - Start the v2 release line. v2 introduces breaking changes to the SDK API and CLI; run `tailor-sdk upgrade` to apply the bundled codemods when migrating. Prereleases are published to the `next` dist-tag — install with `@tailor-platform/sdk@next`. + + +### Patch Changes + + + +- [#1449](https://github.com/tailor-platform/sdk/pull/1449) [`016aff6`](https://github.com/tailor-platform/sdk/commit/016aff6aab31c334c57a5e5244453f2dd559c008) Thanks [@k1LoW](https://github.com/k1LoW)! - Document the `userAuthPolicy`, `gqlOperations`, and `lang` options of `defineIdp()` in the IdP service guide, including the password policy fields, allowed email domains, Google/Microsoft social login, the read-only `"query"` shortcut, and the cross-field validation constraints. + + + +- [#1450](https://github.com/tailor-platform/sdk/pull/1450) [`162ba62`](https://github.com/tailor-platform/sdk/commit/162ba629e0d511593718f289b93788d5d56778da) Thanks [@toiroakr](https://github.com/toiroakr)! - Update OpenTelemetry runtime dependencies to 2.8.0 to resolve a moderate security advisory (GHSA-8988-4f7v-96qf) in `@opentelemetry/core` + + + +- [#1432](https://github.com/tailor-platform/sdk/pull/1432) [`3a854a3`](https://github.com/tailor-platform/sdk/commit/3a854a3a10b938ce3cf6fe7527de4ab56ecf48d5) Thanks [@toiroakr](https://github.com/toiroakr)! - Roll back a migration's pre-migration schema changes when its data migration (`migrate.ts`) fails during `apply`. A failed migration now leaves the workspace at its prior checkpoint and prior schema instead of half-applied, so subsequent deploys are no longer blocked by opaque "Remote schema drift detected" errors. + + + +- [#1422](https://github.com/tailor-platform/sdk/pull/1422) [`f3f8427`](https://github.com/tailor-platform/sdk/commit/f3f84277fe1942601d0fcbb8a64c2c26823b5624) Thanks [@dqn](https://github.com/dqn)! - Internal cleanup of proto field optionality handling. No behavior change. + + + +- [#1421](https://github.com/tailor-platform/sdk/pull/1421) [`b933f47`](https://github.com/tailor-platform/sdk/commit/b933f474d65f8dfed56f3991aae3a52589368b10) Thanks [@dqn](https://github.com/dqn)! - Corrupted or hand-edited TailorDB migration snapshot/diff files now fail with a clear validation error when loaded, instead of causing undefined behavior later. + ## 1.74.0 ### Minor Changes diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 71e4bc1a7f..e604aa1917 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -50,13 +50,13 @@ For more details, see the [Quickstart Guide](./docs/quickstart.md). ## Agent Skill -Install the `tailor-sdk` skill from the locally installed SDK package: +Install the `tailor` skill from the locally installed SDK package: ```bash -npx tailor-sdk skills install +npx tailor skills install # Example: install to Codex in non-interactive mode -npx tailor-sdk skills install -a codex -y +npx tailor skills install -a codex -y ``` This uses the `skills` CLI under the hood, sourcing the skill from @@ -96,5 +96,5 @@ See [Create Tailor Platform SDK](https://github.com/tailor-platform/sdk/tree/mai ## Requirements -- Node.js 22 or later (or Bun) +- Node.js 22.15.0 or later (or Bun) - A Tailor Platform account ([request access](https://www.tailor.tech/demo)) diff --git a/packages/sdk/agent-skills/tailor-sdk/SKILL.md b/packages/sdk/agent-skills/tailor/SKILL.md similarity index 98% rename from packages/sdk/agent-skills/tailor-sdk/SKILL.md rename to packages/sdk/agent-skills/tailor/SKILL.md index 7d1dcdcf2d..68c54ad5b1 100644 --- a/packages/sdk/agent-skills/tailor-sdk/SKILL.md +++ b/packages/sdk/agent-skills/tailor/SKILL.md @@ -1,5 +1,5 @@ --- -name: tailor-sdk +name: tailor description: Use this skill when working with @tailor-platform/sdk projects, including service configuration, CLI usage, and docs navigation. --- diff --git a/packages/sdk/docs/cli-reference.md b/packages/sdk/docs/cli-reference.md index bcfc902981..38948c1f92 100644 --- a/packages/sdk/docs/cli-reference.md +++ b/packages/sdk/docs/cli-reference.md @@ -1,11 +1,11 @@ -# tailor-sdk +# tailor Tailor Platform SDK - The SDK to work with Tailor Platform ## Usage ```bash -tailor-sdk [options] +tailor [options] ``` ## Global Options @@ -38,7 +38,7 @@ The following options are available for most commands: | ---------------- | ----- | -------------------------------------- | | `--workspace-id` | `-w` | Workspace ID (for deployment commands) | | `--profile` | `-p` | Workspace profile | -| `--config` | `-c` | Path to SDK config file | +| `--config` | `-c` | Path to Tailor config file | | `--yes` | `-y` | Skip confirmation prompts | ### Environment File Loading @@ -51,10 +51,10 @@ Both `--env-file` and `--env-file-if-exists` can be specified multiple times and ```bash # Load .env (required) and .env.local (optional, if exists) -tailor-sdk deploy --env-file .env --env-file-if-exists .env.local +tailor deploy --env-file .env --env-file-if-exists .env.local # Load multiple files -tailor-sdk deploy --env-file .env --env-file .env.production +tailor deploy --env-file .env --env-file .env.production ``` ## Environment Variables @@ -67,10 +67,9 @@ You can use environment variables to configure workspace and authentication: | `TAILOR_PLATFORM_ORGANIZATION_ID` | Organization ID for organization commands | | `TAILOR_PLATFORM_FOLDER_ID` | Folder ID for folder commands | | `TAILOR_PLATFORM_TOKEN` | Authentication token (alternative to `login`) | -| `TAILOR_TOKEN` | **Deprecated.** Use `TAILOR_PLATFORM_TOKEN` instead | | `TAILOR_PLATFORM_PROFILE` | Workspace profile name | -| `TAILOR_PLATFORM_SDK_CONFIG_PATH` | Path to SDK config file | -| `TAILOR_PLATFORM_SDK_DTS_PATH` | Output path for generated `tailor.d.ts` type definition file | +| `TAILOR_CONFIG_PATH` | Path to Tailor config file | +| `TAILOR_DTS_PATH` | Output path for generated `tailor.d.ts` type definition file | | `TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID` | Client ID for `login --machine-user` | | `TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET` | Client secret for `login --machine-user` | | `TAILOR_PLATFORM_MACHINE_USER_NAME` | Default machine user name for `query`, `workflow start`, `function test-run`, `machineuser token` | @@ -88,9 +87,8 @@ You can use environment variables to configure workspace and authentication: Token resolution follows this priority order: 1. `TAILOR_PLATFORM_TOKEN` environment variable -2. `TAILOR_TOKEN` environment variable (deprecated) -3. Profile specified via `--profile` option or `TAILOR_PLATFORM_PROFILE` -4. Current user from platform config (`~/.config/tailor-platform/config.yaml`) +2. Profile specified via `--profile` option or `TAILOR_PLATFORM_PROFILE` +3. Current user from platform config (`~/.config/tailor-platform/config.yaml`) Config-backed login tokens are scoped to the Platform API URL. Profiles with `--platform-url` use the token saved for that URL, so switching profiles can also switch between Platform API environments. @@ -102,6 +100,68 @@ Workspace ID resolution follows this priority order: 2. `TAILOR_PLATFORM_WORKSPACE_ID` environment variable 3. Profile specified via `--profile` option or `TAILOR_PLATFORM_PROFILE` +## CLI Plugins + +> [!WARNING] +> CLI plugins are a **beta** feature. The dispatch behavior and the set of injected environment +> variables may change in a future release. + +You can extend the CLI with external plugins, similar to `gh` extensions. When you run a command that +is not a built-in, the CLI looks for an executable named `tailor-` and runs it, forwarding the +remaining arguments: + +```bash +# Runs the `tailor-hello` executable with: world --loud +tailor hello world --loud +``` + +This also works under a built-in command group. The command path is joined with hyphens, so a plugin +nested under `tailordb` is named `tailor-tailordb-erd`: + +```bash +# Runs `tailor-tailordb-erd` with: export +tailor tailordb erd export +``` + +Resolution rules: + +- **Built-ins always win.** A plugin is only used when no built-in command matches. +- **A command that takes its own arguments is never replaced.** Plugin dispatch applies only to command + _groups_ (commands that just route to subcommands). A command that performs its own action — including + one that accepts a positional argument — always runs itself, so a plugin can never shadow an argument value. +- **Lookup order:** the project's `node_modules/.bin` (nearest first, walking up from the current + directory), then your `PATH`. So a plugin installed as a project dev-dependency takes precedence over a + globally installed one. + +Because resolution is based on `node_modules/.bin` and `PATH`, any package manager that populates +`node_modules/.bin` works for project-local plugins — npm, pnpm (its content-addressable store is +transparent here), Bun, and Yarn Classic. The exception is **Yarn Plug'n'Play**, which does not create a +`node_modules` directory: install such plugins globally so they resolve via `PATH`, or use Yarn's +`nodeLinker: node-modules` setting. + +Run `tailor plugin list` to see which plugins are discovered and where they resolve from. + +### Context passed to plugins + +Before running a plugin, the CLI injects the current Tailor Platform context as environment variables so +the plugin does not need to re-implement authentication or re-resolve the active workspace: + +| Variable | Description | +| ---------------------------------- | ------------------------------------------------------------------------ | +| `TAILOR_PLATFORM_TOKEN` | A valid access token (refreshed if needed). Omitted when not logged in. | +| `TAILOR_PLATFORM_URL` | The Tailor Platform endpoint in effect | +| `TAILOR_PLATFORM_OAUTH2_CLIENT_ID` | The OAuth2 client ID in effect, for plugins that run their own auth flow | +| `TAILOR_PLATFORM_WORKSPACE_ID` | The resolved workspace ID, when one can be determined | +| `TAILOR_PLATFORM_USER` | The active user (email when known), when logged in | +| `TAILOR_CONFIG_PATH` | Path to the resolved Tailor config file, when found | +| `TAILOR_VERSION` | The `tailor` version that invoked the plugin | +| `TAILOR_BIN` | Path to the `tailor` executable, for calling back into the CLI | + +The token, workspace ID, and user are best-effort: whatever the current context can resolve is injected, +and auth-free plugins still run when you are not logged in. A long-running plugin (or one started on its +own) can obtain a fresh token at any time with `tailor auth token`, which prints a valid access token to +stdout, refreshing it first if it has expired. + ## Commands ### [Application Commands](./cli/application.md) @@ -152,19 +212,21 @@ Run ad-hoc SQL/GraphQL queries or enter the interactive REPL. Commands for authentication and user management. -| Command | Description | -| ------------------------------------------------ | ----------------------------------------------------- | -| [login](./cli/user.md#login) | Login to Tailor Platform. | -| [logout](./cli/user.md#logout) | Logout from Tailor Platform. | -| [user](./cli/user.md#user) | Manage Tailor Platform users. | -| [user current](./cli/user.md#user-current) | Show current user. | -| [user list](./cli/user.md#user-list) | List all users. | -| [user pat](./cli/user.md#user-pat) | Manage personal access tokens. | -| [user pat create](./cli/user.md#user-pat-create) | Create a new personal access token. | -| [user pat delete](./cli/user.md#user-pat-delete) | Delete a personal access token. | -| [user pat list](./cli/user.md#user-pat-list) | List all personal access tokens. | -| [user pat update](./cli/user.md#user-pat-update) | Update a personal access token (delete and recreate). | -| [user switch](./cli/user.md#user-switch) | Set current user. | +| Command | Description | +| ------------------------------------------------ | ------------------------------------------------------------------------------------- | +| [login](./cli/user.md#login) | Login to Tailor Platform. | +| [logout](./cli/user.md#logout) | Logout from Tailor Platform. | +| [auth](./cli/user.md#auth) | Authentication helpers for scripts and plugins. | +| [auth token](./cli/user.md#auth-token) | Print a valid Tailor Platform access token to stdout, refreshing it first if expired. | +| [user](./cli/user.md#user) | Manage Tailor Platform users. | +| [user current](./cli/user.md#user-current) | Show current user. | +| [user list](./cli/user.md#user-list) | List all users. | +| [user pat](./cli/user.md#user-pat) | Manage personal access tokens. | +| [user pat create](./cli/user.md#user-pat-create) | Create a new personal access token. | +| [user pat delete](./cli/user.md#user-pat-delete) | Delete a personal access token. | +| [user pat list](./cli/user.md#user-pat-list) | List all personal access tokens. | +| [user pat update](./cli/user.md#user-pat-update) | Update a personal access token (delete and recreate). | +| [user switch](./cli/user.md#user-switch) | Set current user. | ### [Organization Commands](./cli/organization.md) @@ -320,7 +382,7 @@ Commands for setting up project infrastructure. | [setup branch](./cli/setup.md#setup-branch) | Generate a branch-target deploy workflow (push to branch triggers deploy). | | [setup check](./cli/setup.md#setup-check) | Audit generated workflows for drift against the current config/repo (read-only). | | [setup coordinate](./cli/setup.md#setup-coordinate) | Generate a coordinator workflow that orchestrates multiple --action-generated composite actions. | -| [setup delete](./cli/setup.md#setup-delete) | Delete managed workflow/action file(s) and their .github/tailor-sdk.lock entries. | +| [setup delete](./cli/setup.md#setup-delete) | Delete managed workflow/action file(s) and their .github/tailor.lock entries. | | [setup preview](./cli/setup.md#setup-preview) | Generate a preview workflow (PR open/sync triggers deploy to a per-PR workspace). | | [setup tag](./cli/setup.md#setup-tag) | Generate a tag-target deploy workflow (tag push triggers deploy). | @@ -336,10 +398,19 @@ Commands for upgrading SDK versions with automated code migration. Commands for installing Tailor SDK agent skills. -| Command | Description | -| ------------------------------------------------ | ------------------------------------------------------------------ | -| [skills](./cli/skills.md#skills) | Manage Tailor SDK agent skills. | -| [skills install](./cli/skills.md#skills-install) | Install the tailor-sdk agent skill from the installed SDK package. | +| Command | Description | +| ------------------------------------------------ | -------------------------------------------------------------- | +| [skills](./cli/skills.md#skills) | Manage Tailor SDK agent skills. | +| [skills install](./cli/skills.md#skills-install) | Install the tailor agent skill from the installed SDK package. | + +### [Plugin Commands](./cli/plugin.md) + +Discover and inspect CLI plugins (external `tailor-` executables). + +| Command | Description | +| ------------------------------------------ | ---------------------------------------------------------------------------------------- | +| [plugin](./cli/plugin.md#plugin) | Manage and inspect CLI plugins (beta). | +| [plugin list](./cli/plugin.md#plugin-list) | List discovered plugins (executables named `-` on PATH or node_modules/.bin). | ### [Completion](./cli/completion.md) diff --git a/packages/sdk/docs/cli-reference.template.md b/packages/sdk/docs/cli-reference.template.md index 84f4ac70f8..ca7af97839 100644 --- a/packages/sdk/docs/cli-reference.template.md +++ b/packages/sdk/docs/cli-reference.template.md @@ -1,11 +1,11 @@ -# tailor-sdk +# tailor Tailor Platform SDK - The SDK to work with Tailor Platform ## Usage ```bash -tailor-sdk [options] +tailor [options] ``` ## Global Options @@ -32,7 +32,7 @@ The following options are available for most commands: | ---------------- | ----- | -------------------------------------- | | `--workspace-id` | `-w` | Workspace ID (for deployment commands) | | `--profile` | `-p` | Workspace profile | -| `--config` | `-c` | Path to SDK config file | +| `--config` | `-c` | Path to Tailor config file | | `--yes` | `-y` | Skip confirmation prompts | ### Environment File Loading @@ -45,10 +45,10 @@ Both `--env-file` and `--env-file-if-exists` can be specified multiple times and ```bash # Load .env (required) and .env.local (optional, if exists) -tailor-sdk deploy --env-file .env --env-file-if-exists .env.local +tailor deploy --env-file .env --env-file-if-exists .env.local # Load multiple files -tailor-sdk deploy --env-file .env --env-file .env.production +tailor deploy --env-file .env --env-file .env.production ``` ## Environment Variables @@ -61,10 +61,9 @@ You can use environment variables to configure workspace and authentication: | `TAILOR_PLATFORM_ORGANIZATION_ID` | Organization ID for organization commands | | `TAILOR_PLATFORM_FOLDER_ID` | Folder ID for folder commands | | `TAILOR_PLATFORM_TOKEN` | Authentication token (alternative to `login`) | -| `TAILOR_TOKEN` | **Deprecated.** Use `TAILOR_PLATFORM_TOKEN` instead | | `TAILOR_PLATFORM_PROFILE` | Workspace profile name | -| `TAILOR_PLATFORM_SDK_CONFIG_PATH` | Path to SDK config file | -| `TAILOR_PLATFORM_SDK_DTS_PATH` | Output path for generated `tailor.d.ts` type definition file | +| `TAILOR_CONFIG_PATH` | Path to Tailor config file | +| `TAILOR_DTS_PATH` | Output path for generated `tailor.d.ts` type definition file | | `TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID` | Client ID for `login --machine-user` | | `TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET` | Client secret for `login --machine-user` | | `TAILOR_PLATFORM_MACHINE_USER_NAME` | Default machine user name for `query`, `workflow start`, `function test-run`, `machineuser token` | @@ -82,9 +81,8 @@ You can use environment variables to configure workspace and authentication: Token resolution follows this priority order: 1. `TAILOR_PLATFORM_TOKEN` environment variable -2. `TAILOR_TOKEN` environment variable (deprecated) -3. Profile specified via `--profile` option or `TAILOR_PLATFORM_PROFILE` -4. Current user from platform config (`~/.config/tailor-platform/config.yaml`) +2. Profile specified via `--profile` option or `TAILOR_PLATFORM_PROFILE` +3. Current user from platform config (`~/.config/tailor-platform/config.yaml`) Config-backed login tokens are scoped to the Platform API URL. Profiles with `--platform-url` use the token saved for that URL, so switching profiles can also switch between Platform API environments. @@ -96,6 +94,68 @@ Workspace ID resolution follows this priority order: 2. `TAILOR_PLATFORM_WORKSPACE_ID` environment variable 3. Profile specified via `--profile` option or `TAILOR_PLATFORM_PROFILE` +## CLI Plugins + +> [!WARNING] +> CLI plugins are a **beta** feature. The dispatch behavior and the set of injected environment +> variables may change in a future release. + +You can extend the CLI with external plugins, similar to `gh` extensions. When you run a command that +is not a built-in, the CLI looks for an executable named `tailor-` and runs it, forwarding the +remaining arguments: + +```bash +# Runs the `tailor-hello` executable with: world --loud +tailor hello world --loud +``` + +This also works under a built-in command group. The command path is joined with hyphens, so a plugin +nested under `tailordb` is named `tailor-tailordb-erd`: + +```bash +# Runs `tailor-tailordb-erd` with: export +tailor tailordb erd export +``` + +Resolution rules: + +- **Built-ins always win.** A plugin is only used when no built-in command matches. +- **A command that takes its own arguments is never replaced.** Plugin dispatch applies only to command + _groups_ (commands that just route to subcommands). A command that performs its own action — including + one that accepts a positional argument — always runs itself, so a plugin can never shadow an argument value. +- **Lookup order:** the project's `node_modules/.bin` (nearest first, walking up from the current + directory), then your `PATH`. So a plugin installed as a project dev-dependency takes precedence over a + globally installed one. + +Because resolution is based on `node_modules/.bin` and `PATH`, any package manager that populates +`node_modules/.bin` works for project-local plugins — npm, pnpm (its content-addressable store is +transparent here), Bun, and Yarn Classic. The exception is **Yarn Plug'n'Play**, which does not create a +`node_modules` directory: install such plugins globally so they resolve via `PATH`, or use Yarn's +`nodeLinker: node-modules` setting. + +Run `tailor plugin list` to see which plugins are discovered and where they resolve from. + +### Context passed to plugins + +Before running a plugin, the CLI injects the current Tailor Platform context as environment variables so +the plugin does not need to re-implement authentication or re-resolve the active workspace: + +| Variable | Description | +| ---------------------------------- | ------------------------------------------------------------------------ | +| `TAILOR_PLATFORM_TOKEN` | A valid access token (refreshed if needed). Omitted when not logged in. | +| `TAILOR_PLATFORM_URL` | The Tailor Platform endpoint in effect | +| `TAILOR_PLATFORM_OAUTH2_CLIENT_ID` | The OAuth2 client ID in effect, for plugins that run their own auth flow | +| `TAILOR_PLATFORM_WORKSPACE_ID` | The resolved workspace ID, when one can be determined | +| `TAILOR_PLATFORM_USER` | The active user (email when known), when logged in | +| `TAILOR_CONFIG_PATH` | Path to the resolved Tailor config file, when found | +| `TAILOR_VERSION` | The `tailor` version that invoked the plugin | +| `TAILOR_BIN` | Path to the `tailor` executable, for calling back into the CLI | + +The token, workspace ID, and user are best-effort: whatever the current context can resolve is injected, +and auth-free plugins still run when you are not logged in. A long-running plugin (or one started on its +own) can obtain a fresh token at any time with `tailor auth token`, which prints a valid access token to +stdout, refreshing it first if it has expired. + ## Commands {{politty:index}} diff --git a/packages/sdk/docs/cli/application.md b/packages/sdk/docs/cli/application.md index dd2288ad35..52a6d69438 100644 --- a/packages/sdk/docs/cli/application.md +++ b/packages/sdk/docs/cli/application.md @@ -9,7 +9,7 @@ Initialize a new project using create-sdk. **Usage** ``` -tailor-sdk init [options] [name] +tailor init [options] [name] ``` **Arguments** @@ -33,7 +33,7 @@ Generate files using Tailor configuration. **Usage** ``` -tailor-sdk generate [options] +tailor generate [options] ``` **Options** @@ -49,12 +49,10 @@ See [Global Options](../cli-reference.md#global-options) for options available t Deploy your application by applying the Tailor configuration. -**Aliases:** `apply` - **Usage** ``` -tailor-sdk deploy [options] +tailor deploy [options] ``` **Options** @@ -81,7 +79,7 @@ On first run, `deploy` automatically injects a stable `id: ""` field into To deploy interdependent applications to the same workspace in one run, pass comma-separated config paths: ```bash -tailor-sdk deploy --config apps/buyer/tailor.config.ts,apps/supplier/tailor.config.ts +tailor deploy --config apps/buyer/tailor.config.ts,apps/supplier/tailor.config.ts ``` When multiple configs are provided, `deploy` creates or updates all configured services first, then updates the applications. This lets one application reference resources owned by another config with `external: true` during the same deploy. @@ -126,7 +124,7 @@ Plan: 5 to create, 3 to update, 1 to delete Use `--dry-run` to preview the plan without applying anything. In dry-run mode the plan is written to **stdout**, so it can be captured in CI without `2>&1`: ```bash -tailor-sdk deploy --dry-run > plan.txt +tailor deploy --dry-run > plan.txt ``` In apply mode, the plan is printed to stderr so it does not interfere with piped output. @@ -167,17 +165,17 @@ Remove all resources managed by the application from the workspace. **Usage** ``` -tailor-sdk remove [options] +tailor remove [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -188,16 +186,16 @@ Show information about the deployed application. **Usage** ``` -tailor-sdk show [options] +tailor show [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -208,16 +206,16 @@ Open Tailor Platform Console. **Usage** ``` -tailor-sdk open [options] +tailor open [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -228,7 +226,7 @@ Call Tailor Platform API endpoints directly. **Usage** ``` -tailor-sdk api [options] [command] +tailor api [options] [command] ``` **Arguments** @@ -239,13 +237,13 @@ tailor-sdk api [options] [command] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | --------------------------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--body ` | `-b` | Request body as JSON. | No | `"{}"` | - | -| `--field ` | `-f` | Set a body field as `key=value` (repeatable; dotted keys nest). Overrides --body. | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | --------------------------------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--body ` | `-b` | Request body as JSON. | No | `"{}"` | - | +| `--field ` | `-f` | Set a body field as `key=value` (repeatable; dotted keys nest). Overrides --body. | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -261,30 +259,30 @@ See [Global Options](../cli-reference.md#global-options) for options available t **Call an endpoint; workspaceId is auto-injected.** ```bash -$ tailor-sdk api GetApplication -b '{"applicationName":"app-1"}' +$ tailor api GetApplication -b '{"applicationName":"app-1"}' ``` **Same as above, using --field instead of --body.** ```bash -$ tailor-sdk api GetApplication -f applicationName=app-1 +$ tailor api GetApplication -f applicationName=app-1 ``` **List all invocable OperatorService methods.** ```bash -$ tailor-sdk api list +$ tailor api list ``` **Show the input message tree for an endpoint.** ```bash -$ tailor-sdk api inspect GetApplication +$ tailor api inspect GetApplication ``` **Notes** -Use `tailor-sdk api list` to enumerate invocable methods and `tailor-sdk api inspect ` to print an endpoint's input message tree (combine with `--json` for machine-readable output). +Use `tailor api list` to enumerate invocable methods and `tailor api inspect ` to print an endpoint's input message tree (combine with `--json` for machine-readable output). The request body is inferred from the target endpoint's request schema, and commonly required fields are auto-injected so they can be omitted from `--body`: @@ -304,7 +302,7 @@ Print the input message tree of an OperatorService endpoint. **Usage** ``` -tailor-sdk api inspect +tailor api inspect ``` **Arguments** @@ -320,18 +318,18 @@ See [Global Options](../cli-reference.md#global-options) for options available t **Show fields of GetApplicationRequest.** ```bash -$ tailor-sdk api inspect GetApplication +$ tailor api inspect GetApplication ``` **Inspect a deeply nested input with `(oneof config)` annotations.** ```bash -$ tailor-sdk api inspect CreateExecutorExecutor +$ tailor api inspect CreateExecutorExecutor ``` **Notes** -Combine with the global `--json` flag for a machine-readable descriptor. Recursive type references and `oneof` membership are annotated. Use `tailor-sdk api list` to discover endpoint names. +Combine with the global `--json` flag for a machine-readable descriptor. Recursive type references and `oneof` membership are annotated. Use `tailor api list` to discover endpoint names. ### api list @@ -340,7 +338,7 @@ List all invocable OperatorService methods. **Usage** ``` -tailor-sdk api list +tailor api list ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. diff --git a/packages/sdk/docs/cli/application.template.md b/packages/sdk/docs/cli/application.template.md index 27dc0cc8bf..279d1df5f1 100644 --- a/packages/sdk/docs/cli/application.template.md +++ b/packages/sdk/docs/cli/application.template.md @@ -21,7 +21,7 @@ On first run, `deploy` automatically injects a stable `id: ""` field into To deploy interdependent applications to the same workspace in one run, pass comma-separated config paths: ```bash -tailor-sdk deploy --config apps/buyer/tailor.config.ts,apps/supplier/tailor.config.ts +tailor deploy --config apps/buyer/tailor.config.ts,apps/supplier/tailor.config.ts ``` When multiple configs are provided, `deploy` creates or updates all configured services first, then updates the applications. This lets one application reference resources owned by another config with `external: true` during the same deploy. @@ -66,7 +66,7 @@ Plan: 5 to create, 3 to update, 1 to delete Use `--dry-run` to preview the plan without applying anything. In dry-run mode the plan is written to **stdout**, so it can be captured in CI without `2>&1`: ```bash -tailor-sdk deploy --dry-run > plan.txt +tailor deploy --dry-run > plan.txt ``` In apply mode, the plan is printed to stderr so it does not interfere with piped output. diff --git a/packages/sdk/docs/cli/auth.md b/packages/sdk/docs/cli/auth.md index 30cab7c5b8..4434b33ba5 100644 --- a/packages/sdk/docs/cli/auth.md +++ b/packages/sdk/docs/cli/auth.md @@ -9,7 +9,7 @@ Manage auth connections. **Usage** ``` -tailor-sdk authconnection [command] +tailor authconnection [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -31,7 +31,7 @@ Authorize an auth connection via OAuth2 flow. **Usage** ``` -tailor-sdk authconnection authorize [options] +tailor authconnection authorize [options] ``` **Options** @@ -54,7 +54,7 @@ Delete an auth connection entirely. **Usage** ``` -tailor-sdk authconnection delete [options] +tailor authconnection delete [options] ``` **Options** @@ -75,7 +75,7 @@ List all auth connections. **Usage** ``` -tailor-sdk authconnection list [options] +tailor authconnection list [options] ``` **Options** @@ -96,7 +96,7 @@ Open the auth connections page in the Tailor Platform Console. **Usage** ``` -tailor-sdk authconnection open [options] +tailor authconnection open [options] ``` **Options** @@ -115,7 +115,7 @@ Revoke an auth connection's tokens (keeps the connection; use 'delete' to remove **Usage** ``` -tailor-sdk authconnection revoke [options] +tailor authconnection revoke [options] ``` **Options** @@ -140,7 +140,7 @@ Manage machine users in your Tailor Platform application. **Usage** ``` -tailor-sdk machineuser [command] +tailor machineuser [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -159,18 +159,18 @@ List all machine users in the application. **Usage** ``` -tailor-sdk machineuser list [options] +tailor machineuser list [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | -------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--order ` | - | Sort order (asc or desc) | No | `"desc"` | - | -| `--limit ` | `-l` | Maximum number of items to return (0 or omit: unlimited) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--order ` | - | Sort order (asc or desc) | No | `"desc"` | - | +| `--limit ` | `-l` | Maximum number of items to return (0 or omit: unlimited) | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -181,7 +181,7 @@ Get an access token for a machine user. **Usage** ``` -tailor-sdk machineuser token [options] [name] +tailor machineuser token [options] [name] ``` **Arguments** @@ -192,11 +192,11 @@ tailor-sdk machineuser token [options] [name] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -207,7 +207,7 @@ Manage OAuth2 clients in your Tailor Platform application. **Usage** ``` -tailor-sdk oauth2client [command] +tailor oauth2client [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -226,18 +226,18 @@ List all OAuth2 clients in the application. **Usage** ``` -tailor-sdk oauth2client list [options] +tailor oauth2client list [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | -------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--order ` | - | Sort order (asc or desc) | No | `"desc"` | - | -| `--limit ` | `-l` | Maximum number of items to return (0 or omit: unlimited) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--order ` | - | Sort order (asc or desc) | No | `"desc"` | - | +| `--limit ` | `-l` | Maximum number of items to return (0 or omit: unlimited) | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -259,7 +259,7 @@ Get OAuth2 client credentials (including client secret). **Usage** ``` -tailor-sdk oauth2client get [options] +tailor oauth2client get [options] ``` **Arguments** @@ -270,11 +270,11 @@ tailor-sdk oauth2client get [options] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | See [Global Options](../cli-reference.md#global-options) for options available to all commands. diff --git a/packages/sdk/docs/cli/completion.md b/packages/sdk/docs/cli/completion.md index d85dc1788e..330341e053 100644 --- a/packages/sdk/docs/cli/completion.md +++ b/packages/sdk/docs/cli/completion.md @@ -7,7 +7,7 @@ Generate shell completion script **Usage** ``` -tailor-sdk completion [options] [shell] +tailor completion [options] [shell] ``` **Arguments** diff --git a/packages/sdk/docs/cli/crashreport.md b/packages/sdk/docs/cli/crashreport.md index 28b39997f5..4a95b34306 100644 --- a/packages/sdk/docs/cli/crashreport.md +++ b/packages/sdk/docs/cli/crashreport.md @@ -6,12 +6,10 @@ Commands for managing crash reports. Manage crash reports. -**Aliases:** `crash-report` - **Usage** ``` -tailor-sdk crashreport [command] +tailor crashreport [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -30,7 +28,7 @@ List local crash report files. **Usage** ``` -tailor-sdk crashreport list [options] +tailor crashreport list [options] ``` **Options** @@ -49,7 +47,7 @@ Submit a crash report to help improve the SDK. **Usage** ``` -tailor-sdk crashreport send [options] +tailor crashreport send [options] ``` **Options** diff --git a/packages/sdk/docs/cli/executor.md b/packages/sdk/docs/cli/executor.md index 55a6dea490..c78d37749a 100644 --- a/packages/sdk/docs/cli/executor.md +++ b/packages/sdk/docs/cli/executor.md @@ -9,7 +9,7 @@ Manage executors **Usage** ``` -tailor-sdk executor [command] +tailor executor [command] ``` **Commands** @@ -31,7 +31,7 @@ List all executors **Usage** ``` -tailor-sdk executor list [options] +tailor executor list [options] ``` **Options** @@ -52,7 +52,7 @@ Get executor details **Usage** ``` -tailor-sdk executor get [options] +tailor executor get [options] ``` **Arguments** @@ -77,7 +77,7 @@ List or get executor jobs. **Usage** ``` -tailor-sdk executor jobs [options] [job-id] +tailor executor jobs [options] [job-id] ``` **Arguments** @@ -109,43 +109,43 @@ See [Global Options](../cli-reference.md#global-options) for options available t **List jobs for an executor (default: 50 jobs)** ```bash -$ tailor-sdk executor jobs my-executor +$ tailor executor jobs my-executor ``` **Limit the number of jobs** ```bash -$ tailor-sdk executor jobs my-executor --limit 10 +$ tailor executor jobs my-executor --limit 10 ``` **Filter by status** ```bash -$ tailor-sdk executor jobs my-executor -s RUNNING +$ tailor executor jobs my-executor -s RUNNING ``` **Get job details** ```bash -$ tailor-sdk executor jobs my-executor +$ tailor executor jobs my-executor ``` **Get job details with attempts** ```bash -$ tailor-sdk executor jobs my-executor --attempts +$ tailor executor jobs my-executor --attempts ``` **Wait for job to complete** ```bash -$ tailor-sdk executor jobs my-executor -W +$ tailor executor jobs my-executor -W ``` **Wait for job with logs** ```bash -$ tailor-sdk executor jobs my-executor -W -l +$ tailor executor jobs my-executor -W -l ``` ### executor trigger @@ -155,7 +155,7 @@ Trigger an executor manually. **Usage** ``` -tailor-sdk executor trigger [options] +tailor executor trigger [options] ``` **Arguments** @@ -182,31 +182,31 @@ tailor-sdk executor trigger [options] **Trigger an executor** ```bash -$ tailor-sdk executor trigger my-executor +$ tailor executor trigger my-executor ``` **Trigger with data** ```bash -$ tailor-sdk executor trigger my-executor -d '{"message": "hello"}' +$ tailor executor trigger my-executor -d '{"message": "hello"}' ``` **Trigger with data and headers** ```bash -$ tailor-sdk executor trigger my-executor -d '{"message": "hello"}' -H "X-Custom: value" -H "X-Another: value2" +$ tailor executor trigger my-executor -d '{"message": "hello"}' -H "X-Custom: value" -H "X-Another: value2" ``` **Trigger and wait for completion** ```bash -$ tailor-sdk executor trigger my-executor -W +$ tailor executor trigger my-executor -W ``` **Trigger, wait, and show logs** ```bash -$ tailor-sdk executor trigger my-executor -W -l +$ tailor executor trigger my-executor -W -l ``` **Shell automation** @@ -215,7 +215,7 @@ Trigger an executor and wait for the executor job plus any downstream workflow o function execution: ```bash -tailor-sdk executor trigger daily-workflow \ +tailor executor trigger daily-workflow \ --wait \ --timeout 5m \ --interval 5s \ @@ -225,7 +225,7 @@ tailor-sdk executor trigger daily-workflow \ Wait for an existing job when another process already captured the job ID: ```bash -tailor-sdk executor jobs daily-workflow "$job_id" \ +tailor executor jobs daily-workflow "$job_id" \ --wait \ --timeout 5m \ --logs \ @@ -286,7 +286,7 @@ Manage executor webhooks **Usage** ``` -tailor-sdk executor webhook [command] +tailor executor webhook [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -304,7 +304,7 @@ List executors with incoming webhook triggers **Usage** ``` -tailor-sdk executor webhook list [options] +tailor executor webhook list [options] ``` **Options** diff --git a/packages/sdk/docs/cli/executor.template.md b/packages/sdk/docs/cli/executor.template.md index c1e30b1fea..46752b5a3b 100644 --- a/packages/sdk/docs/cli/executor.template.md +++ b/packages/sdk/docs/cli/executor.template.md @@ -40,7 +40,7 @@ Trigger an executor and wait for the executor job plus any downstream workflow o function execution: ```bash -tailor-sdk executor trigger daily-workflow \ +tailor executor trigger daily-workflow \ --wait \ --timeout 5m \ --interval 5s \ @@ -50,7 +50,7 @@ tailor-sdk executor trigger daily-workflow \ Wait for an existing job when another process already captured the job ID: ```bash -tailor-sdk executor jobs daily-workflow "$job_id" \ +tailor executor jobs daily-workflow "$job_id" \ --wait \ --timeout 5m \ --logs \ diff --git a/packages/sdk/docs/cli/function.md b/packages/sdk/docs/cli/function.md index e5ab94729f..bcc74b55e4 100644 --- a/packages/sdk/docs/cli/function.md +++ b/packages/sdk/docs/cli/function.md @@ -9,7 +9,7 @@ Manage functions **Usage** ``` -tailor-sdk function [command] +tailor function [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -30,7 +30,7 @@ Get a function registry by name **Usage** ``` -tailor-sdk function get [options] +tailor function get [options] ``` **Options** @@ -50,7 +50,7 @@ List function registries in a workspace **Usage** ``` -tailor-sdk function list [options] +tailor function list [options] ``` **Options** @@ -71,7 +71,7 @@ List or get function execution logs. **Usage** ``` -tailor-sdk function logs [options] [execution-id] +tailor function logs [options] [execution-id] ``` **Arguments** @@ -96,32 +96,32 @@ See [Global Options](../cli-reference.md#global-options) for options available t **List all function execution logs** ```bash -$ tailor-sdk function logs +$ tailor function logs ``` **Get execution details with logs** ```bash -$ tailor-sdk function logs +$ tailor function logs ``` **Output as JSON** ```bash -$ tailor-sdk function logs --json +$ tailor function logs --json ``` **Get execution details as JSON** ```bash -$ tailor-sdk function logs --json +$ tailor function logs --json ``` **Notes** When viewing a specific execution that failed, the command displays error details with the stack trace mapped back to your original source files (clickable file links and code snippets, matching `function test-run` output). -Stack traces stay accurate even after later redeploys, because the trace is resolved against the exact build that produced the execution. If that build is no longer available, the command falls back to a plain-text error display. +Stack traces are mapped only when the execution includes a content hash for the exact build that ran. If the content hash is missing or the build is no longer available, the command falls back to a plain-text error display. ### function test-run @@ -130,7 +130,7 @@ Run a function on the Tailor Platform server without deploying. **Usage** ``` -tailor-sdk function test-run [options] +tailor function test-run [options] ``` **Arguments** @@ -157,19 +157,19 @@ See [Global Options](../cli-reference.md#global-options) for options available t **Run a resolver with input arguments** ```bash -$ tailor-sdk function test-run resolvers/add.ts --arg '{"a":1,"b":2}' +$ tailor function test-run resolvers/add.ts --arg '{"a":1,"b":2}' ``` **Run a specific workflow job by name** ```bash -$ tailor-sdk function test-run workflows/sample.ts --name validate-order +$ tailor function test-run workflows/sample.ts --name validate-order ``` **Run a pre-bundled .js file directly** ```bash -$ tailor-sdk function test-run build/resolvers/add.js --arg '{"a":1,"b":2}' +$ tailor function test-run build/resolvers/add.js --arg '{"a":1,"b":2}' ``` **Notes** diff --git a/packages/sdk/docs/cli/organization.md b/packages/sdk/docs/cli/organization.md index f1e29ec6de..23020746ee 100644 --- a/packages/sdk/docs/cli/organization.md +++ b/packages/sdk/docs/cli/organization.md @@ -5,7 +5,7 @@ Manage Tailor Platform organizations. **Usage** ``` -tailor-sdk organization [command] +tailor organization [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -27,7 +27,7 @@ Manage organization folders. **Usage** ``` -tailor-sdk organization folder +tailor organization folder ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -49,7 +49,7 @@ Create a new folder in an organization. **Usage** ``` -tailor-sdk organization folder create [options] +tailor organization folder create [options] ``` **Options** @@ -69,7 +69,7 @@ Delete a folder from an organization. **Usage** ``` -tailor-sdk organization folder delete [options] +tailor organization folder delete [options] ``` **Options** @@ -89,7 +89,7 @@ Show detailed information about a folder. **Usage** ``` -tailor-sdk organization folder get [options] +tailor organization folder get [options] ``` **Options** @@ -108,7 +108,7 @@ List folders in an organization. **Usage** ``` -tailor-sdk organization folder list [options] +tailor organization folder list [options] ``` **Options** @@ -129,7 +129,7 @@ Update a folder's name. **Usage** ``` -tailor-sdk organization folder update [options] +tailor organization folder update [options] ``` **Options** @@ -149,7 +149,7 @@ Show detailed information about an organization. **Usage** ``` -tailor-sdk organization get [options] +tailor organization get [options] ``` **Options** @@ -167,7 +167,7 @@ List organizations you belong to. **Usage** ``` -tailor-sdk organization list [options] +tailor organization list [options] ``` **Options** @@ -185,7 +185,7 @@ Display organization folder hierarchy as a tree. **Usage** ``` -tailor-sdk organization tree [options] +tailor organization tree [options] ``` **Options** @@ -204,7 +204,7 @@ Update an organization's name. **Usage** ``` -tailor-sdk organization update [options] +tailor organization update [options] ``` **Options** diff --git a/packages/sdk/docs/cli/plugin.md b/packages/sdk/docs/cli/plugin.md new file mode 100644 index 0000000000..5f34ecd7fa --- /dev/null +++ b/packages/sdk/docs/cli/plugin.md @@ -0,0 +1,29 @@ +## plugin + +Manage and inspect CLI plugins (beta). + +**Usage** + +``` +tailor plugin [command] +``` + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. + +**Commands** + +| Command | Description | +| ----------------------------- | ---------------------------------------------------------------------------------------- | +| [`plugin list`](#plugin-list) | List discovered plugins (executables named `-` on PATH or node_modules/.bin). | + +### plugin list + +List discovered plugins (executables named `-` on PATH or node_modules/.bin). + +**Usage** + +``` +tailor plugin list +``` + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. diff --git a/packages/sdk/docs/cli/plugin.template.md b/packages/sdk/docs/cli/plugin.template.md new file mode 100644 index 0000000000..01b67cfdd4 --- /dev/null +++ b/packages/sdk/docs/cli/plugin.template.md @@ -0,0 +1,8 @@ +--- +politty: + index: + title: "Plugin Commands" + description: "Discover and inspect CLI plugins (external `tailor-` executables)." +--- + +{{politty:command:plugin}} diff --git a/packages/sdk/docs/cli/query.md b/packages/sdk/docs/cli/query.md index 567f99425c..5683caa031 100644 --- a/packages/sdk/docs/cli/query.md +++ b/packages/sdk/docs/cli/query.md @@ -5,7 +5,7 @@ Run SQL/GraphQL query. **Usage** ``` -tailor-sdk query [options] +tailor query [options] ``` **Options** @@ -14,7 +14,7 @@ tailor-sdk query [options] | ------------------------------- | ----- | ---------------------------------------------------------------------------------------------------- | -------- | -------------------- | ----------------------------------- | | `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | | `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | | `--engine ` | - | Query engine (sql or gql) | Yes | - | - | | `--query ` | `-q` | Query string to execute directly; omit to start REPL mode | No | - | - | | `--file ` | `-f` | Read query string from file; omit to start REPL mode | No | - | - | diff --git a/packages/sdk/docs/cli/secret.md b/packages/sdk/docs/cli/secret.md index 5f93a04b9f..0d08bb31aa 100644 --- a/packages/sdk/docs/cli/secret.md +++ b/packages/sdk/docs/cli/secret.md @@ -9,7 +9,7 @@ Manage Secret Manager vaults and secrets. **Usage** ``` -tailor-sdk secret [command] +tailor secret [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -31,7 +31,7 @@ Create a secret in a vault. **Usage** ``` -tailor-sdk secret create [options] +tailor secret create [options] ``` **Options** @@ -54,7 +54,7 @@ Delete a secret in a vault. **Usage** ``` -tailor-sdk secret delete [options] +tailor secret delete [options] ``` **Options** @@ -76,7 +76,7 @@ List all secrets in a vault. **Usage** ``` -tailor-sdk secret list [options] +tailor secret list [options] ``` **Options** @@ -98,7 +98,7 @@ Update a secret in a vault. **Usage** ``` -tailor-sdk secret update [options] +tailor secret update [options] ``` **Options** @@ -121,7 +121,7 @@ Manage Secret Manager vaults. **Usage** ``` -tailor-sdk secret vault [command] +tailor secret vault [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -141,7 +141,7 @@ Create a new Secret Manager vault. **Usage** ``` -tailor-sdk secret vault create [options] +tailor secret vault create [options] ``` **Arguments** @@ -166,7 +166,7 @@ Delete a Secret Manager vault. **Usage** ``` -tailor-sdk secret vault delete [options] +tailor secret vault delete [options] ``` **Arguments** @@ -192,7 +192,7 @@ List all Secret Manager vaults in the workspace. **Usage** ``` -tailor-sdk secret vault list [options] +tailor secret vault list [options] ``` **Options** diff --git a/packages/sdk/docs/cli/setup.md b/packages/sdk/docs/cli/setup.md index e7bf6b07f1..c6a8c533d8 100644 --- a/packages/sdk/docs/cli/setup.md +++ b/packages/sdk/docs/cli/setup.md @@ -9,7 +9,7 @@ Generate CI deploy workflows for your project. (beta) **Usage** ``` -tailor-sdk setup +tailor setup ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -24,7 +24,7 @@ See [Global Options](../cli-reference.md#global-options) for options available t | [`setup action`](#setup-action) | Generate a per-app composite action for use with setup coordinate (monorepo multi-app deploys). | | [`setup coordinate`](#setup-coordinate) | Generate a coordinator workflow that orchestrates multiple --action-generated composite actions. | | [`setup check`](#setup-check) | Audit generated workflows for drift against the current config/repo (read-only). | -| [`setup delete`](#setup-delete) | Delete managed workflow/action file(s) and their .github/tailor-sdk.lock entries. | +| [`setup delete`](#setup-delete) | Delete managed workflow/action file(s) and their .github/tailor.lock entries. | ### setup action @@ -33,7 +33,7 @@ Generate a per-app composite action for use with setup coordinate (monorepo mult **Usage** ``` -tailor-sdk setup action [options] +tailor setup action [options] ``` **Options** @@ -54,7 +54,7 @@ Generate a branch-target deploy workflow (push to branch triggers deploy). **Usage** ``` -tailor-sdk setup branch [options] +tailor setup branch [options] ``` **Options** @@ -77,7 +77,7 @@ Audit generated workflows for drift against the current config/repo (read-only). **Usage** ``` -tailor-sdk setup check [options] +tailor setup check [options] ``` **Options** @@ -95,7 +95,7 @@ Generate a coordinator workflow that orchestrates multiple --action-generated co **Usage** ``` -tailor-sdk setup coordinate [options] +tailor setup coordinate [options] ``` **Options** @@ -113,12 +113,12 @@ See [Global Options](../cli-reference.md#global-options) for options available t ### setup delete -Delete managed workflow/action file(s) and their .github/tailor-sdk.lock entries. +Delete managed workflow/action file(s) and their .github/tailor.lock entries. **Usage** ``` -tailor-sdk setup delete [options] +tailor setup delete [options] ``` **Arguments** @@ -142,7 +142,7 @@ Generate a preview workflow (PR open/sync triggers deploy to a per-PR workspace) **Usage** ``` -tailor-sdk setup preview [options] +tailor setup preview [options] ``` **Options** @@ -166,7 +166,7 @@ Generate a tag-target deploy workflow (tag push triggers deploy). **Usage** ``` -tailor-sdk setup tag [options] +tailor setup tag [options] ``` **Options** diff --git a/packages/sdk/docs/cli/skills.md b/packages/sdk/docs/cli/skills.md index 17fcb873af..84514c9038 100644 --- a/packages/sdk/docs/cli/skills.md +++ b/packages/sdk/docs/cli/skills.md @@ -5,25 +5,25 @@ Manage Tailor SDK agent skills. **Usage** ``` -tailor-sdk skills [command] +tailor skills [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. **Commands** -| Command | Description | -| ----------------------------------- | ------------------------------------------------------------------ | -| [`skills install`](#skills-install) | Install the tailor-sdk agent skill from the installed SDK package. | +| Command | Description | +| ----------------------------------- | -------------------------------------------------------------- | +| [`skills install`](#skills-install) | Install the tailor agent skill from the installed SDK package. | ### skills install -Install the tailor-sdk agent skill from the installed SDK package. +Install the tailor agent skill from the installed SDK package. **Usage** ``` -tailor-sdk skills install [options] +tailor skills install [options] ``` **Options** diff --git a/packages/sdk/docs/cli/staticwebsite.md b/packages/sdk/docs/cli/staticwebsite.md index e899c158e1..1a6ffa7684 100644 --- a/packages/sdk/docs/cli/staticwebsite.md +++ b/packages/sdk/docs/cli/staticwebsite.md @@ -9,7 +9,7 @@ Manage static websites in your workspace. **Usage** ``` -tailor-sdk staticwebsite [command] +tailor staticwebsite [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -30,7 +30,7 @@ Deploy a static website from a local build directory. **Usage** ``` -tailor-sdk staticwebsite deploy [options] +tailor staticwebsite deploy [options] ``` **Options** @@ -48,10 +48,10 @@ See [Global Options](../cli-reference.md#global-options) for options available t ```bash # Deploy a static website from the dist directory -tailor-sdk staticwebsite deploy --name my-website --dir ./dist +tailor staticwebsite deploy --name my-website --dir ./dist # Deploy with workspace ID -tailor-sdk staticwebsite deploy -n my-website -d ./dist -w ws_abc123 +tailor staticwebsite deploy -n my-website -d ./dist -w ws_abc123 ``` **Notes:** @@ -68,7 +68,7 @@ List all static websites in a workspace. **Usage** ``` -tailor-sdk staticwebsite list [options] +tailor staticwebsite list [options] ``` **Options** @@ -86,10 +86,10 @@ See [Global Options](../cli-reference.md#global-options) for options available t ```bash # List all static websites -tailor-sdk staticwebsite list +tailor staticwebsite list # List with JSON output -tailor-sdk staticwebsite list --json +tailor staticwebsite list --json ``` ### staticwebsite domain @@ -99,7 +99,7 @@ Manage custom domains for static websites. **Usage** ``` -tailor-sdk staticwebsite domain +tailor staticwebsite domain ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -118,7 +118,7 @@ Get details of a custom domain. **Usage** ``` -tailor-sdk staticwebsite domain get [options] +tailor staticwebsite domain get [options] ``` **Arguments** @@ -143,7 +143,7 @@ List custom domains for a static website. **Usage** ``` -tailor-sdk staticwebsite domain list [options] +tailor staticwebsite domain list [options] ``` **Arguments** @@ -168,7 +168,7 @@ Get details of a specific static website. **Usage** ``` -tailor-sdk staticwebsite get [options] +tailor staticwebsite get [options] ``` **Arguments** @@ -190,8 +190,8 @@ See [Global Options](../cli-reference.md#global-options) for options available t ```bash # Get details of a static website -tailor-sdk staticwebsite get my-website +tailor staticwebsite get my-website # Get with JSON output -tailor-sdk staticwebsite get my-website --json +tailor staticwebsite get my-website --json ``` diff --git a/packages/sdk/docs/cli/staticwebsite.template.md b/packages/sdk/docs/cli/staticwebsite.template.md index 708af789db..9fe41c137e 100644 --- a/packages/sdk/docs/cli/staticwebsite.template.md +++ b/packages/sdk/docs/cli/staticwebsite.template.md @@ -25,10 +25,10 @@ Commands for managing and deploying static websites. ```bash # Deploy a static website from the dist directory -tailor-sdk staticwebsite deploy --name my-website --dir ./dist +tailor staticwebsite deploy --name my-website --dir ./dist # Deploy with workspace ID -tailor-sdk staticwebsite deploy -n my-website -d ./dist -w ws_abc123 +tailor staticwebsite deploy -n my-website -d ./dist -w ws_abc123 ``` **Notes:** @@ -44,10 +44,10 @@ tailor-sdk staticwebsite deploy -n my-website -d ./dist -w ws_abc123 ```bash # List all static websites -tailor-sdk staticwebsite list +tailor staticwebsite list # List with JSON output -tailor-sdk staticwebsite list --json +tailor staticwebsite list --json ``` {{politty:command:staticwebsite domain}} @@ -57,8 +57,8 @@ tailor-sdk staticwebsite list --json ```bash # Get details of a static website -tailor-sdk staticwebsite get my-website +tailor staticwebsite get my-website # Get with JSON output -tailor-sdk staticwebsite get my-website --json +tailor staticwebsite get my-website --json ``` diff --git a/packages/sdk/docs/cli/tailordb.md b/packages/sdk/docs/cli/tailordb.md index 990ab38e77..824f38a8f6 100644 --- a/packages/sdk/docs/cli/tailordb.md +++ b/packages/sdk/docs/cli/tailordb.md @@ -9,7 +9,7 @@ Manage TailorDB tables and data. **Usage** ``` -tailor-sdk tailordb +tailor tailordb ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -29,7 +29,7 @@ Truncate (delete all records from) TailorDB tables. **Usage** ``` -tailor-sdk tailordb truncate [options] [types] +tailor tailordb truncate [options] [types] ``` **Arguments** @@ -40,14 +40,14 @@ tailor-sdk tailordb truncate [options] [types] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | -------------------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | -| `--all` | `-a` | Truncate all tables in all owned namespaces (excludes external namespaces) | No | `false` | - | -| `--namespace ` | `-n` | Truncate all tables in specified namespace | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | -------------------------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | +| `--all` | `-a` | Truncate all tables in all owned namespaces (excludes external namespaces) | No | `false` | - | +| `--namespace ` | `-n` | Truncate all tables in specified namespace | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -55,19 +55,19 @@ See [Global Options](../cli-reference.md#global-options) for options available t ```bash # Truncate all tables in all namespaces (requires confirmation) -tailor-sdk tailordb truncate --all +tailor tailordb truncate --all # Truncate all tables in all namespaces (skip confirmation) -tailor-sdk tailordb truncate --all --yes +tailor tailordb truncate --all --yes # Truncate all tables in a specific namespace -tailor-sdk tailordb truncate --namespace myNamespace +tailor tailordb truncate --namespace myNamespace # Truncate specific types (namespace is auto-detected) -tailor-sdk tailordb truncate User Post Comment +tailor tailordb truncate User Post Comment # Truncate specific types with confirmation skipped -tailor-sdk tailordb truncate User Post --yes +tailor tailordb truncate User Post --yes ``` **Notes:** @@ -85,12 +85,12 @@ tailor-sdk tailordb truncate User Post --yes Manage TailorDB schema migrations. -Note: Migration scripts are automatically executed during `tailor-sdk deploy`. See [Automatic Migration Execution](../services/tailordb-migration.md#automatic-migration-execution) for details. +Note: Migration scripts are automatically executed during `tailor deploy`. See [Automatic Migration Execution](../services/tailordb-migration.md#automatic-migration-execution) for details. **Usage** ``` -tailor-sdk tailordb migration +tailor tailordb migration ``` **Commands** @@ -112,17 +112,17 @@ Generate migration files by detecting schema differences between current local t **Usage** ``` -tailor-sdk tailordb migration generate [options] +tailor tailordb migration generate [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------- | ----- | ------------------------------------------ | -------- | -------------------- | --------------------------------- | -| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--name ` | `-n` | Optional description for the migration | No | - | - | -| `--init` | - | Delete existing migrations and start fresh | No | `false` | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------- | ----- | ------------------------------------------ | -------- | -------------------- | -------------------- | +| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--name ` | `-n` | Optional description for the migration | No | - | - | +| `--init` | - | Delete existing migrations and start fresh | No | `false` | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -133,7 +133,7 @@ Add a migration script (migrate.ts) template to an existing migration directory. **Usage** ``` -tailor-sdk tailordb migration script [options] +tailor tailordb migration script [options] ``` **Arguments** @@ -144,10 +144,10 @@ tailor-sdk tailordb migration script [options] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--namespace ` | `-n` | Target TailorDB namespace (required if multiple namespaces exist) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | -------------------- | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--namespace ` | `-n` | Target TailorDB namespace (required if multiple namespaces exist) | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -158,7 +158,7 @@ Set migration checkpoint to a specific number. **Usage** ``` -tailor-sdk tailordb migration set [options] +tailor tailordb migration set [options] ``` **Arguments** @@ -169,13 +169,13 @@ tailor-sdk tailordb migration set [options] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | -| `--namespace ` | `-n` | Target TailorDB namespace (required if multiple namespaces exist) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | +| `--namespace ` | `-n` | Target TailorDB namespace (required if multiple namespaces exist) | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -186,17 +186,17 @@ Show the current migration status for TailorDB namespaces, including applied and **Usage** ``` -tailor-sdk tailordb migration status [options] +tailor tailordb migration status [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--namespace ` | `-n` | Target TailorDB namespace (shows all namespaces if not specified) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--namespace ` | `-n` | Target TailorDB namespace (shows all namespaces if not specified) | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -207,7 +207,7 @@ Sync remote TailorDB schema to a specific migration snapshot (recovery from --no **Usage** ``` -tailor-sdk tailordb migration sync [options] +tailor tailordb migration sync [options] ``` **Arguments** @@ -218,13 +218,13 @@ tailor-sdk tailordb migration sync [options] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | -| `--namespace ` | `-n` | Target TailorDB namespace (required if multiple namespaces exist) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | ----------------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--yes` | `-y` | Skip confirmation prompts | No | `false` | - | +| `--namespace ` | `-n` | Target TailorDB namespace (required if multiple namespaces exist) | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -237,7 +237,7 @@ Generate TailorDB ERD viewer artifacts from local TailorDB schema. (beta) **Usage** ``` -tailor-sdk tailordb erd +tailor tailordb erd ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -258,16 +258,16 @@ Export TailorDB ERD static viewer from local TailorDB schema. **Usage** ``` -tailor-sdk tailordb erd export [options] +tailor tailordb erd export [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------- | ----- | ---------------------------------------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--namespace ` | `-n` | TailorDB namespace name (optional if only one namespace is defined in config) | No | - | - | -| `--output ` | `-o` | Output directory path for TailorDB ERD viewer files (writes to `//dist`) | No | `".tailor-sdk/erd"` | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------- | ----- | ---------------------------------------------------------------------------------------------- | -------- | -------------------- | -------------------- | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--namespace ` | `-n` | TailorDB namespace name (optional if only one namespace is defined in config) | No | - | - | +| `--output ` | `-o` | Output directory path for TailorDB ERD viewer files (writes to `//dist`) | No | `".tailor/erd"` | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -278,7 +278,7 @@ Render TailorDB ERD schema diff HTML from exported ERD viewers. **Usage** ``` -tailor-sdk tailordb erd diff [options] +tailor tailordb erd diff [options] ``` **Options** @@ -300,17 +300,17 @@ Generate and serve TailorDB ERD locally with watch reload. (beta) **Usage** ``` -tailor-sdk tailordb erd serve [options] +tailor tailordb erd serve [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------- | ----- | ------------------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--namespace ` | `-n` | TailorDB namespace name (uses first namespace in config if not specified) | No | - | - | -| `--port ` | - | Local server port (0 selects a free port) | No | `0` | - | -| `--open` | - | Open the ERD viewer in the default browser | No | `false` | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------- | ----- | ------------------------------------------------------------------------- | -------- | -------------------- | -------------------- | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--namespace ` | `-n` | TailorDB namespace name (uses first namespace in config if not specified) | No | - | - | +| `--port ` | - | Local server port (0 selects a free port) | No | `0` | - | +| `--open` | - | Open the ERD viewer in the default browser | No | `false` | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -321,17 +321,17 @@ Deploy ERD static website for TailorDB namespace(s). **Usage** ``` -tailor-sdk tailordb erd deploy [options] +tailor tailordb erd deploy [options] ``` **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | ----------------------------------------------------------------------------------- | -------- | -------------------- | --------------------------------- | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | -| `--namespace ` | `-n` | TailorDB namespace name (optional - deploys all namespaces with erdSite if omitted) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | ----------------------------------------------------------------------------------- | -------- | -------------------- | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | +| `--namespace ` | `-n` | TailorDB namespace name (optional - deploys all namespaces with erdSite if omitted) | No | - | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -347,13 +347,13 @@ See [Global Options](../cli-reference.md#global-options) for options available t ```bash # Deploy ERD for all namespaces with erdSite configured -tailor-sdk tailordb erd deploy +tailor tailordb erd deploy # Deploy ERD for a specific namespace -tailor-sdk tailordb erd deploy --namespace myNamespace +tailor tailordb erd deploy --namespace myNamespace # Deploy ERD with JSON output -tailor-sdk tailordb erd deploy --json +tailor tailordb erd deploy --json ``` **Notes:** diff --git a/packages/sdk/docs/cli/tailordb.template.md b/packages/sdk/docs/cli/tailordb.template.md index b709353e57..869ee7a721 100644 --- a/packages/sdk/docs/cli/tailordb.template.md +++ b/packages/sdk/docs/cli/tailordb.template.md @@ -25,19 +25,19 @@ Commands for managing TailorDB tables, data, and schema migrations. ```bash # Truncate all tables in all namespaces (requires confirmation) -tailor-sdk tailordb truncate --all +tailor tailordb truncate --all # Truncate all tables in all namespaces (skip confirmation) -tailor-sdk tailordb truncate --all --yes +tailor tailordb truncate --all --yes # Truncate all tables in a specific namespace -tailor-sdk tailordb truncate --namespace myNamespace +tailor tailordb truncate --namespace myNamespace # Truncate specific types (namespace is auto-detected) -tailor-sdk tailordb truncate User Post Comment +tailor tailordb truncate User Post Comment # Truncate specific types with confirmation skipped -tailor-sdk tailordb truncate User Post --yes +tailor tailordb truncate User Post --yes ``` **Notes:** @@ -55,7 +55,7 @@ tailor-sdk tailordb truncate User Post --yes {{politty:command:tailordb migration:description}} -Note: Migration scripts are automatically executed during `tailor-sdk deploy`. See [Automatic Migration Execution](../services/tailordb-migration.md#automatic-migration-execution) for details. +Note: Migration scripts are automatically executed during `tailor deploy`. See [Automatic Migration Execution](../services/tailordb-migration.md#automatic-migration-execution) for details. {{politty:command:tailordb migration:usage}} @@ -97,13 +97,13 @@ Note: Migration scripts are automatically executed during `tailor-sdk deploy`. S ```bash # Deploy ERD for all namespaces with erdSite configured -tailor-sdk tailordb erd deploy +tailor tailordb erd deploy # Deploy ERD for a specific namespace -tailor-sdk tailordb erd deploy --namespace myNamespace +tailor tailordb erd deploy --namespace myNamespace # Deploy ERD with JSON output -tailor-sdk tailordb erd deploy --json +tailor tailordb erd deploy --json ``` **Notes:** diff --git a/packages/sdk/docs/cli/upgrade.md b/packages/sdk/docs/cli/upgrade.md index 4485bfd2cd..bb1889c934 100644 --- a/packages/sdk/docs/cli/upgrade.md +++ b/packages/sdk/docs/cli/upgrade.md @@ -5,7 +5,7 @@ Run codemods to upgrade your project to a newer SDK version. **Usage** ``` -tailor-sdk upgrade [options] +tailor upgrade [options] ``` **Options** @@ -25,7 +25,7 @@ The `upgrade` command runs codemods that automatically transform your project co **Typical workflow:** 1. Update your SDK packages to the new version (e.g., `pnpm update @tailor-platform/sdk`) -2. Run `tailor-sdk upgrade --from ` to apply codemods +2. Run `tailor upgrade --from ` to apply codemods 3. Review changes and commit Use `--dry-run` to preview what changes will be made before applying them. diff --git a/packages/sdk/docs/cli/upgrade.template.md b/packages/sdk/docs/cli/upgrade.template.md index 592192f2fe..b36976a240 100644 --- a/packages/sdk/docs/cli/upgrade.template.md +++ b/packages/sdk/docs/cli/upgrade.template.md @@ -14,7 +14,7 @@ The `upgrade` command runs codemods that automatically transform your project co **Typical workflow:** 1. Update your SDK packages to the new version (e.g., `pnpm update @tailor-platform/sdk`) -2. Run `tailor-sdk upgrade --from ` to apply codemods +2. Run `tailor upgrade --from ` to apply codemods 3. Review changes and commit Use `--dry-run` to preview what changes will be made before applying them. diff --git a/packages/sdk/docs/cli/user.md b/packages/sdk/docs/cli/user.md index ad1b784455..ca4ceb11f4 100644 --- a/packages/sdk/docs/cli/user.md +++ b/packages/sdk/docs/cli/user.md @@ -9,7 +9,7 @@ Login to Tailor Platform. **Usage** ``` -tailor-sdk login [options] +tailor login [options] ``` **Options** @@ -41,7 +41,7 @@ Logout from Tailor Platform. **Usage** ``` -tailor-sdk logout [options] +tailor logout [options] ``` **Options** @@ -52,6 +52,42 @@ tailor-sdk logout [options] See [Global Options](../cli-reference.md#global-options) for options available to all commands. +## auth + +Authentication helpers for scripts and plugins. + +**Usage** + +``` +tailor auth +``` + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. + +**Commands** + +| Command | Description | +| --------------------------- | ------------------------------------------------------------------------------------- | +| [`auth token`](#auth-token) | Print a valid Tailor Platform access token to stdout, refreshing it first if expired. | + +### auth token + +Print a valid Tailor Platform access token to stdout, refreshing it first if expired. + +**Usage** + +``` +tailor auth token [options] +``` + +**Options** + +| Option | Alias | Description | Required | Default | Env | +| --------------------- | ----- | ----------------- | -------- | ------- | ------------------------- | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. + ## user Manage Tailor Platform users. @@ -59,7 +95,7 @@ Manage Tailor Platform users. **Usage** ``` -tailor-sdk user [command] +tailor user [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -80,7 +116,7 @@ Show current user. **Usage** ``` -tailor-sdk user current +tailor user current ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -92,7 +128,7 @@ List all users. **Usage** ``` -tailor-sdk user list +tailor user list ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -104,7 +140,7 @@ Manage personal access tokens. **Usage** ``` -tailor-sdk user pat [command] +tailor user pat [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -125,7 +161,7 @@ Create a new personal access token. **Usage** ``` -tailor-sdk user pat create [options] +tailor user pat create [options] ``` **Arguments** @@ -149,7 +185,7 @@ Delete a personal access token. **Usage** ``` -tailor-sdk user pat delete +tailor user pat delete ``` **Arguments** @@ -167,7 +203,7 @@ List all personal access tokens. **Usage** ``` -tailor-sdk user pat list [options] +tailor user pat list [options] ``` **Options** @@ -186,7 +222,7 @@ Update a personal access token (delete and recreate). **Usage** ``` -tailor-sdk user pat update [options] +tailor user pat update [options] ``` **Arguments** @@ -210,14 +246,14 @@ Set current user. **Usage** ``` -tailor-sdk user switch +tailor user switch ``` **Arguments** -| Argument | Description | Required | -| -------- | ----------- | -------- | -| `user` | User email | Yes | +| Argument | Description | Required | +| -------- | -------------------------------------------- | -------- | +| `user` | User email address or machine user client ID | Yes | See [Global Options](../cli-reference.md#global-options) for options available to all commands. When no subcommand is provided, defaults to `list`. diff --git a/packages/sdk/docs/cli/user.template.md b/packages/sdk/docs/cli/user.template.md index a78a0ffbde..2530b03ff9 100644 --- a/packages/sdk/docs/cli/user.template.md +++ b/packages/sdk/docs/cli/user.template.md @@ -11,6 +11,7 @@ Commands for authentication and user management. {{politty:command:login}} {{politty:command:logout}} +{{politty:command:auth}} {{politty:command:user}} When no subcommand is provided, defaults to `list`. diff --git a/packages/sdk/docs/cli/workflow.md b/packages/sdk/docs/cli/workflow.md index ee6c590f5d..38ddd04188 100644 --- a/packages/sdk/docs/cli/workflow.md +++ b/packages/sdk/docs/cli/workflow.md @@ -9,7 +9,7 @@ Manage workflows and workflow executions. **Usage** ``` -tailor-sdk workflow [command] +tailor workflow [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -32,7 +32,7 @@ List all workflows in the workspace. **Usage** ``` -tailor-sdk workflow list [options] +tailor workflow list [options] ``` **Options** @@ -53,7 +53,7 @@ Get workflow details. **Usage** ``` -tailor-sdk workflow get [options] +tailor workflow get [options] ``` **Arguments** @@ -78,7 +78,7 @@ Start a workflow execution. **Usage** ``` -tailor-sdk workflow start [options] +tailor workflow start [options] ``` **Arguments** @@ -93,7 +93,7 @@ tailor-sdk workflow start [options] | ------------------------------- | ----- | --------------------------------------------------------------------------- | -------- | -------------------- | ----------------------------------- | | `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | | `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--config ` | `-c` | Path to SDK config file | No | `"tailor.config.ts"` | `TAILOR_PLATFORM_SDK_CONFIG_PATH` | +| `--config ` | `-c` | Path to Tailor config file | No | `"tailor.config.ts"` | `TAILOR_CONFIG_PATH` | | `--machine-user ` | `-m` | Machine user name. Falls back to the active profile's default machine user. | No | - | `TAILOR_PLATFORM_MACHINE_USER_NAME` | | `--arg ` | `-a` | Workflow argument (JSON string) | No | - | - | | `--wait` | `-W` | Wait for execution to complete | No | `false` | - | @@ -108,13 +108,13 @@ See [Global Options](../cli-reference.md#global-options) for options available t ```bash # Start a workflow -tailor-sdk workflow start my-workflow -m admin-machine-user +tailor workflow start my-workflow -m admin-machine-user # Start with argument -tailor-sdk workflow start my-workflow -m admin -a '{"userId": "123"}' +tailor workflow start my-workflow -m admin -a '{"userId": "123"}' # Start and wait for completion -tailor-sdk workflow start my-workflow -m admin -W +tailor workflow start my-workflow -m admin -W ``` ### workflow wait @@ -124,7 +124,7 @@ Wait for a workflow execution. **Usage** ``` -tailor-sdk workflow wait [options] +tailor workflow wait [options] ``` **Arguments** @@ -151,19 +151,19 @@ See [Global Options](../cli-reference.md#global-options) for options available t **Wait for workflow success** ```bash -$ tailor-sdk workflow wait execution-id --until success --timeout 10m --json +$ tailor workflow wait execution-id --until success --timeout 10m --json ``` **Wait for a workflow wait point** ```bash -$ tailor-sdk workflow wait execution-id --until suspended --timeout 6m --logs --json +$ tailor workflow wait execution-id --until suspended --timeout 6m --logs --json ``` **Wait for success, failure, or suspension** ```bash -$ tailor-sdk workflow wait execution-id --until terminal +$ tailor workflow wait execution-id --until terminal ``` **Shell automation** @@ -173,10 +173,10 @@ separate command: ```bash execution_id="$( - tailor-sdk workflow start order-workflow --json | jq -r '.executionId' + tailor workflow start order-workflow --json | jq -r '.executionId' )" -tailor-sdk workflow wait "$execution_id" \ +tailor workflow wait "$execution_id" \ --until success \ --timeout 10m \ --interval 5s \ @@ -186,7 +186,7 @@ tailor-sdk workflow wait "$execution_id" \ Wait until a workflow reaches a wait point, such as an approval step: ```bash -tailor-sdk workflow wait "$execution_id" \ +tailor workflow wait "$execution_id" \ --until suspended \ --timeout 6m \ --logs \ @@ -226,7 +226,7 @@ List or get workflow executions. **Usage** ``` -tailor-sdk workflow executions [options] [execution-id] +tailor workflow executions [options] [execution-id] ``` **Arguments** @@ -257,22 +257,22 @@ See [Global Options](../cli-reference.md#global-options) for options available t ```bash # List all executions -tailor-sdk workflow executions +tailor workflow executions # Filter by workflow name -tailor-sdk workflow executions -n my-workflow +tailor workflow executions -n my-workflow # Filter by status -tailor-sdk workflow executions -s RUNNING +tailor workflow executions -s RUNNING # Get execution details -tailor-sdk workflow executions +tailor workflow executions # Get execution details with logs -tailor-sdk workflow executions --logs +tailor workflow executions --logs # Wait for execution to complete -tailor-sdk workflow executions -W +tailor workflow executions -W ``` ### workflow resume @@ -282,7 +282,7 @@ Resume a failed or pending workflow execution. **Usage** ``` -tailor-sdk workflow resume [options] +tailor workflow resume [options] ``` **Arguments** diff --git a/packages/sdk/docs/cli/workflow.template.md b/packages/sdk/docs/cli/workflow.template.md index 01001d71f5..e9981a7cce 100644 --- a/packages/sdk/docs/cli/workflow.template.md +++ b/packages/sdk/docs/cli/workflow.template.md @@ -27,13 +27,13 @@ Commands for managing workflows and workflow executions. ```bash # Start a workflow -tailor-sdk workflow start my-workflow -m admin-machine-user +tailor workflow start my-workflow -m admin-machine-user # Start with argument -tailor-sdk workflow start my-workflow -m admin -a '{"userId": "123"}' +tailor workflow start my-workflow -m admin -a '{"userId": "123"}' # Start and wait for completion -tailor-sdk workflow start my-workflow -m admin -W +tailor workflow start my-workflow -m admin -W ``` {{politty:command:workflow wait}} @@ -45,10 +45,10 @@ separate command: ```bash execution_id="$( - tailor-sdk workflow start order-workflow --json | jq -r '.executionId' + tailor workflow start order-workflow --json | jq -r '.executionId' )" -tailor-sdk workflow wait "$execution_id" \ +tailor workflow wait "$execution_id" \ --until success \ --timeout 10m \ --interval 5s \ @@ -58,7 +58,7 @@ tailor-sdk workflow wait "$execution_id" \ Wait until a workflow reaches a wait point, such as an approval step: ```bash -tailor-sdk workflow wait "$execution_id" \ +tailor workflow wait "$execution_id" \ --until suspended \ --timeout 6m \ --logs \ @@ -97,22 +97,22 @@ if (result.timedOut) { ```bash # List all executions -tailor-sdk workflow executions +tailor workflow executions # Filter by workflow name -tailor-sdk workflow executions -n my-workflow +tailor workflow executions -n my-workflow # Filter by status -tailor-sdk workflow executions -s RUNNING +tailor workflow executions -s RUNNING # Get execution details -tailor-sdk workflow executions +tailor workflow executions # Get execution details with logs -tailor-sdk workflow executions --logs +tailor workflow executions --logs # Wait for execution to complete -tailor-sdk workflow executions -W +tailor workflow executions -W ``` {{politty:command:workflow resume}} diff --git a/packages/sdk/docs/cli/workspace.md b/packages/sdk/docs/cli/workspace.md index 50358619c9..ae85e7634d 100644 --- a/packages/sdk/docs/cli/workspace.md +++ b/packages/sdk/docs/cli/workspace.md @@ -9,7 +9,7 @@ Manage Tailor Platform workspaces. **Usage** ``` -tailor-sdk workspace [command] +tailor workspace [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -33,7 +33,7 @@ Manage workspace applications **Usage** ``` -tailor-sdk workspace app [command] +tailor workspace app [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -52,7 +52,7 @@ Check application schema health **Usage** ``` -tailor-sdk workspace app health [options] +tailor workspace app health [options] ``` **Options** @@ -72,7 +72,7 @@ List applications in a workspace **Usage** ``` -tailor-sdk workspace app list [options] +tailor workspace app list [options] ``` **Options** @@ -93,7 +93,7 @@ Create a new Tailor Platform workspace. **Usage** ``` -tailor-sdk workspace create [options] +tailor workspace create [options] ``` **Options** @@ -106,7 +106,7 @@ tailor-sdk workspace create [options] | `--organization-id ` | `-o` | Organization ID to workspace associate with | No | - | `TAILOR_PLATFORM_ORGANIZATION_ID` | | `--folder-id ` | `-f` | Folder ID to workspace associate with | No | - | `TAILOR_PLATFORM_FOLDER_ID` | | `--profile-name ` | `-p` | Profile name to create | No | - | - | -| `--profile-user ` | - | User email for the profile (defaults to current user) | No | - | - | +| `--profile-user ` | - | User email address or machine user client ID for the profile (defaults to current user) | No | - | - | | `--permission ` | - | Profile permission (requires --profile-name). 'read' blocks all write commands while the profile is active. | No | `"write"` | - | See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -118,7 +118,7 @@ Delete a Tailor Platform workspace. **Usage** ``` -tailor-sdk workspace delete [options] +tailor workspace delete [options] ``` **Options** @@ -137,7 +137,7 @@ Show detailed information about a workspace **Usage** ``` -tailor-sdk workspace get [options] +tailor workspace get [options] ``` **Options** @@ -156,7 +156,7 @@ List all Tailor Platform workspaces. **Usage** ``` -tailor-sdk workspace list [options] +tailor workspace list [options] ``` **Options** @@ -175,7 +175,7 @@ Restore a deleted workspace **Usage** ``` -tailor-sdk workspace restore [options] +tailor workspace restore [options] ``` **Options** @@ -194,7 +194,7 @@ Manage workspace users **Usage** ``` -tailor-sdk workspace user [command] +tailor workspace user [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -215,7 +215,7 @@ Invite a user to a workspace **Usage** ``` -tailor-sdk workspace user invite [options] +tailor workspace user invite [options] ``` **Options** @@ -236,7 +236,7 @@ List users in a workspace **Usage** ``` -tailor-sdk workspace user list [options] +tailor workspace user list [options] ``` **Options** @@ -257,7 +257,7 @@ Remove a user from a workspace **Usage** ``` -tailor-sdk workspace user remove [options] +tailor workspace user remove [options] ``` **Options** @@ -278,7 +278,7 @@ Update a user's role in a workspace **Usage** ``` -tailor-sdk workspace user update [options] +tailor workspace user update [options] ``` **Options** @@ -299,7 +299,7 @@ Manage workspace profiles (user + workspace combinations). **Usage** ``` -tailor-sdk profile [command] +tailor profile [command] ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -320,7 +320,7 @@ Create a new profile. **Usage** ``` -tailor-sdk profile create [options] +tailor profile create [options] ``` **Arguments** @@ -333,7 +333,7 @@ tailor-sdk profile create [options] | Option | Alias | Description | Required | Default | Env | | ------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------- | ---------------------------------- | -| `--user ` | `-u` | User email | Yes | - | - | +| `--user ` | `-u` | User email address or machine user client ID | Yes | - | - | | `--workspace-id ` | `-w` | Workspace ID | Yes | - | - | | `--permission ` | - | Profile permission. 'read' blocks all write commands while the profile is active. | No | `"write"` | - | | `--machine-user ` | `-m` | Default machine user name for application-data commands (query, workflow start, function test-run, machineuser token). | No | - | - | @@ -351,7 +351,7 @@ Delete a profile. **Usage** ``` -tailor-sdk profile delete +tailor profile delete ``` **Arguments** @@ -369,7 +369,7 @@ List all profiles. **Usage** ``` -tailor-sdk profile list +tailor profile list ``` See [Global Options](../cli-reference.md#global-options) for options available to all commands. @@ -381,7 +381,7 @@ Update profile properties. **Usage** ``` -tailor-sdk profile update [options] +tailor profile update [options] ``` **Arguments** @@ -394,7 +394,7 @@ tailor-sdk profile update [options] | Option | Alias | Description | Required | Default | | ------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | -| `--user ` | `-u` | New user email | No | - | +| `--user ` | `-u` | New user email address or machine user client ID | No | - | | `--workspace-id ` | `-w` | New workspace ID | No | - | | `--permission ` | - | Profile permission. 'read' blocks all write commands; 'write' lifts the restriction. | No | - | | `--machine-user ` | `-m` | Default machine user name for application-data commands (query, workflow start, function test-run, machineuser token). Pass an empty string to clear. | No | - | diff --git a/packages/sdk/docs/configuration.md b/packages/sdk/docs/configuration.md index 6d0333eb42..c5fc2d8db7 100644 --- a/packages/sdk/docs/configuration.md +++ b/packages/sdk/docs/configuration.md @@ -27,7 +27,7 @@ export default defineConfig({ cors: ["https://example.com"], allowedIpAddresses: ["192.168.1.0/24"], disableIntrospection: false, - logLevel: process.env.LOG_LEVEL ?? "DEBUG", + logLevel: process.env.TAILOR_APP_LOG_LEVEL ?? "DEBUG", }); ``` @@ -46,11 +46,11 @@ export default defineConfig({ ```typescript export default defineConfig({ name: "my-app", - logLevel: process.env.LOG_LEVEL ?? "DEBUG", + logLevel: process.env.TAILOR_APP_LOG_LEVEL ?? "DEBUG", }); ``` -This is a bundle-time setting. Changing `LOG_LEVEL` affects newly bundled deployments; already deployed functions must be redeployed. +This is a bundle-time setting. Changing `TAILOR_APP_LOG_LEVEL` affects newly bundled deployments; already deployed functions must be redeployed. ### Service Configuration @@ -300,5 +300,3 @@ export const plugins = definePlugins( enumConstantsPlugin({ distPath: "./generated/enums.ts" }), ); ``` - -See [Generators](./generator/index.md) for legacy `defineGenerators()` documentation. diff --git a/packages/sdk/docs/generator/builtin.md b/packages/sdk/docs/generator/builtin.md deleted file mode 100644 index d961934dfe..0000000000 --- a/packages/sdk/docs/generator/builtin.md +++ /dev/null @@ -1,257 +0,0 @@ -# Builtin Generators - -The SDK includes four builtin generators for common code generation tasks. - -## @tailor-platform/kysely-type - -Generates Kysely type definitions and the `getDB()` function for type-safe database access. - -### Configuration - -```typescript -["@tailor-platform/kysely-type", { distPath: "./generated/tailordb.ts" }]; -``` - -| Option | Type | Description | -| ---------- | -------- | --------------------------- | -| `distPath` | `string` | Output file path (required) | - -### Output - -Generates a TypeScript file containing: - -- Type definitions for all TailorDB types -- `getDB(namespace)` function to create Kysely instances -- Utility types for `Timestamp`, `Serial`, and `ObjectColumnType` (used for nested object fields so insert and select types stay correct) - -### Usage - -```typescript -import { getDB } from "./generated/tailordb"; - -// In resolvers -body: async (context) => { - const db = getDB("tailordb"); - const users = await db - .selectFrom("User") - .selectAll() - .where("email", "=", context.input.email) - .execute(); - return { users }; -}; - -// In executors -body: async ({ newRecord }) => { - const db = getDB("tailordb"); - await db.insertInto("AuditLog").values({ userId: newRecord.id, action: "created" }).execute(); -}; - -// In workflow jobs -body: async (input, { env }) => { - const db = getDB("tailordb"); - return await db - .selectFrom("Order") - .selectAll() - .where("id", "=", input.orderId) - .executeTakeFirst(); -}; -``` - -### Raw SQL - -For queries that the Kysely query builder can't express, use the `sql` tag re-exported from `@tailor-platform/sdk/kysely`. Plain value substitutions (`${...}`) are sent as bound parameters, so user-supplied values are parameterized safely. SQL fragments produced by Kysely helpers (for example `sql.raw(...)`, identifiers, refs) are inlined into the generated SQL string by design — do not pass untrusted input through those. - -```typescript -import { sql } from "@tailor-platform/sdk/kysely"; -import { getDB } from "./generated/tailordb"; - -createResolver({ - name: "supplierCountByState", - operation: "query", - input: { country: t.string() }, - output: t.object({ - rows: t.array(t.object({ state: t.string(), count: t.int() })), - }), - body: async ({ input }) => { - const db = getDB("tailordb"); - const { rows } = await sql<{ state: string; count: number }>` - SELECT state, COUNT(*) AS count - FROM "Supplier" - WHERE country = ${input.country} - GROUP BY state - `.execute(db); - return { rows }; - }, -}); -``` - -The same `sql` tag works inside `db.transaction().execute(async (trx) => ...)` by passing `trx` to `.execute()`: - -```typescript -await db.transaction().execute(async (trx) => { - await sql`UPDATE "Supplier" SET state = ${state} WHERE id = ${id}`.execute(trx); -}); -``` - -## @tailor-platform/enum-constants - -Extracts enum constants from TailorDB type definitions. - -### Configuration - -```typescript -["@tailor-platform/enum-constants", { distPath: "./generated/enums.ts" }]; -``` - -| Option | Type | Description | -| ---------- | -------- | --------------------------- | -| `distPath` | `string` | Output file path (required) | - -### Output - -Generates TypeScript constants for all enum fields: - -```typescript -// Generated output -export const OrderStatus = { - PENDING: "PENDING", - PROCESSING: "PROCESSING", - COMPLETED: "COMPLETED", - CANCELLED: "CANCELLED", -} as const; - -export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus]; -``` - -### Usage - -```typescript -import { OrderStatus } from "./generated/enums"; - -// Type-safe enum usage -const status: OrderStatus = OrderStatus.PENDING; - -// In queries -const orders = await db - .selectFrom("Order") - .selectAll() - .where("status", "=", OrderStatus.COMPLETED) - .execute(); -``` - -## @tailor-platform/file-utils - -Generates utility functions for handling file-type fields in TailorDB. - -### Configuration - -```typescript -["@tailor-platform/file-utils", { distPath: "./generated/files.ts" }]; -``` - -| Option | Type | Description | -| ---------- | -------- | --------------------------- | -| `distPath` | `string` | Output file path (required) | - -### Output - -Generates TypeScript interfaces and utilities for types with file fields: - -```typescript -// Generated output -export interface UserFileFields { - avatar: string; - documents: string; -} - -export function getUserFileFields(): (keyof UserFileFields)[] { - return ["avatar", "documents"]; -} -``` - -## @tailor-platform/seed - -Generates seed data configuration files for database initialization. - -### Configuration - -```typescript -// Basic configuration -["@tailor-platform/seed", { distPath: "./seed" }]; - -// With default machine user -["@tailor-platform/seed", { distPath: "./seed", machineUserName: "admin" }]; -``` - -| Option | Type | Description | -| -------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `distPath` | `string` | Output directory path (required) | -| `machineUserName` | `string` | Default machine user name (can be overridden at runtime) | -| `disableIdpUserSync` | `{ userToIdp?: boolean; idpToUser?: boolean }` | Skip emitting individual `_User <-> userProfile` foreign keys. Both directions are emitted by default. See [IdP user synchronization](#idp-user-synchronization). | - -### IdP user synchronization - -When `auth.userProfile` is configured, the seed plugin treats the userProfile -type (e.g. `User`) and the IdP-managed `_User` table as a pair. By default it -emits foreign keys in both directions so that `validate` rejects any row in -either table that does not have a matching counterpart: - -| Direction | Foreign key | Catches | -| ----------- | ---------------------------------------------- | ---------------------------------------------- | -| `idpToUser` | `_User.name` → `.` | Creating an IdP credential with no profile row | -| `userToIdp` | `.` → `_User.name` | Creating a profile row with no IdP credential | - -Neither direction is enforced by the runtime. In production it is normal for -one side to exist without the other — for example a userProfile row exists -the moment a user is invited, but the corresponding `_User` row appears only -after the user finishes signing up. To seed such states, set the relevant -direction in `disableIdpUserSync` to `true`: - -```typescript -// Allow seeding invited-but-not-registered userProfile rows. -// Still rejects _User rows without a matching userProfile row. -["@tailor-platform/seed", { distPath: "./seed", disableIdpUserSync: { userToIdp: true } }]; - -// Allow seeding _User rows whose userProfile does not exist yet. -// Still rejects userProfile rows without a matching _User row. -["@tailor-platform/seed", { distPath: "./seed", disableIdpUserSync: { idpToUser: true } }]; -``` - -Omitted directions default to `false` (FK emitted). - -### Output - -Generates a seed directory structure: - -``` -seed/ -├── data/ -│ ├── User.jsonl # Seed data files (JSONL format) -│ ├── User.schema.ts # lines-db schema definitions -│ └── Product.jsonl -└── exec.mjs # Executable script -``` - -### Usage - -Run the generated executable script: - -```bash -# With machine user from config -node seed/exec.mjs - -# Specify machine user at runtime (required if not configured, or to override) -node seed/exec.mjs --machine-user admin - -# Short form -node seed/exec.mjs -m admin - -# With other options -node seed/exec.mjs -m admin --truncate --yes -``` - -The `--machine-user` option is required at runtime if `machineUserName` is not configured in the generator options. - -The generated files are compatible with gql-ingest for bulk data import. - -The `exec.mjs` is fully regenerated on every `sdk generate` and starts with an `@generated` header — do not hand-edit it. Its `--truncate` path reuses the `tailordb truncate` command, so namespaces declared with `{ external: true }` are skipped automatically and a shell app cannot wipe a sibling app's data via `seed:reset`. diff --git a/packages/sdk/docs/generator/custom.md b/packages/sdk/docs/generator/custom.md deleted file mode 100644 index 5272636d34..0000000000 --- a/packages/sdk/docs/generator/custom.md +++ /dev/null @@ -1,147 +0,0 @@ -# Custom Generators (Deprecated) - -> **Deprecated**: Use `definePlugins()` with generation-time hooks (`onTypeLoaded`, `generate`, etc.) instead. See [Custom Plugins](../plugin/custom.md) for the recommended approach. - -Create your own generators by implementing the `CodeGenerator` interface. - -## CodeGenerator Interface - -```typescript -interface CodeGenerator { - id: string; - description: string; - - // Process individual items - processType(args: { - type: TailorDBType; - namespace: string; - source: { filePath: string; exportName: string }; - }): T | Promise; - - processResolver(args: { resolver: Resolver; namespace: string }): R | Promise; - - processExecutor(executor: Executor): E | Promise; - - // Aggregate per namespace (optional) - processTailorDBNamespace?(args: { - namespace: string; - types: Record; - }): Ts | Promise; - - processResolverNamespace?(args: { - namespace: string; - resolvers: Record; - }): Rs | Promise; - - // Final aggregation - aggregate(args: { - input: GeneratorInput; - executorInputs: E[]; - baseDir: string; - }): GeneratorResult | Promise; -} -``` - -## GeneratorResult - -Generators return a `GeneratorResult` containing files to create: - -```typescript -interface GeneratorResult { - files: Array<{ - path: string; // Relative path from project root - content: string; // File content - skipIfExists?: boolean; // Skip if file already exists (default: false) - executable?: boolean; // Make file executable (default: false) - }>; - errors?: string[]; -} -``` - -## Example: Simple Type List Generator - -```typescript -import type { CodeGenerator, GeneratorResult } from "@tailor-platform/sdk"; - -const typeListGenerator: CodeGenerator = { - id: "type-list", - description: "Generates a list of all TailorDB type names", - - processType({ type }) { - return type.name; - }, - - processResolver() { - return null; - }, - - processExecutor() { - return null; - }, - - processTailorDBNamespace({ types }) { - return Object.values(types); - }, - - aggregate({ input }) { - const allTypes = input.tailordb.flatMap((ns) => ns.types); - const content = `// Generated type list\nexport const types = ${JSON.stringify(allTypes, null, 2)} as const;\n`; - - return { - files: [{ path: "generated/types.ts", content }], - }; - }, -}; -``` - -## Using Custom Generators - -Pass the generator object directly to `defineGenerators()`: - -```typescript -import { defineGenerators } from "@tailor-platform/sdk"; -import { typeListGenerator } from "./generators/type-list"; - -export const generators = defineGenerators( - ["@tailor-platform/kysely-type", { distPath: "./generated/tailordb.ts" }], - typeListGenerator, // Custom generator -); -``` - -## Available Input Data - -### TailorDBType - -Contains full type information including: - -- `name`: Type name -- `fields`: Field definitions with types, validation, and descriptions -- `relations`: Relationship definitions -- `indexes`: Index configurations -- `permission`: Permission rules - -### Resolver - -Contains resolver configuration: - -- `name`: Resolver name -- `operation`: Query or mutation -- `input`: Input schema -- `output`: Output schema - -### Executor - -Contains executor configuration: - -- `name`: Executor name -- `trigger`: Trigger configuration -- `operation`: Execution target - -### GeneratorAuthInput - -Contains authentication configuration when available: - -- `name`: Auth service name -- `userProfile`: User profile type information -- `machineUsers`: Machine user definitions -- `oauth2Clients`: OAuth2 client configurations diff --git a/packages/sdk/docs/generator/index.md b/packages/sdk/docs/generator/index.md deleted file mode 100644 index 25882e41b5..0000000000 --- a/packages/sdk/docs/generator/index.md +++ /dev/null @@ -1,66 +0,0 @@ -# Generators - -Generators analyze your TailorDB types, Resolvers, and Executors to automatically generate TypeScript code. - -## Overview - -When you run `tailor-sdk generate`, the SDK: - -1. Loads all TailorDB types, Resolvers, and Executors from your configuration -2. Passes each definition to the configured generators -3. Aggregates the results and writes output files - -This enables generators to create derived code based on your application's schema. For example, the `@tailor-platform/kysely-type` generator produces type-safe database access code from your TailorDB definitions. - -## Configuration - -Define generators in `tailor.config.ts` using `defineGenerators()`: - -```typescript -import { defineConfig, defineGenerators } from "@tailor-platform/sdk"; - -export const generators = defineGenerators( - ["@tailor-platform/kysely-type", { distPath: "./generated/tailordb.ts" }], - ["@tailor-platform/enum-constants", { distPath: "./generated/enums.ts" }], -); - -export default defineConfig({ - name: "my-app", - // ... -}); -``` - -**Important**: The `generators` export must be a named export (not default). - -## CLI Commands - -### Generate Files - -```bash -tailor-sdk generate -``` - -Generates all configured output files. - -### Watch Mode - -```bash -tailor-sdk generate --watch -``` - -Watches for file changes and regenerates automatically. - -## Environment Variables - -### `TAILOR_PLATFORM_SDK_DTS_PATH` - -Customize the output path of the generated `tailor.d.ts` type definition file. By default, `tailor.d.ts` is written next to `tailor.config.ts`. - -```bash -TAILOR_PLATFORM_SDK_DTS_PATH=src/types/tailor.d.ts tailor-sdk generate -``` - -## Generator Types - -- [Builtin Generators](./builtin.md) - Ready-to-use generators included with the SDK -- [Custom Generators](./custom.md) - Create your own generators (Preview) diff --git a/packages/sdk/docs/github-actions.md b/packages/sdk/docs/github-actions.md index 4b765b4df9..1232e71c9d 100644 --- a/packages/sdk/docs/github-actions.md +++ b/packages/sdk/docs/github-actions.md @@ -1,10 +1,10 @@ # GitHub Actions Integration -`tailor-sdk setup` generates a GitHub Actions workflow that deploys your +`tailor setup` generates a GitHub Actions workflow that deploys your Tailor Platform application automatically on push or tag. > **Beta:** This command is under active development. CLI flags, the generated -> workflow, and the `.github/tailor-sdk.lock` schema may change before general +> workflow, and the `.github/tailor.lock` schema may change before general > availability. ## Quick start @@ -14,10 +14,10 @@ lives): ```bash # Branch target: deploy to stg on every push to main -tailor-sdk setup -n my-app-stg +tailor setup -n my-app-stg # Tag target: deploy to production when a tag is pushed, with an approval gate -tailor-sdk setup -n my-app-prod \ +tailor setup -n my-app-prod \ --tag --branch main --environment production ``` @@ -44,9 +44,9 @@ The branch target fires on pull requests and pushes to the branch you specify (defaulting to the repository's default branch when `--branch` is omitted): ```bash -tailor-sdk setup -n my-app-stg +tailor setup -n my-app-stg # Equivalent to: -tailor-sdk setup -n my-app-stg --branch main +tailor setup -n my-app-stg --branch main ``` What it does: @@ -69,7 +69,7 @@ Pass `--erd-preview` on a branch target to add TailorDB ERD preview artifacts to pull requests: ```bash -tailor-sdk setup -n my-app-stg --erd-preview +tailor setup -n my-app-stg --erd-preview ``` The generated workflow builds one self-contained ERD viewer HTML file for each @@ -96,7 +96,7 @@ The tag target fires when a tag matching `--tag-pattern` (default `v*`) is pushed: ```bash -tailor-sdk setup -n my-app-prod \ +tailor setup -n my-app-prod \ --tag --tag-pattern "v*" --branch main --environment production ``` @@ -145,7 +145,7 @@ Because the variable is scoped to a GitHub Environment, both the `plan` and this is a manual step: ```bash - tailor-sdk workspace create # copy the printed workspace id + tailor workspace create # copy the printed workspace id ``` 2. Set the id as the Environment variable (the environment name is your @@ -188,7 +188,7 @@ step or added your own — the command stops and reports the conflict. Pass re-apply your own steps. (Preserving user-added steps across regeneration is planned.) -### `.github/tailor-sdk.lock` +### `.github/tailor.lock` A machine-owned JSON file that tracks which files the SDK manages, the inputs they were generated from, and their content hashes. **Commit this file. Never @@ -198,14 +198,14 @@ detect hand edits. ### `tailor.config.ts` (id injection) If your config does not already have an `id` field, `setup` injects one. -This `id` must be committed alongside the workflow file. In CI, `tailor-sdk +This `id` must be committed alongside the workflow file. In CI, `tailor deploy` refuses to inject a new id — if the id were assigned fresh on each CI run, every deploy would create a brand-new application and lose ownership of previously deployed resources. If your pipeline intentionally deploys a fresh, throwaway application on every run (for example an end-to-end test harness that creates and deletes its own -workspace), set `TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION=true` to opt back +workspace), set `TAILOR_CI_ALLOW_ID_INJECTION=true` to opt back into automatic id injection for that pipeline. ## Secrets @@ -268,7 +268,7 @@ you can deploy any commit regardless of branch membership. For a monorepo where your SDK app lives in a subdirectory, pass `--dir`: ```bash -tailor-sdk setup -n my-app --dir apps/backend +tailor setup -n my-app --dir apps/backend ``` The generated workflow adds a `paths` filter on `apps/backend/**` so the @@ -277,7 +277,7 @@ SDK commands is set accordingly. ## Rollback -`tailor-sdk deploy` is declarative: redeploying a past configuration returns +`tailor deploy` is declarative: redeploying a past configuration returns the platform to that state. The recommended rollback approaches are: ### Option 1 — Revert the commit (branch target) @@ -333,10 +333,10 @@ A typical setup with staging and production: ```bash # Staging: main → stg (deploy on every push to main) -tailor-sdk setup -n my-app-stg +tailor setup -n my-app-stg # Production: tagged commits → prod, with approval gate and branch guard -tailor-sdk setup -n my-app-prod \ +tailor setup -n my-app-prod \ --tag --branch main --environment production ``` @@ -354,12 +354,12 @@ gh secret set TAILOR_PLATFORM_MACHINE_USER_CLIENT_ID --env production gh secret set TAILOR_PLATFORM_MACHINE_USER_CLIENT_SECRET --env production ``` -Commit both workflow files and `.github/tailor-sdk.lock`. +Commit both workflow files and `.github/tailor.lock`. ## Checking for drift -`tailor-sdk setup check` audits the workflows recorded in -`.github/tailor-sdk.lock` against your current config and repository, without +`tailor setup check` audits the workflows recorded in +`.github/tailor.lock` against your current config and repository, without writing anything. It reports when a workflow file is missing or hand-edited, a newer template is available, `tailor.config.ts` is no longer under the recorded `--dir`, or the repository default branch no longer matches a branch target's @@ -373,5 +373,5 @@ template improvements. If the SDK detects that you have hand-edited a managed section, it stops and asks you to use `--force` to overwrite your edits, or to move your customizations into your own steps before regenerating. -The `.github/tailor-sdk.lock` file records the flags used at generation time, +The `.github/tailor.lock` file records the flags used at generation time, so you can check what arguments were used previously. diff --git a/packages/sdk/docs/migration/v2.md b/packages/sdk/docs/migration/v2.md new file mode 100644 index 0000000000..28f7d644c4 --- /dev/null +++ b/packages/sdk/docs/migration/v2.md @@ -0,0 +1,987 @@ +# Migrating to v2 + + + +Run the codemods, then finish anything reported as not migrated automatically: + +```sh +npx @tailor-platform/sdk-codemod --from --to +``` + +## defineGenerators → definePlugins + +**Migration:** Partially automatic + +Migrate defineGenerators() tuple syntax to definePlugins() with explicit plugin imports + +Before: + +```ts +import { defineGenerators } from "@tailor-platform/sdk"; + +export const generators = defineGenerators( + ["@tailor-platform/kysely-type", { distPath: "db.ts" }], +); +``` + +After: + +```ts +import { definePlugins } from "@tailor-platform/sdk"; +import { kyselyTypePlugin } from "@tailor-platform/sdk/plugin/kysely-type"; + +export const generators = definePlugins(kyselyTypePlugin({ distPath: "db.ts" })); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +defineGenerators() is replaced by definePlugins() in v2. The codemod rewrites the +known plugin tuples (kysely-type, enum-constants, file-utils, seed). For any +remaining defineGenerators([...]) the codemod left in place — a plugin it does not +know, or a non-tuple/spread form — convert it to definePlugins(pluginFn(config)), +importing the matching plugin from its @tailor-platform/sdk/plugin/ subpath. +``` + +
+ +## @tailor-platform/sdk/cli plugin imports → dedicated subpaths + +**Migration:** Automatic + +Rewrite deprecated plugin re-export imports (kyselyTypePlugin, enumConstantsPlugin, fileUtilsPlugin, seedPlugin) from `@tailor-platform/sdk/cli` to their dedicated plugin subpaths + +Before: + +```ts +import { kyselyTypePlugin } from "@tailor-platform/sdk/cli"; +``` + +After: + +```ts +import { kyselyTypePlugin } from "@tailor-platform/sdk/plugin/kysely-type"; +``` + +## function test-run --arg input unwrap + +**Migration:** Automatic + +Strip the deprecated {input: ...} wrapper from `tailor function test-run --arg` JSON in scripts and docs + +Before: + +```sh +tailor function test-run resolvers/add.ts --arg '{"input":{"a":1}}' +``` + +After: + +```sh +tailor function test-run resolvers/add.ts --arg '{"a":1}' +``` + +## tailor-sdk-skills → tailor-sdk skills install + +**Migration:** Partially automatic + +Replace deprecated `tailor-sdk-skills` invocations with `tailor-sdk skills install` + +Before: + +```sh +npx tailor-sdk-skills +``` + +After: + +```sh +tailor-sdk skills install +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +The standalone tailor-sdk-skills binary is removed in v2; call the skills install +subcommand on the main tailor-sdk CLI instead. Replace any remaining +tailor-sdk-skills invocations the codemod did not rewrite with +`tailor-sdk skills install`. +``` + +
+ +## Unify TailorUser/TailorActor/TailorActorType/TailorInvoker → TailorPrincipal + +**Migration:** Partially automatic + +Rename TailorUser/TailorActor/TailorActorType/TailorInvoker to TailorPrincipal, drop unauthenticatedTailorUser, rename resolver body `user` to `caller`, and rename TailorDB callback `user` to `invoker` + +Type references unify under `TailorPrincipal`: + +Before: + +```ts +import type { TailorUser } from "@tailor-platform/sdk"; +``` + +After: + +```ts +import type { TailorPrincipal } from "@tailor-platform/sdk"; +``` + +The resolver body `user` becomes `caller`: + +Before: + +```ts +body: ({ input, user }) => user.id, +``` + +After: + +```ts +body: ({ input, caller }) => caller.id, +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +Finish the cases the codemod left for manual migration: +- Rename user -> caller in resolver bodies the codemod skipped because a `caller` + binding already exists or renaming would shadow/collide with another value. +- Replace member-access on the removed unauthenticatedTailorUser (e.g. + unauthenticatedTailorUser.id); the codemod only replaced standalone references + with null and left member access to surface a type error. +- Review helper adapters that still accept or read `context.user`; v2 resolver + context uses nullable `caller` and `invoker`, so project-specific helper + semantics for anonymous callers and command invokers must be chosen explicitly. +- Review `caller?.` values passed to APIs that require non-null values. If the + resolver requires authentication, throw or otherwise narrow before the call; + if anonymous callers are allowed, keep the nullable flow explicit. +Use TailorPrincipal for the unified user/actor/invoker type. +``` + +
+ +## AttributeMap → Attributes + +**Migration:** Partially automatic + +Rename auth attribute module augmentation and related SDK type names from `AttributeMap` to `Attributes` + +Module augmentation uses `Attributes`: + +Before: + +```ts +declare module "@tailor-platform/sdk" { + interface AttributeMap { + role: string; + } +} +``` + +After: + +```ts +declare module "@tailor-platform/sdk" { + interface Attributes { + role: string; + } +} +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +In Tailor SDK v2, the auth attribute type API is renamed from `AttributeMap` +to `Attributes`; related SDK types are renamed to `UserAttributes` and +`InferredAttributes`. The codemod rewrites SDK imports, re-exports, +namespace-qualified references, import() type references, and module +augmentations. Review any remaining matches manually and leave unrelated +local names or deploy/proto wire field names unchanged. +``` + +
+ +## tailor-sdk apply → tailor-sdk deploy + +**Migration:** Automatic + +Rewrite `tailor-sdk apply` invocations in package.json scripts, shell scripts, CI configs, and docs to the canonical v2 `tailor-sdk deploy` command + +Before: + +```sh +tailor-sdk apply --profile prod +``` + +After: + +```sh +tailor-sdk deploy --profile prod +``` + +## v2 CLI rename + +**Migration:** Partially automatic + +Rewrite `tailor-sdk crash-report` to `tailor-sdk crashreport` and `--machineuser` to `--machine-user` across package.json scripts, shell scripts, CI configs, and docs + +Before: + +```sh +tailor-sdk crash-report list +tailor-sdk login --machineuser +``` + +After: + +```sh +tailor-sdk crashreport list +tailor-sdk login --machine-user +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +Apply the v2 CLI renames the codemod did not reach (only `tailor-sdk`-prefixed +invocations are rewritten): `tailor-sdk crash-report` -> `tailor-sdk crashreport` +and the `--machineuser` option -> `--machine-user`. Leave unrelated commands that +happen to use `--machineuser` alone. +``` + +
+ +## SDK environment variable rename + +**Migration:** Partially automatic + +Rewrite unambiguous removed SDK environment variable names to their v2 `TAILOR_*` names and flag generic names for manual review + +Before: + +```sh +TAILOR_PLATFORM_SDK_BUILD_ONLY=true tailor-sdk deploy +``` + +After: + +```sh +TAILOR_DEPLOY_BUILD_ONLY=true tailor-sdk deploy +``` + +Before: + +```ts +const token = process.env.TAILOR_TOKEN; +``` + +After: + +```ts +const token = process.env.TAILOR_PLATFORM_TOKEN; +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +Review any remaining removed SDK environment variable names after the codemod +runs. The codemod intentionally leaves generic names such as `LOG_LEVEL`, +`PLATFORM_URL`, and `PLATFORM_OAUTH2_CLIENT_ID` for manual review because +they can configure non-SDK tools. Replace only actual SDK usages with their +v2 names. If a remaining match is an unrelated local identifier, fixture +label, or historical documentation that intentionally does not configure the +SDK, leave it unchanged. +``` + +
+ +## auth.invoker("name") → "name" + +**Migration:** Partially automatic + +Replace statically identified SDK `auth.invoker("name")` option values with the bare `"name"` string while preserving the `authInvoker` key for SDK versions before the option rename. + +Before: + +```ts +createResolver({ authInvoker: auth.invoker("manager") }); +``` + +After: + +```ts +createResolver({ authInvoker: "manager" }); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +In Tailor SDK v2 the auth.invoker() helper is removed; an invoker is now the +machine user name passed directly as a string. The codemod already rewrote the +statically identified SDK option form authInvoker: auth.invoker("name") to authInvoker: "name". These files still contain +auth.invoker(...) calls that need manual review. + +For each remaining auth.invoker() call: +1. Replace the whole call with only where the target option expects a + machine user name string; platform/runtime authInvoker payloads still expect + the object form. +2. Keep the authInvoker key when targeting SDK versions before the invoker + option rename; later v2 targets run a separate codemod for that key rename. +3. After removing every auth.invoker usage in a file, delete the now-unused auth + import (keeping it pulls Node-only config modules into runtime bundles); leave + the import if auth is still referenced elsewhere. + +Do not change behavior beyond the auth.invoker() removal. +``` + +
+ +## auth.invoker("name") → invoker: "name" + +**Migration:** Partially automatic + +Rename statically identified SDK `authInvoker` options to `invoker`, replace `auth.invoker("name")` there with the bare `"name"` string, and drop the `auth` import when no other reference remains. Ambiguous workflow `.trigger()` calls are left for manual review. The `auth.invoker()` helper is removed in v2 because importing `auth` from `tailor.config.ts` into runtime files pulls Node-only modules into the bundle. + +Before: + +```ts +createResolver({ invoker: auth.invoker("manager") }); +``` + +After: + +```ts +createResolver({ invoker: "manager" }); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +In Tailor SDK v2 the auth.invoker() helper is removed; an invoker is now the +machine user name passed directly as a string. The codemod already rewrote the +statically identified SDK option form authInvoker: auth.invoker("name") to invoker: "name" and renamed supported authInvoker option keys. These files still contain +auth.invoker(...) calls or authInvoker keys that need manual review. + +For each remaining auth.invoker() call: +1. Replace the whole call with only where the target option expects a + machine user name string; platform/runtime authInvoker payloads still expect + the object form. +2. Rename remaining authInvoker option keys to invoker only for SDK resolver, + executor, workflow.trigger(), or startWorkflow() options. Keep platform/runtime + payload keys such as tailor.workflow.triggerWorkflow(..., { authInvoker: ... }). +3. After removing every auth.invoker usage in a file, delete the now-unused auth + import (keeping it pulls Node-only config modules into runtime bundles); leave + the import if auth is still referenced elsewhere. + +Do not change behavior beyond the SDK option rename and auth.invoker() removal. +``` + +
+ +## auth.getConnectionToken() → runtime authconnection + +**Migration:** Partially automatic + +The deprecated `auth.getConnectionToken()` helper returned by `defineAuth()` is removed in v2. Use `authconnection.getConnectionToken(...)` from `@tailor-platform/sdk/runtime` in resolvers, executors, and workflows instead. + +Before: + +```ts +import { auth } from "../tailor.config"; + +const token = await auth.getConnectionToken("google"); +``` + +After: + +```ts +import { authconnection } from "@tailor-platform/sdk/runtime"; + +const token = await authconnection.getConnectionToken("google"); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +In Tailor SDK v2 the auth.getConnectionToken() helper returned by defineAuth() +is removed. Runtime code should call authconnection.getConnectionToken(...) from +@tailor-platform/sdk/runtime instead of importing auth from tailor.config.ts. + +For each getConnectionToken usage where is a defineAuth() result +imported from tailor.config.ts: +1. Replace .getConnectionToken() calls with + authconnection.getConnectionToken(). +2. Update non-call references, including .getConnectionToken, + ["getConnectionToken"], and destructuring from , to + reference authconnection instead. +3. Add or reuse `import { authconnection } from "@tailor-platform/sdk/runtime"`. +4. Remove the auth import from tailor.config.ts only when no other auth reference + remains in the file. + +Leave usages unchanged when the receiver is already the runtime authconnection +wrapper or global tailor.authconnection. +``` + +
+ +## Tailordb → tailordb (lowercase ambient namespace) + +**Migration:** Partially automatic + +Rewrite references to the removed capital-cased `Tailordb` ambient namespace (`Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, `typeof Tailordb.Client`) to the lowercase `tailordb.*` namespace exposed by `@tailor-platform/sdk/runtime/globals`. Because v2 no longer activates ambient declarations automatically, each file that contains `tailordb.*` references after the rewrite must also add `import "@tailor-platform/sdk/runtime/globals"`. + +Before: + +```ts +const command: Tailordb.CommandType = "SELECT"; +``` + +After: + +```ts +import "@tailor-platform/sdk/runtime/globals"; +const command: tailordb.CommandType = "SELECT"; +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +The capital-cased Tailordb ambient namespace is removed in v2; use the lowercase +tailordb.* namespace from @tailor-platform/sdk/runtime/globals. The codemod rewrites +the known members (QueryResult, CommandType, Client). Rewrite any other remaining +Tailordb.* reference to its tailordb.* equivalent (and confirm the member still +exists on the lowercase namespace). +Also add `import "@tailor-platform/sdk/runtime/globals"` at the top of each file +that contains any tailordb.* type reference — v2 no longer activates ambient +declarations automatically on SDK import. +``` + +
+ +## executeScript arg JSON.stringify → value + +**Migration:** Partially automatic + +Unwrap `JSON.stringify(...)` passed as the `executeScript` `arg` option. In v2 `arg` takes a JSON-serializable value and is serialized internally, so a pre-stringified argument double-encodes. + +Before: + +```ts +await executeScript({ ...opts, arg: JSON.stringify({ a: 1 }) }); +``` + +After: + +```ts +await executeScript({ ...opts, arg: { a: 1 } }); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +In Tailor SDK v2 the executeScript() arg option takes a JSON-serializable value +and is serialized internally, so a pre-stringified argument double-encodes. The +codemod already rewrote the direct form arg: JSON.stringify(X) to arg: X. Review +the executeScript calls in these files for cases it could not rewrite — where the +arg value is reached indirectly, for example: +- a variable holding a JSON.stringify(...) result (const s = JSON.stringify(x); ... arg: s) +- JSON.stringify(x, null, 2) or another multi-argument form +- an options object built or spread dynamically + +For each such call, pass the underlying value directly as arg (drop the +JSON.stringify wrapper) so executeScript serializes it once. Leave calls that +already pass a plain value unchanged. +``` + +
+ +## defineWaitPoint/defineWaitPoints → createWaitPoint/createWaitPoints + +**Migration:** Partially automatic + +Rename `defineWaitPoint` and `defineWaitPoints` to `createWaitPoint` and `createWaitPoints`. The functions create runtime instances with `.wait()` / `.resolve()` methods, so the `create*` prefix is used consistently. + +Before: + +```ts +import { defineWaitPoints } from "@tailor-platform/sdk"; + +export const { approval } = defineWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); +``` + +After: + +```ts +import { createWaitPoints } from "@tailor-platform/sdk"; + +export const { approval } = createWaitPoints((define) => ({ + approval: define<{ message: string }, { approved: boolean }>(), +})); +``` + +## openDownloadStream → downloadStream + +**Migration:** Manual + +The deprecated `openDownloadStream` file-streaming API is removed in v2. Use `downloadStream` for streamed file downloads. The generated file utilities now emit `downloadFileStream` (which calls `downloadStream` and returns `FileDownloadStreamResponse`) instead of the removed `openFileDownloadStream` helper. + +Before: + +```ts +const res = await openDownloadStream(namespace, typeName, fieldName, recordId); +``` + +After: + +```ts +const res = await downloadStream(namespace, typeName, fieldName, recordId); +``` + +
+Prompt for an AI agent (to perform this migration) + +```text +The openDownloadStream file-streaming API is removed in v2. Replace every call to +openDownloadStream with downloadStream (same arguments). If you used the generated +openFileDownloadStream helper, switch to downloadFileStream, which calls +downloadStream and returns FileDownloadStreamResponse. +``` + +
+ +## Ambient runtime globals are opt-in + +**Migration:** Partially automatic + +Importing `@tailor-platform/sdk` no longer activates the ambient `tailor.*` / `tailordb.*` global declarations. The codemod rewrites simple direct `new tailor.idp.Client(...)` calls to the typed `idp.Client` wrapper from `@tailor-platform/sdk/runtime`; broader runtime global usage remains review-only. Only if you relied on the ambient globals directly, add `import "@tailor-platform/sdk/runtime/globals"`. (The capital-cased `Tailordb.*` namespace is removed separately — see the `Tailordb → tailordb` codemod.) + +Preferred: switch to the typed wrappers from `@tailor-platform/sdk/runtime` and drop the ambient globals: + +Before: + +```ts +const client = new tailor.idp.Client(); +``` + +After: + +```ts +import { idp } from "@tailor-platform/sdk/runtime"; +const client = new idp.Client({ namespace: "my-namespace" }); +``` + +Fallback: only if you must keep referencing the bare `tailor.*` names, opt into the global declarations: + +Before: + +```ts +const client = new tailor.idp.Client(); +``` + +After: + +```ts +import "@tailor-platform/sdk/runtime/globals"; +const client = new tailor.idp.Client(); +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +The v2 SDK no longer enables ambient Tailor runtime globals from +`@tailor-platform/sdk`. For each flagged file that uses `tailor.*`, +`tailordb.*`, or Tailor runtime error globals, prefer migrating to the +typed wrappers from `@tailor-platform/sdk/runtime`. The codemod already +rewrites direct `new tailor.idp.Client(...)` calls to `new idp.Client(...)` +when the file has no conflicting `tailor` or `idp` binding. For any remaining +`tailor.idp.Client` references, either resolve the binding collision and use +`idp.Client`, or keep the ambient global deliberately. + +Only when the file must keep referencing the bare `tailor.*` names directly, +opt into the global declarations instead by adding one of these: +- per-file: `import "@tailor-platform/sdk/runtime/globals";` +- project-wide: `"types": ["@tailor-platform/sdk/runtime/globals"]` in + the relevant tsconfig compilerOptions + +Leave files unchanged when the matching name is local, imported from another +module, or appears only in comments or prose strings. Embedded code strings +that use runtime globals are review-only findings; do not insert imports inside +string literals. +``` + +
+ +## Workflow .trigger() and trigger tests + +**Migration:** Manual + +Workflow job `.trigger()` now aligns with the platform runtime: it returns the job result directly instead of a Promise wrapper, and tests no longer run job bodies locally. Mock trigger responses with `mockWorkflow()` (`setJobHandler` / `enqueueResult`, assert via `triggeredJobs`), or use `runWorkflowLocally()` for a full-chain local run. + +Tests must mock the workflow runtime instead of running bodies locally: + +Before: + +```ts +const result = await orderJob.trigger({ id }); +expect(result.status).toBe("done"); +``` + +After: + +```ts +using wf = mockWorkflow(); +wf.setJobHandler((jobName) => (jobName === "order-job" ? { status: "done" } : null)); +const result = await orderJob.trigger({ id }); +expect(result.status).toBe("done"); +``` + +
+Prompt for an AI agent (to perform this migration) + +```text +Workflow job .trigger() now uses the platform workflow runtime instead of running +the job body locally. In tests, acquire `using wf = mockWorkflow()` and provide +trigger responses (setJobHandler / enqueueResult), or use runWorkflowLocally() for a +full-chain local run; an unmocked trigger now throws. Outside tests, treat the +trigger result as the job output directly (no Promise wrapper to unwrap). +``` + +
+ +## Strict scalar string types for UUID/date/datetime/time/decimal fields + +**Migration:** Manual + +Tailor field outputs infer strict string shapes instead of plain `string`: UUID fields are `UUIDString`, date fields `DateString`, datetime fields `DateTimeString | Date`, time fields `TimeString`, and decimal fields `DecimalString` (all exported from `@tailor-platform/sdk`). Generated Kysely types, migration DB helper types, auth `tailor.d.ts` attributes, and runtime principal / IdP user ids use the same aliases, and the Kysely `Timestamp` columns now select as `Date | DateTimeString` instead of `Date`. String values that already match a shape keep typechecking unchanged; plain `string` values must be narrowed with the new `is*String` / `parse*String` / `assert*String` helpers or have their source types tightened. + +Tighten the source type — or narrow at an untyped boundary with the scalar helpers — instead of passing plain `string` to a strict scalar API: + +Before: + +```ts +async function loadCustomer(customerId: string) { + return getDB("tailordb") + .selectFrom("Customer") + .where("id", "=", customerId) + .selectAll() + .executeTakeFirstOrThrow(); +} +``` + +After: + +```ts +import { type UUIDString, parseUUIDString } from "@tailor-platform/sdk"; + +async function loadCustomer(customerId: UUIDString) { + return getDB("tailordb") + .selectFrom("Customer") + .where("id", "=", customerId) + .selectAll() + .executeTakeFirstOrThrow(); +} + +// At an untyped boundary: +await loadCustomer(parseUUIDString(value, "customerId")); +``` + +Kysely `Timestamp` columns now select as `Date | DateTimeString`, so guard `Date` methods: + +Before: + +```ts +return { createdAt: customer.createdAt.toISOString() }; +``` + +After: + +```ts +return { + createdAt: + customer.createdAt instanceof Date + ? customer.createdAt.toISOString() + : customer.createdAt, +}; +``` + +Record and principal ids are `UUIDString`, so test fixtures need UUID-shaped literals: + +Before: + +```ts +const payload = { newRecord: { id: "user-1" } }; +``` + +After: + +```ts +const payload = { newRecord: { id: "11111111-1111-4111-8111-111111111111" } }; +``` + +
+Prompt for an AI agent (to perform this migration) + +```text +The v2 SDK types UUID, date, datetime, time, and decimal Tailor field values as +strict string shapes (UUIDString, DateString, DateTimeString, TimeString, +DecimalString from @tailor-platform/sdk) instead of plain string. Generated +Kysely types, auth attributes, and runtime principal / IdP user ids use the same +aliases, and Kysely Timestamp columns now select as Date | DateTimeString. +Regenerate the generated types (tailor generate), then typecheck the project and +fix the remaining errors: +- String literals that already match a shape typecheck unchanged. +- For string or unknown values, tighten the source type (e.g. declare the + parameter as UUIDString, use t.uuid() for resolver inputs that carry record + ids), or narrow with the isUUIDString / parseUUIDString / assertUUIDString + helper families (same variants exist for date, datetime, time, and decimal). +- Guard Date methods on selected timestamp columns: + value instanceof Date ? value.toISOString() : value. +- Give test fixtures UUID-shaped ids (e.g. "11111111-1111-4111-8111-111111111111"). + The mockIdp default user id changed from "mock-id" to + "123e4567-e89b-12d3-a456-426614174000". +- Existing migration db.ts snapshots keep compiling; leave them unchanged. New + migrations are generated with the strict shapes automatically. +``` + +
+ +## tailor-sdk binary → tailor + +**Migration:** Partially automatic + +Rename the CLI binary from `tailor-sdk` to `tailor` in package.json scripts, shell scripts, CI workflows, source files, generated declaration comments, and documentation. Does not rename `.tailor-sdk` directory paths or the `create-tailor-sdk` scaffolding package. Note: v2 also changes the default generated output directory from `.tailor-sdk/` to `.tailor/` and the setup lock file from `.github/tailor-sdk.lock` to `.github/tailor.lock`. Run `mv .tailor-sdk .tailor` to migrate the generated output directory (preserves auth connection state and other local files). Run `git mv .github/tailor-sdk.lock .github/tailor.lock` if the old lock file exists; without it `tailor setup check` will treat all managed workflows as missing. Exact ignore-file entries for `.tailor-sdk/` are handled by the generated-output ignore codemod. + +Before: + +```sh +tailor-sdk deploy +npx tailor-sdk@latest login +``` + +After: + +```sh +tailor deploy +npx @tailor-platform/sdk@latest login +``` + +
+Prompt for an AI agent (to finish the cases the codemod could not migrate) + +```text +Rename any remaining `tailor-sdk` binary invocations to `tailor`. Only rewrite +the binary name — leave `.tailor-sdk` directory paths and `create-tailor-sdk` +package references unchanged. +``` + +
+ +## .tailor-sdk ignore entries → .tailor + +**Migration:** Automatic + +Rewrite exact ignore-file entries for the v1 generated output directory from `.tailor-sdk` to the v2 `.tailor` directory. Other `.tailor-sdk` paths and prose are left unchanged. + +Before: + +```gitignore +.tailor-sdk/ +``` + +After: + +```gitignore +.tailor/ +``` + +## ValidateFn simplification and type-level validate + +**Migration:** Manual + +Field-level `ValidateFn` is simplified from `(args: { value, data, invoker }) => boolean` to `(args: { value }) => string | void` — the function now returns the error message directly instead of a separate `[fn, message]` tuple. The `ValidateConfig` tuple form and `Validators` record syntax on `db.type().validate()` are removed. Type-level validation uses `db.type().validate((args, issues) => void)` with `{ newRecord, oldRecord, invoker }` args and an `issues(field, message)` callback for cross-field rules. + +Field-level validate: return an error message string instead of a boolean (tuple form removed): + +Before: + +```ts +.validate( + [({ value }) => value.length > 5, "Name must be longer than 5 characters"], +) +``` + +After: + +```ts +.validate(({ value }) => + value.length <= 5 ? "Name must be longer than 5 characters" : undefined, +) +``` + +Type-level validate: per-field record syntax replaced by a single function with `issues()` callback: + +Before: + +```ts +.validate({ + name: [({ value }) => value.length > 5, "Name must be longer than 5"], +}) +``` + +After: + +```ts +.validate(({ newRecord }, issues) => { + if (newRecord.name && newRecord.name.length <= 5) { + issues("name", "Name must be longer than 5"); + } +}) +``` + +
+Prompt for an AI agent (to perform this migration) + +```text +The v2 SDK simplifies field validation and introduces type-level validation. + +Field-level `.validate()` changes: +- Signature: `(args: { value, data, invoker }) => boolean` → `(args: { value }) => string | void` +- The function now returns the error message string directly (or undefined/void to pass) + instead of returning a boolean with the message in a separate tuple. +- The `[fn, errorMessage]` tuple form (`ValidateConfig`) is removed. +- `data` and `invoker` are no longer available in field-level validators. + Use type-level `.validate()` for cross-field or invoker-dependent rules. + +Type-level `.validate()` on `db.type()` changes: +- Old: `.validate({ fieldName: fn | [fn, msg] | fn[] })` (per-field record, `Validators` type) +- New: `.validate((args, issues) => void)` (single function, `TypeValidateFn` type) +- Args: `{ newRecord, oldRecord, invoker }` — `newRecord` is the record after hooks run +- Call `issues(field, message)` to report validation errors; `field` supports dotted paths +- Move per-field validators that need `data`/`invoker` to the type-level function + +For each remaining `ValidateConfig`, `Validators<`, or old-signature `.validate()` usage: +1. Rewrite field-level validators to return the error string directly +2. Move cross-field / invoker-dependent validators to the type-level function +3. Remove unused `ValidateConfig` / `Validators` type imports +``` + +
+ +## TailorDB hook redesign: field-level args and type-level hooks + +**Migration:** Manual + +Field-level `HookFn` args change from `{ value, data, invoker }` to `{ value, oldValue, invoker, now }` — `data` (the full record) is replaced by `oldValue` (the previous field value) and `now` (operation timestamp). Type-level hooks on `db.type().hooks()` change from per-field mapping `{ fieldName: { create, update } }` (`Hooks`) to a single `{ create, update }` object (`TypeHook`) where each function takes `{ input, oldRecord, invoker, now }` and returns partial field overrides. + +Field-level hooks: `data` replaced by `oldValue` and `now`; use `now` instead of `new Date()`: + +Before: + +```ts +db.datetime().hooks({ + create: ({ value }) => value ?? new Date(), + update: () => new Date(), +}) +``` + +After: + +```ts +db.datetime().hooks({ + create: ({ value, now }) => value ?? now, + update: ({ now }) => now, +}) +``` + +Type-level hooks: per-field mapping replaced by single create/update functions: + +Before: + +```ts +.hooks({ + fullAddress: { + create: ({ data }) => `${data.postalCode} ${data.address}`, + update: ({ data }) => `${data.postalCode} ${data.address}`, + }, +}) +``` + +After: + +```ts +.hooks({ + create: ({ input }) => ({ + fullAddress: `${input.postalCode} ${input.address}`, + }), + update: ({ input }) => ({ + fullAddress: `${input.postalCode} ${input.address}`, + }), +}) +``` + +
+Prompt for an AI agent (to perform this migration) + +```text +The v2 SDK redesigns TailorDB hooks at both field and type levels. + +Field-level `.hooks()` on individual fields: +- Args: `{ value, data, invoker }` → `{ value, oldValue, invoker, now }` +- `data` (full record) is removed; use `oldValue` (previous field value) instead +- `now` provides the operation timestamp — use `now` instead of `new Date()` +- If a field-level hook needs the full record (other fields), move it to a type-level hook + +Type-level `.hooks()` on `db.type()`: +- Old: `.hooks({ fieldName: { create: fn, update: fn } })` (per-field mapping, `Hooks` type) +- New: `.hooks({ create: fn, update: fn })` (single object, `TypeHook` type) +- Each function: `({ input, oldRecord, invoker, now }) => ({ fieldName: value, ... })` +- `input` is the pre-hook input (may have nullish values for optional/defaulted fields) +- `oldRecord` is null on create, the previous record on update +- Return an object with only the fields to override; unmentioned fields are unchanged + +Migration steps for each `.hooks()` call on a `db.type()`: +1. If the old per-field hooks only use `value`/`invoker` and don't reference `data`, + convert them to field-level hooks with the new args (`oldValue`, `now`) +2. If the old hooks reference `data` (cross-field access), convert to a type-level hook + using `input`/`oldRecord` +3. Remove unused `Hooks` / `HookFn<>` type imports +``` + +
+ +## Behavioral changes (no migration required) + +These v2 changes alter runtime or CLI behavior; no source change is needed. + +### CLI tokens stored in the OS keyring + +CLI login tokens are stored in the OS keyring by default when available, falling back to the platform config file when it is not. No source change is required; re-login if you need tokens moved into the keyring. + +### CLI users keyed by subject ID + +The CLI stores human users by their stable subject ID instead of email (email is kept for display). Legacy email-keyed entries are migrated automatically on the next login or token refresh. No source change is required. + +### function logs require a content hash for source mapping + +`tailor function logs` maps stack traces against the function bundle only when the execution recorded a `contentHash`. Executions without one now show raw stack traces instead of mapped frames. No source change is required. + +### Node.js minimum version raised to 22.15.0 + +v2 requires Node.js **22.15.0** or later. This is the first version that includes `module.registerHooks()`, which the SDK uses to register its TypeScript loader hook synchronously in the main thread. No source change is required; ensure your environment runs Node.js 22.15.0+. diff --git a/packages/sdk/docs/multi-environment.md b/packages/sdk/docs/multi-environment.md index 51c9828bc4..ea88f382a7 100644 --- a/packages/sdk/docs/multi-environment.md +++ b/packages/sdk/docs/multi-environment.md @@ -11,16 +11,16 @@ Deployment commands resolve the target workspace from, in priority order, the `- For one-off commands, pass the workspace explicitly: ```bash -tailor-sdk deploy -w +tailor deploy -w ``` For environments you switch between regularly, create a named profile per environment with the [profile commands](./cli/workspace.md#profile-create) and select it with `--profile` (`-p`) or `TAILOR_PLATFORM_PROFILE`: ```bash -tailor-sdk profile create staging -u you@example.com -w -tailor-sdk profile create production -u you@example.com -w --permission read +tailor profile create staging -u you@example.com -w +tailor profile create production -u you@example.com -w --permission read -tailor-sdk deploy -p staging +tailor deploy -p staging ``` Profiles are created with `write` permission by default. The production profile above opts into `--permission read`, which blocks write commands such as `deploy` while the profile is active — a guard against deploying to production by accident. To deploy to production deliberately, pass the workspace explicitly with `-w` without selecting the profile — the guard applies only while a profile is selected via `-p` or `TAILOR_PLATFORM_PROFILE` — or use a separate profile created with `write` permission. @@ -32,8 +32,8 @@ export TAILOR_PLATFORM_URL= export TAILOR_PLATFORM_OAUTH2_CLIENT_ID= export TAILOR_PLATFORM_CONSOLE_URL= -tailor-sdk login -tailor-sdk profile create development \ +tailor login +tailor profile create development \ -u you@example.com \ -w \ --platform-url "$TAILOR_PLATFORM_URL" \ @@ -41,11 +41,11 @@ tailor-sdk profile create development \ --console-url "$TAILOR_PLATFORM_CONSOLE_URL" unset TAILOR_PLATFORM_URL TAILOR_PLATFORM_OAUTH2_CLIENT_ID TAILOR_PLATFORM_CONSOLE_URL -tailor-sdk deploy -p development -tailor-sdk open -p development +tailor deploy -p development +tailor open -p development ``` -After the profile exists, run `tailor-sdk login -p development` to refresh the login for that Platform without re-exporting the connection variables. +After the profile exists, run `tailor login -p development` to refresh the login for that Platform without re-exporting the connection variables. ## Varying config values per environment @@ -54,17 +54,17 @@ After the profile exists, run `tailor-sdk login -p development` to refresh the l ```ini # .env.production APP_ENV=production -LOG_LEVEL=WARN +TAILOR_APP_LOG_LEVEL=WARN ``` ```bash -tailor-sdk deploy -w --env-file .env.production +tailor deploy -w --env-file .env.production ``` ```typescript export default defineConfig({ name: "my-app", - logLevel: process.env.LOG_LEVEL ?? "DEBUG", + logLevel: process.env.TAILOR_APP_LOG_LEVEL ?? "DEBUG", }); ``` diff --git a/packages/sdk/docs/plugin/custom.md b/packages/sdk/docs/plugin/custom.md index 3b50de80fe..33e07c7cd2 100644 --- a/packages/sdk/docs/plugin/custom.md +++ b/packages/sdk/docs/plugin/custom.md @@ -18,7 +18,7 @@ const myPlugin: Plugin = { export default myPlugin; // Required: must be default export ``` -This is required so that generators can use plugin-generated TailorDB types via `getGeneratedType()`. +This is required so that other plugins and generation-time hooks can use plugin-generated TailorDB types via `getGeneratedType()`. ## Plugin Interface @@ -276,7 +276,7 @@ import type { ## getGeneratedType Helper -The SDK provides an async `getGeneratedType()` helper function to retrieve plugin-generated TailorDB types. This enables generators and other tools to work with types generated by plugins. +The SDK provides an async `getGeneratedType()` helper function to retrieve plugin-generated TailorDB types. This enables plugins and other tools to work with types generated by plugins. ```typescript import { join } from "node:path"; diff --git a/packages/sdk/docs/plugin/index.md b/packages/sdk/docs/plugin/index.md index e265c5ac96..dbb2012a57 100644 --- a/packages/sdk/docs/plugin/index.md +++ b/packages/sdk/docs/plugin/index.md @@ -6,7 +6,7 @@ Plugins extend TailorDB types by automatically generating additional types, exec ## Overview -When you run `tailor-sdk generate`, the SDK: +When you run `tailor generate`, the SDK: 1. Loads all TailorDB types with plugin attachments 2. Passes each type to the attached plugins @@ -102,18 +102,18 @@ Plugins can generate: - **Field Extensions**: Additional fields added to the source type - **Output Files**: TypeScript code and other files via generation-time hooks -Generated files are placed under `.tailor-sdk//` (the plugin ID is sanitized, +Generated files are placed under `.tailor//` (the plugin ID is sanitized, e.g. `@example/soft-delete` → `example-soft-delete`), such as: -- `.tailor-sdk/example-soft-delete/types` -- `.tailor-sdk/example-soft-delete/executors` +- `.tailor/example-soft-delete/types` +- `.tailor/example-soft-delete/executors` ## Plugin Lifecycle -Plugins have 5 hooks across two lifecycle phases. Each hook fires at a specific point in the `tailor-sdk generate` pipeline: +Plugins have 5 hooks across two lifecycle phases. Each hook fires at a specific point in the `tailor generate` pipeline: ``` -tailor-sdk generate +tailor generate │ ├─ Load TailorDB types │ ├─ onTypeLoaded ← per type with .plugin() attached @@ -149,7 +149,7 @@ These hooks produce TailorDB types, resolvers, and executors that become part of | `onResolverReady` | TailorDB types, Resolvers, Auth | Write output files | | `onExecutorReady` | TailorDB types, Resolvers, Executors, Auth | Write output files | -These hooks receive all finalized data and produce output files (TypeScript code, etc.). They replace the previous standalone `defineGenerators()` approach. No `importPath` required. +These hooks receive all finalized data and produce output files (TypeScript code, etc.). No `importPath` required. A plugin can implement hooks from either or both phases. diff --git a/packages/sdk/docs/quickstart.md b/packages/sdk/docs/quickstart.md index b3071e772f..5bd5bb831e 100644 --- a/packages/sdk/docs/quickstart.md +++ b/packages/sdk/docs/quickstart.md @@ -12,7 +12,7 @@ Contact us [here](https://www.tailor.tech/demo) to get started. ### Install Node.js -The SDK requires Node.js 22 or later. Install Node.js via your package manager by following the official Node.js instructions. +The SDK requires Node.js 22.15.0 or later. Install Node.js via your package manager by following the official Node.js instructions. Alternatively, you can use [Bun](https://bun.sh/) as the runtime. @@ -24,20 +24,22 @@ The following command creates a new project with the required configuration file ```bash npm create @tailor-platform/sdk -- --template hello-world example-app +cd example-app # Or with Bun: # bun create @tailor-platform/sdk --template hello-world example-app +# cd example-app ``` Before deploying your app, you need to create a workspace: ```bash -npx tailor-sdk login -npx tailor-sdk workspace create --name --region -npx tailor-sdk workspace list +npx tailor login +npx tailor workspace create --name --region +npx tailor workspace list # Or with Bun: -# bunx tailor-sdk login -# bunx tailor-sdk workspace create --name --region +# bunx tailor login +# bunx tailor workspace create --name --region # OR # Create a new workspace using Tailor Platform Console @@ -49,7 +51,6 @@ npx tailor-sdk workspace list Run the deploy command to deploy your project: ```bash -cd example-app npm run deploy -- --workspace-id # Or with Bun: # bun run deploy --workspace-id diff --git a/packages/sdk/docs/runtime.md b/packages/sdk/docs/runtime.md index a9086f4091..68cc2ee732 100644 --- a/packages/sdk/docs/runtime.md +++ b/packages/sdk/docs/runtime.md @@ -53,9 +53,7 @@ import type { ListUsersResponse, ClientConfig } from "@tailor-platform/sdk/runti Most users do not need to touch the globals entry — `@tailor-platform/sdk/runtime` (and its subpath modules) cover the same surface without depending on any ambient declaration. -For backwards compatibility with the previous `@tailor-platform/function-types`-based setup, the SDK still activates the ambient `tailor.*` / `tailordb.*` types automatically when you import from `@tailor-platform/sdk`. **This implicit activation will be removed in v2.0**; new code should prefer the typed wrappers from `@tailor-platform/sdk/runtime`. - -If you want to opt into the globals explicitly (or you are migrating ahead of v2.0), add a single side-effect import anywhere in your project: +Importing from `@tailor-platform/sdk` does not activate the ambient `tailor.*` / `tailordb.*` declarations. If you want to opt into the globals, add a single side-effect import anywhere in your project: ```ts import "@tailor-platform/sdk/runtime/globals"; @@ -71,6 +69,8 @@ Or register the entry in `tsconfig.json`: } ``` +The globals entry exposes the lowercase `tailordb.*` namespace only. If your project still references the removed capital-cased `Tailordb.*` namespace from `@tailor-platform/function-types`, migrate before upgrading in two steps: run `pnpm dlx @tailor-platform/sdk-codemod v2/tailordb-namespace` to rewrite `Tailordb.*` references to lowercase `tailordb.*`, then add the `import "@tailor-platform/sdk/runtime/globals"` opt-in above so the rewritten references resolve. + ## Namespaces The runtime entry re-exports the following namespaces. Detailed signatures, parameters, and return types live in the JSDoc next to each export — hover the symbol in your IDE or browse the source. @@ -81,7 +81,7 @@ The runtime entry re-exports the following namespaces. Detailed signatures, para - `idp` — IdP user management (`new Client({ namespace })`) - `workflow` — workflow & job control (`triggerWorkflow`, `resumeWorkflow`, `triggerJobFunction`, `wait`, `resolve`) - `context` — execution context (`getInvoker`) -- `file` — `tailordb.file` BLOB API (`upload`, `download`, `downloadAsBase64`, `delete`, `getMetadata`, `downloadStream`, `uploadStream`, `openDownloadStream` _(deprecated)_) +- `file` — `tailordb.file` BLOB API (`upload`, `download`, `downloadAsBase64`, `delete`, `getMetadata`, `downloadStream`, `uploadStream`) - `aigateway` — AI Gateway URL resolution (`get`) ## Testing diff --git a/packages/sdk/docs/services/aigateway.md b/packages/sdk/docs/services/aigateway.md index 46608d6587..7f740c2181 100644 --- a/packages/sdk/docs/services/aigateway.md +++ b/packages/sdk/docs/services/aigateway.md @@ -110,4 +110,4 @@ const { url } = await aigateway.get("my-aigateway"); // await aigateway.get("unknown"); // Type error — only "my-aigateway" is allowed ``` -Type narrowing is provided by the generated `tailor.d.ts` (the `AIGatewayNameRegistry` interface). Run `tailor-sdk generate` (or `deploy`) after defining new AI Gateways to refresh it. Before the first generate run, `get()` accepts any string. +Type narrowing is provided by the generated `tailor.d.ts` (the `AIGatewayNameRegistry` interface). Run `tailor generate` (or `deploy`) after defining new AI Gateways to refresh it. Before the first generate run, `get()` accepts any string. diff --git a/packages/sdk/docs/services/auth.md b/packages/sdk/docs/services/auth.md index 8f7443279e..32be2584d6 100644 --- a/packages/sdk/docs/services/auth.md +++ b/packages/sdk/docs/services/auth.md @@ -139,18 +139,18 @@ export const user = db.type("User", { }); ``` -The `attributeList` values are accessible via `user.attributeList` as a tuple: +The `attributeList` values are accessible via the runtime principal's `attributeList` as a tuple: ```typescript // In a resolver body: (context) => { - const [organizationId, teamId] = context.user.attributeList; + const [organizationId, teamId] = context.caller?.attributeList ?? []; }, // In TailorDB hooks .hooks({ field: { - create: ({ user }) => user.attributeList[0], // First UUID from list + create: ({ invoker }) => invoker?.attributeList[0] ?? null, // First UUID from list }, }) ``` @@ -160,7 +160,7 @@ body: (context) => { When you want to use machine users without defining a `userProfile`, define `machineUserAttributes` instead. These attributes are used for: - type-safe `machineUsers[*].attributes` -- `context.user.attributes` typing (via `tailor.d.ts`) +- runtime principal `attributes` typing (via `tailor.d.ts`) ```typescript import { defineAuth, t } from "@tailor-platform/sdk"; @@ -182,7 +182,7 @@ export const auth = defineAuth("my-auth", { To update types in `tailor.d.ts`, run: ```bash -tailor-sdk generate +tailor generate ``` ## Machine Users @@ -200,12 +200,12 @@ machineUsers: { }, ``` -**attributes**: Values for attributes enabled in `userProfile.attributes` (or all fields defined in `machineUserAttributes` when `userProfile` is omitted). All enabled fields must be set here. These values are accessible via `user.attributes`: +**attributes**: Values for attributes enabled in `userProfile.attributes` (or all fields defined in `machineUserAttributes` when `userProfile` is omitted). All enabled fields must be set here. These values are accessible via the runtime principal's `attributes`: ```typescript // In a resolver body: (context) => { - const role = context.user.attributes?.role; + const role = context.caller?.attributes.role; }, ``` @@ -230,25 +230,25 @@ machineUsers: { }, ``` -These values are accessible via `user.attributeList`: +These values are accessible via the runtime principal's `attributeList`: ```typescript // In a resolver body: (context) => { - const [organizationId, teamId] = context.user.attributeList; + const [organizationId, teamId] = context.caller?.attributeList ?? []; }, // In TailorDB hooks .hooks({ field: { - create: ({ user }) => user.attributes?.role === "ADMIN" ? "default" : null, + create: ({ invoker }) => invoker?.attributes.role === "ADMIN" ? "default" : null, }, }) // In TailorDB validate .validate({ field: [ - ({ user }) => user.attributes?.role === "ADMIN", + ({ invoker }) => invoker?.attributes.role === "ADMIN", "Only admins can set this field", ], }) @@ -264,12 +264,12 @@ Machine users are useful for: Get a machine user token using the CLI: ```bash -tailor-sdk machineuser token +tailor machineuser token ``` ### Specifying a machine user invoker -Resolvers, executors, and `workflow.trigger()` accept an `authInvoker` option that chooses which machine user runs the operation. Pass the machine user name as a plain string — it is type-narrowed to the names you registered in `machineUsers`. +Resolvers, executors, and `workflow.trigger()` accept an `invoker` option that chooses which machine user runs the operation. Pass the machine user name as a plain string — it is type-narrowed to the names you registered in `machineUsers`. ```typescript // tailor.config.ts @@ -298,7 +298,7 @@ export default createResolver({ // Trigger workflow with machine user permissions const workflowRunId = await myWorkflow.trigger( { id: input.id }, - { authInvoker: "admin-machine-user" }, + { invoker: "admin-machine-user" }, ); return { workflowRunId }; }, @@ -308,9 +308,7 @@ export default createResolver({ }); ``` -Type narrowing is provided by the generated `tailor.d.ts` (the `MachineUserNameRegistry` interface). Run `tailor-sdk generate` (or `deploy`) after defining new machine users to refresh it. - -> **Deprecated:** The `auth.invoker("")` helper is still available for backward compatibility. Prefer the string form — it does not require importing `auth` from `tailor.config.ts` into runtime files, avoiding bundling config-layer (Node-only) dependencies. +Type narrowing is provided by the generated `tailor.d.ts` (the `MachineUserNameRegistry` interface). Run `tailor generate` (or `deploy`) after defining new machine users to refresh it. ## OAuth 2.0 Clients @@ -350,7 +348,7 @@ oauth2Clients: { Get OAuth2 client credentials using the CLI: ```bash -tailor-sdk oauth2client get +tailor oauth2client get ``` ## Identity Provider @@ -373,9 +371,9 @@ For the official Tailor Platform documentation, see [AuthConnection Guide](https > Deploy updates connections **in-place**, preserving the OAuth token. If the connection requires re-authorization after an update, the deploy will warn you: > > ```bash -> tailor-sdk authconnection authorize --name +> tailor authconnection authorize --name > # Or via the Console: -> tailor-sdk authconnection open +> tailor authconnection open > ``` ### Setup Flow @@ -408,10 +406,10 @@ export const auth = defineAuth("my-auth", { }); ``` -After `tailor-sdk deploy`, authorize the connection: +After `tailor deploy`, authorize the connection: ```bash -tailor-sdk authconnection authorize --name google-connection \ +tailor authconnection authorize --name google-connection \ --scopes "openid,profile,email" ``` @@ -445,9 +443,9 @@ const response = await fetch("https://www.googleapis.com/...", { // authconnection.getConnectionToken("unknown"); // Type error — only "google-connection" is allowed ``` -Type narrowing is provided by the generated `tailor.d.ts` (the `ConnectionNameRegistry` interface). Run `tailor-sdk generate` (or `deploy`) after defining new connections to refresh it. Before the first generate, or when `connections` is not defined in `defineAuth()`, `getConnectionToken()` accepts any string — this also supports connections managed entirely via the CLI. +Type narrowing is provided by the generated `tailor.d.ts` (the `ConnectionNameRegistry` interface). Run `tailor generate` (or `deploy`) after defining new connections to refresh it. Before the first generate, or when `connections` is not defined in `defineAuth()`, `getConnectionToken()` accepts any string — this also supports connections managed entirely via the CLI. -> **Deprecated:** `auth.getConnectionToken("")` still works, but is deprecated. Importing `auth` from `tailor.config.ts` into runtime files pulls config-layer (Node-only) dependencies into the bundle. +This keeps runtime files independent from `tailor.config.ts`. See [Built-in Interfaces](https://docs.tailor.tech/guides/function/builtin-interfaces.html#auth-connection) for the full runtime API. @@ -457,19 +455,19 @@ Auth connections can also be managed via the CLI: ```bash # Open the connections page in the Console (recommended for creating connections/tokens) -tailor-sdk authconnection open +tailor authconnection open # Authorize (opens browser for OAuth2 flow) -tailor-sdk authconnection authorize --name google-connection +tailor authconnection authorize --name google-connection # List all connections -tailor-sdk authconnection list +tailor authconnection list # Revoke a connection -tailor-sdk authconnection revoke --name google-connection +tailor authconnection revoke --name google-connection ``` -Connection creation is handled by `tailor-sdk deploy` via the config, but recreation on deploy can drop the authorized token (see the warning at the top of this section) — for shared and CI workflows, create connections and tokens from the Console (`tailor-sdk authconnection open`) instead. +Connection creation and updates are handled by `tailor deploy` via the config. Deploy updates connections in-place, preserving the authorized token, and warns you if an update requires re-authorization (see the note above). See [Auth Resource Commands](../cli/auth.md) for full CLI documentation. @@ -532,21 +530,21 @@ Manage Auth resources using the CLI: ```bash # Auth connections -tailor-sdk authconnection authorize --name -tailor-sdk authconnection list -tailor-sdk authconnection revoke --name +tailor authconnection authorize --name +tailor authconnection list +tailor authconnection revoke --name # List machine users -tailor-sdk machineuser list +tailor machineuser list # Get machine user token -tailor-sdk machineuser token +tailor machineuser token # List OAuth2 clients -tailor-sdk oauth2client list +tailor oauth2client list # Get OAuth2 client credentials -tailor-sdk oauth2client get +tailor oauth2client get ``` See [Auth Resource Commands](../cli/auth.md) for full documentation. diff --git a/packages/sdk/docs/services/executor.md b/packages/sdk/docs/services/executor.md index 11ef602bf7..954b588c91 100644 --- a/packages/sdk/docs/services/executor.md +++ b/packages/sdk/docs/services/executor.md @@ -219,7 +219,7 @@ createExecutor({ }); ``` -Executor callbacks receive the trigger args, including `env` from `defineConfig({ env })`. `function` and `jobFunction` `body` args also include an `invoker` field: the principal running this function, overridden by `authInvoker` when set; `null` for anonymous calls. Other operation kinds (`graphql`, `webhook`, `workflow`) receive `env` through their callback args but do not pass `invoker` into those callbacks. +Executor callbacks receive the trigger args, including `env` from `defineConfig({ env })`. `function` and `jobFunction` `body` args also include an `invoker` field: the principal running this function, or the machine user configured through the operation `invoker` option; `null` for anonymous calls. Other operation kinds (`graphql`, `webhook`, `workflow`) receive `env` through their callback args but do not pass `invoker` into those callbacks. ### Job Function Operation @@ -331,7 +331,7 @@ createExecutor({ ### Authentication for Operations -GraphQL and Workflow operations can specify an `authInvoker` to execute with machine user credentials. Pass the machine user name as a plain string — it is type-narrowed to the names defined in your auth config: +GraphQL and Workflow operations can specify an `invoker` to execute with machine user credentials. Pass the machine user name as a plain string — it is type-narrowed to the names defined in your auth config: ```typescript import { createExecutor, scheduleTrigger } from "@tailor-platform/sdk"; @@ -342,13 +342,11 @@ export default createExecutor({ operation: { kind: "graphql", query: `mutation { cleanupOldRecords { count } }`, - authInvoker: "batch-processor", + invoker: "batch-processor", }, }); ``` -> **Deprecated:** `auth.invoker("batch-processor")` still works, but is deprecated. Prefer the string form to avoid importing config-layer modules into runtime files. - ## Event Payloads Each trigger type provides specific context data in the callback functions. diff --git a/packages/sdk/docs/services/resolver.md b/packages/sdk/docs/services/resolver.md index cdc6ac61fa..51d2a6f117 100644 --- a/packages/sdk/docs/services/resolver.md +++ b/packages/sdk/docs/services/resolver.md @@ -208,7 +208,7 @@ Validation functions receive: - `value` - The field value being validated - `data` - The entire input object -- `user` - The user performing the operation +- `invoker` - The principal performing the operation You can specify validation as: @@ -234,13 +234,13 @@ Validation runs automatically before the `body` function executes. When validati Define actual resolver logic in the `body` function. Function arguments include: - `input` - Input data from GraphQL request -- `user` - The user who called this resolver; unaffected by `authInvoker` -- `invoker` - The principal running this function; equals `user` by default, or the machine user set by `authInvoker`. `null` for anonymous calls. +- `caller` - The user or machine user who called this resolver; unaffected by `invoker`. `null` for anonymous calls. +- `invoker` - The principal running this function; equals `caller` by default, or the machine user configured through the resolver `invoker` option. `null` for anonymous calls. - `env` - Environment variables declared in `tailor.config.ts` ### Using Kysely for Database Access -If you're generating Kysely types with a generator, you can use `getDB` to execute typed queries: +If you're generating Kysely types with `kyselyTypePlugin`, you can use `getDB` to execute typed queries: ```typescript import { getDB } from "../generated/tailordb"; @@ -306,7 +306,7 @@ createResolver({ - When `publishEvents: true`, resolver execution events are published - When not specified, it is **automatically set to `true`** if an executor uses this resolver with `resolverExecutedTrigger` -- When explicitly set to `false` while an executor uses this resolver, an error is thrown during `tailor apply` +- When explicitly set to `false` while an executor uses this resolver, an error is thrown during `tailor deploy` **Use cases:** @@ -352,7 +352,7 @@ createResolver({ ## Authentication -Specify an `authInvoker` to execute the resolver with machine user credentials. Pass the machine user name as a plain string — it is type-narrowed to the names you defined in your auth config: +Specify an `invoker` to execute the resolver with machine user credentials. Pass the machine user name as a plain string — it is type-narrowed to the names you defined in your auth config: ```typescript import { createResolver, t } from "@tailor-platform/sdk"; @@ -365,12 +365,10 @@ export default createResolver({ // Executes as "batch-processor" machine user return { result: "ok" }; }, - authInvoker: "batch-processor", + invoker: "batch-processor", }); ``` The machine user name is looked up in the auth service configured on your app (`machineUsers` in `defineAuth`). The namespace is resolved automatically — no need to import `auth` from `tailor.config.ts` in resolver files. -> **Deprecated:** `auth.invoker("batch-processor")` still works, but is deprecated. Importing `auth` into runtime files pulls config-layer (Node-only) dependencies into the bundle. - -**Note:** `authInvoker` controls the permissions for database operations and other platform actions. The `user` object passed to `body` still reflects the original caller, while `invoker` reflects the principal actually running the body. +**Note:** The `invoker` option controls the permissions for database operations and other platform actions. The `caller` object passed to `body` still reflects the original caller, while the `invoker` body field reflects the principal actually running the body. diff --git a/packages/sdk/docs/services/secret.md b/packages/sdk/docs/services/secret.md index 374cb7dd42..6cc7543fb1 100644 --- a/packages/sdk/docs/services/secret.md +++ b/packages/sdk/docs/services/secret.md @@ -34,11 +34,11 @@ Secrets are key-value pairs stored within a vault. Secret values are encrypted a ## Managing Secrets -There are two ways to manage secrets: declaratively via `defineSecretManager()` in `tailor.config.ts`, or imperatively via the [CLI](#cli-management). Management is scoped per vault — **do not mix both approaches for the same vault**. When a vault is defined in config, the config becomes the source of truth: any secrets in that vault not present in the config will be deleted on `tailor-sdk deploy`. +There are two ways to manage secrets: declaratively via `defineSecretManager()` in `tailor.config.ts`, or imperatively via the [CLI](#cli-management). Management is scoped per vault — **do not mix both approaches for the same vault**. When a vault is defined in config, the config becomes the source of truth: any secrets in that vault not present in the config will be deleted on `tailor deploy`. ### Declarative Configuration -Define your secrets in `tailor.config.ts` using `defineSecretManager()`. Each key is a vault name, and its value is a record of secret names to their values. These values are deployed to each vault on `tailor-sdk deploy`. +Define your secrets in `tailor.config.ts` using `defineSecretManager()`. Each key is a vault name, and its value is a record of secret names to their values. These values are deployed to each vault on `tailor deploy`. Since secret values should not be committed to source control, use environment variables: @@ -87,7 +87,7 @@ When `ignoreNullishValues: true`: - Skipped secrets are shown in the deploy output for visibility - Secrets removed from the config entirely are still deleted (orphan cleanup) -This allows you to set secret values once (e.g., via local `tailor-sdk deploy` or the CLI) and then deploy from CI without needing the actual values in CI environment variables. +This allows you to set secret values once (e.g., via local `tailor deploy` or the CLI) and then deploy from CI without needing the actual values in CI environment variables. ## Using Secrets @@ -175,25 +175,25 @@ At runtime, these references are replaced with the actual secret values. Use the CLI to manage vaults that are **not** defined in `defineSecretManager()`. If you attempt to modify a vault that is managed by the config, the CLI will show a warning and ask for confirmation. Once confirmed, the CLI releases the vault's ownership label so it is no longer managed by config. -After ownership is released, the next `tailor-sdk deploy` will treat the vault as an unmanaged resource and prompt for confirmation before taking any action on it. +After ownership is released, the next `tailor deploy` will treat the vault as an unmanaged resource and prompt for confirmation before taking any action on it. ### Create a Vault ```bash -tailor-sdk secret vault create --name api-keys +tailor secret vault create --name api-keys ``` ### Add Secrets ```bash # Create a secret -tailor-sdk secret create \ +tailor secret create \ --vault-name api-keys \ --name stripe-secret-key \ --value sk_live_xxxxx # Update a secret -tailor-sdk secret update \ +tailor secret update \ --vault-name api-keys \ --name stripe-secret-key \ --value sk_live_yyyyy @@ -203,20 +203,20 @@ tailor-sdk secret update \ ```bash # List vaults -tailor-sdk secret vault list +tailor secret vault list # List secrets in a vault (values are hidden) -tailor-sdk secret list --vault-name api-keys +tailor secret list --vault-name api-keys ``` ### Delete Secrets ```bash # Delete a secret -tailor-sdk secret delete --vault-name api-keys --name old-key --yes +tailor secret delete --vault-name api-keys --name old-key --yes # Delete a vault (must be empty) -tailor-sdk secret vault delete --name old-vault --yes +tailor secret vault delete --name old-vault --yes ``` See [Secret CLI Commands](../cli/secret.md) for full documentation. diff --git a/packages/sdk/docs/services/staticwebsite.md b/packages/sdk/docs/services/staticwebsite.md index 70143c61a1..6a7c6dcc03 100644 --- a/packages/sdk/docs/services/staticwebsite.md +++ b/packages/sdk/docs/services/staticwebsite.md @@ -72,7 +72,7 @@ defineStaticWebSite("my-website", { }); ``` -After deploying, use `tailor-sdk staticwebsite domain get ` to check domain status and retrieve the CNAME targets required for DNS configuration. +After deploying, use `tailor staticwebsite domain get ` to check domain status and retrieve the CNAME targets required for DNS configuration. A domain can be associated with only one workspace at a time. To set custom domains only in the workspace that owns the domain, see [Multi-Environment Configuration](../multi-environment.md#settings-that-belong-to-a-single-environment). diff --git a/packages/sdk/docs/services/tailordb-migration.md b/packages/sdk/docs/services/tailordb-migration.md index 85fc8adc4b..88fd4b0805 100644 --- a/packages/sdk/docs/services/tailordb-migration.md +++ b/packages/sdk/docs/services/tailordb-migration.md @@ -12,7 +12,7 @@ For the CLI command reference, see [`tailordb migration`](../cli/tailordb.md#tai - **Local snapshot–based diff detection** — each migration is generated by diffing your current type definitions against the previous snapshot stored in `migrations//`. - **Transaction-wrapped data migrations** — each `migrate.ts` script runs inside a database transaction on the platform; if the script throws, all changes in that migration roll back. -- **Automatic execution during `apply`** — `tailor-sdk deploy` detects pending migrations, runs the two-stage type update (pre-migration → script → post-migration), and updates the migration checkpoint label. +- **Automatic execution during `apply`** — `tailor deploy` detects pending migrations, runs the two-stage type update (pre-migration → script → post-migration), and updates the migration checkpoint label. - **Type-safe scripts** — the generated `db.ts` provides Kysely types that reflect the schema state **before** the migration runs, so transformations are written against the actual data shape. **Files in `migrations/`** @@ -42,24 +42,24 @@ When you start with no `migrations/` directory: 2. Define your initial types in `tailordb/`. 3. Generate the initial migration: ```bash - tailor-sdk tailordb migration generate + tailor tailordb migration generate ``` This creates `migrations/0000/schema.json` from your current types. -4. Run `tailor-sdk deploy`. The migration label is set to `0000` on the deployed namespace. +4. Run `tailor deploy`. The migration label is set to `0000` on the deployed namespace. ### Adding migrations to an existing project If you already have a deployed workspace whose schema matches your local type definitions: 1. Add the `migration` block to `tailor.config.ts`. -2. Run `tailor-sdk tailordb migration generate` to create `0000/schema.json` from current local types. -3. Run `tailor-sdk deploy`. Because remote schema already matches, no script runs; only the migration label is set. +2. Run `tailor tailordb migration generate` to create `0000/schema.json` from current local types. +3. Run `tailor deploy`. Because remote schema already matches, no script runs; only the migration label is set. If your local types and remote schema have **diverged**, reconcile them before introducing migrations — either update local types to match remote, or accept that the first non-`0000` migration will reflect that gap. ### Resetting -`tailor-sdk tailordb migration generate --init` deletes the existing `migrations/` directory and starts over from `0000`. Use this only on projects that are not yet deployed, or when you have decided to re-baseline (the next `apply` will see all migrations as new and require coordination — see [Resetting a deployed project](#resetting-a-deployed-project)). +`tailor tailordb migration generate --init` deletes the existing `migrations/` directory and starts over from `0000`. Use this only on projects that are not yet deployed, or when you have decided to re-baseline (the next `apply` will see all migrations as new and require coordination — see [Resetting a deployed project](#resetting-a-deployed-project)). ## Migration Workflow @@ -79,7 +79,7 @@ A typical change cycle: 2. **Generate the migration.** ```bash - tailor-sdk tailordb migration generate --name "add email to user" + tailor tailordb migration generate --name "add email to user" ``` Output: @@ -109,7 +109,7 @@ A typical change cycle: 4. **Apply.** ```bash - tailor-sdk deploy + tailor deploy ``` The pre-migration phase relaxes the new field to optional, the script runs and populates values, then the post-migration phase enforces `required: true`. @@ -126,10 +126,10 @@ Warning: data loss possible: No `migrate.ts` is generated automatically because the schema change itself is non-breaking, but the existing data is dropped during the post-migration phase. If you need to preserve or transform that data first (for example, copy a column into another table before it disappears), add a script with: ```bash -tailor-sdk tailordb migration script 0002 +tailor tailordb migration script 0002 ``` -This writes `migrations/0002/migrate.ts` and `migrations/0002/db.ts` next to the existing `diff.json`. The removed field stays readable inside `migrate.ts` because the pre-migration phase keeps it on the type until the script finishes (see [Per-migration phases](#per-migration-phases)). The next `tailor-sdk deploy` runs the script automatically — `migrate.ts` is executed whenever the file exists on disk, regardless of whether the diff itself required it. +This writes `migrations/0002/migrate.ts` and `migrations/0002/db.ts` next to the existing `diff.json`. The removed field stays readable inside `migrate.ts` because the pre-migration phase keeps it on the type until the script finishes (see [Per-migration phases](#per-migration-phases)). The next `tailor deploy` runs the script automatically — `migrate.ts` is executed whenever the file exists on disk, regardless of whether the diff itself required it. ## Configuration @@ -245,7 +245,7 @@ The same pattern works for switching between scalar and array. ## Automatic Migration Execution -When you run `tailor-sdk deploy`, the SDK detects pending migrations (anything past the current `sdk-migration` label on the deployed namespace) and runs them in order before continuing with the rest of the apply. +When you run `tailor deploy`, the SDK detects pending migrations (anything past the current `sdk-migration` label on the deployed namespace) and runs them in order before continuing with the rest of the apply. ### Per-migration phases @@ -280,7 +280,7 @@ The error also points you at `migration status`, `migration generate`, `migratio To bypass both checks (not recommended outside of recovery scenarios): ```bash -tailor-sdk deploy --no-schema-check +tailor deploy --no-schema-check ``` ### Example output @@ -299,7 +299,7 @@ tailor-sdk deploy --no-schema-check ## `migration set` Semantics -`tailor-sdk tailordb migration set ` updates the `sdk-migration` label on the deployed namespace's metadata. **It does not modify any data or schema.** It only changes which migrations the next `apply` will consider pending. +`tailor tailordb migration set ` updates the `sdk-migration` label on the deployed namespace's metadata. **It does not modify any data or schema.** It only changes which migrations the next `apply` will consider pending. | Movement | Effect on next `apply` | Effect on data | | -------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------- | @@ -316,7 +316,7 @@ Use cases: ## `migration sync` Semantics -`tailor-sdk tailordb migration sync ` reconstructs the schema snapshot at migration `N` from the working tree's migration history and **overwrites the remote schema to match it**, then sets the `sdk-migration` label to `N`. Unlike `migration set`, it changes the remote schema as well as the bookkeeping. Like `set`, it never runs `migrate.ts` scripts itself — it only changes what the next `apply` considers pending: +`tailor tailordb migration sync ` reconstructs the schema snapshot at migration `N` from the working tree's migration history and **overwrites the remote schema to match it**, then sets the `sdk-migration` label to `N`. Unlike `migration set`, it changes the remote schema as well as the bookkeeping. Like `set`, it never runs `migrate.ts` scripts itself — it only changes what the next `apply` considers pending: | Movement | Effect on next `apply` | Effect on data | | -------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | @@ -327,7 +327,7 @@ Before anything is sent to the remote, `sync` verifies that replaying the full m Because syncing backward causes already-applied scripts to re-execute on the next deploy, **write `migrate.ts` scripts to be idempotent** (see [Performance and Large Tables](#performance-and-large-tables) for resumable `where` clauses). -The main use case is recovering from drift after a `deploy --no-schema-check` from an older revision: instead of checking out that revision, run `migration sync ` to restore the remote to a known snapshot, then `tailor-sdk deploy` to apply the remaining migrations from the working tree. +The main use case is recovering from drift after a `deploy --no-schema-check` from an older revision: instead of checking out that revision, run `migration sync ` to restore the remote to a known snapshot, then `tailor deploy` to apply the remaining migrations from the working tree. ## Team Workflow and CI/CD @@ -342,7 +342,7 @@ Migration numbers are assigned sequentially, so two developers branching off the ### CI / CD - For non-interactive environments, pass `--yes` to `migration generate` and `--yes` to `apply`. `apply` runs migrations automatically when the `migrations/` directory is configured. -- Run `tailor-sdk tailordb migration status` in CI to detect "developer forgot to commit a migration" situations early. The exit code is non-zero only on errors, so check the output. +- Run `tailor tailordb migration status` in CI to detect "developer forgot to commit a migration" situations early. The exit code is non-zero only on errors, so check the output. - Avoid running migrations in parallel against the same workspace — there is no locking. Serialize deploys per environment. ### Resetting a deployed project @@ -350,8 +350,8 @@ Migration numbers are assigned sequentially, so two developers branching off the `migration generate --init` is destructive locally but does not touch the deployed workspace. Re-baselining a deployed project requires: 1. Run `migration generate --init` to start over from `0000`. -2. Run `tailor-sdk tailordb migration set 0` against the deployed namespace. -3. Run `tailor-sdk deploy` — the new `0000` becomes the baseline. +2. Run `tailor tailordb migration set 0` against the deployed namespace. +3. Run `tailor deploy` — the new `0000` becomes the baseline. Coordinate this with your team because everyone else's local migrations will be invalidated. @@ -369,7 +369,7 @@ After a failure: 1. Read the `Logs:` block in the apply output to find the cause. 2. Fix `migrate.ts` (or the data it depends on). -3. Re-run `tailor-sdk deploy`. The same migration runs again because its label was never bumped, and the prior-checkpoint schema is a clean baseline to retry against. +3. Re-run `tailor deploy`. The same migration runs again because its label was never bumped, and the prior-checkpoint schema is a clean baseline to retry against. If a migration **succeeds in script** but the **post-migration phase** fails (rare; usually a constraint violation the script should have prevented), the pre-migration changes are **not** rolled back: the script's data changes are already committed and the post-migration phase may have dropped removed columns or types, which cannot be reverted without data loss. Investigate, fix, and re-run. @@ -398,7 +398,7 @@ The machine user needs read/write access to every type the migration script touc If you see `No machine user available for migration execution`, either: -- Add `machineUsers: { ... }` to your auth config and `tailor-sdk deploy` it, or +- Add `machineUsers: { ... }` to your auth config and `tailor deploy` it, or - Set `migration.machineUser` to an existing machine user name in the db config. ## Multi-Namespace Coordination @@ -441,7 +441,7 @@ For genuinely different schemas across environments, prefer separate workspaces **Resolution:** -1. `tailor-sdk tailordb migration status` to see local vs remote. +1. `tailor tailordb migration status` to see local vs remote. 2. Compare with teammates — has someone applied different migrations? 3. If remote was changed manually, decide whether to update local migrations to match or to use `migration set ` to align bookkeeping. 4. To force the remote schema back to a known snapshot, use `migration sync ` (see [`migration sync` Semantics](#migration-sync-semantics)). @@ -473,7 +473,7 @@ For genuinely different schemas across environments, prefer separate workspaces **Cause:** Runtime error in your `migrate.ts`, a permission error from the machine user, or a constraint violation when post-migration tightens types. -**Resolution:** Read the `Logs:` block. Fix the script or the data assumption it relies on, and re-run `tailor-sdk deploy`. The label is not bumped on failure, so the same migration retries. +**Resolution:** Read the `Logs:` block. Fix the script or the data assumption it relies on, and re-run `tailor deploy`. The label is not bumped on failure, so the same migration retries. ### `migrate.ts` not found for a migration that needs one diff --git a/packages/sdk/docs/services/tailordb.md b/packages/sdk/docs/services/tailordb.md index 9ef67b394d..0bae371e7e 100644 --- a/packages/sdk/docs/services/tailordb.md +++ b/packages/sdk/docs/services/tailordb.md @@ -279,28 +279,36 @@ field, files entry, or relation on the same type. ### Hooks -Add hooks to execute functions during data creation or update. Hooks receive three arguments: - -- `value`: User input if provided, otherwise existing value on update or null on create -- `data`: Entire record data (for accessing other field values) -- `user`: User performing the operation +Add hooks to execute functions during data creation or update. #### Field-level Hooks -Set hooks directly on individual fields: +Set hooks directly on individual fields. Each hook receives: + +- `value`: The field value from the input (null on create when not provided) +- `oldValue`: The previous field value (null on create) +- `invoker`: Principal performing the operation +- `now`: Operation timestamp (`Date`), shared across all hooks in the same operation ```typescript db.string().hooks({ - create: ({ user }) => user.id, + create: ({ invoker }) => invoker?.id ?? "", update: ({ value }) => value, }); ``` -**Note:** When setting hooks at the field level, the `data` argument type is `unknown` since the field doesn't know about other fields in the type. Use type-level hooks if you need to access other fields with type safety. +Field-level hooks operate on a single field and cannot access other fields. Use type-level hooks for cross-field logic. #### Type-level Hooks -Set hooks for multiple fields at once using `db.type().hooks()`: +Set hooks across multiple fields using `db.type().hooks()`. Each hook receives: + +- `input`: The submitted record data (pre-hook values) +- `oldRecord`: The existing record (null on create) +- `invoker`: Principal performing the operation +- `now`: Operation timestamp (`Date`), shared across all hooks in the same operation + +The hook returns an object with the fields to override: ```typescript export const customer = db @@ -310,60 +318,47 @@ export const customer = db fullName: db.string(), }) .hooks({ - fullName: { - create: ({ data }) => `${data.firstName} ${data.lastName}`, - update: ({ data }) => `${data.firstName} ${data.lastName}`, - }, + create: ({ input }) => ({ + fullName: `${input.firstName} ${input.lastName}`, + }), + update: ({ input }) => ({ + fullName: `${input.firstName} ${input.lastName}`, + }), }); ``` -**Important:** Field-level and type-level hooks cannot coexist on the same field. TypeScript will prevent this at compile time: +Use `now` to stamp several fields with the exact same instant: ```typescript -// Compile error - cannot set hooks on the same field twice -export const user = db - .type("User", { - name: db.string().hooks({ create: ({ data }) => data.firstName }), // Field-level - }) - .hooks({ - name: { create: ({ data }) => data.lastName }, // Type-level - ERROR - }); - -// OK - set hooks on different fields -export const user = db - .type("User", { - firstName: db.string().hooks({ create: () => "John" }), // Field-level on firstName - lastName: db.string(), +export const order = db + .type("Order", { + createdAt: db.datetime(), + updatedAt: db.datetime(), }) .hooks({ - lastName: { create: () => "Doe" }, // Type-level on lastName + create: ({ now }) => ({ createdAt: now, updatedAt: now }), + update: ({ now }) => ({ updatedAt: now }), }); ``` ### Validation -Add validation rules to fields. Validators receive three arguments (executed after hooks): - -- `value`: Field value after hook transformation -- `data`: Entire record data after hook transformations (for accessing other field values) -- `user`: User performing the operation - -Validators return `true` for success, `false` for failure. Use array form `[validator, errorMessage]` for custom error messages. +Add validation rules to fields. Validators run after hooks. #### Field-level Validation -Set validators directly on individual fields: +Set validators directly on individual fields. Each validator receives `{ value }` (the field value after hooks) and returns an error message string to fail, or void to pass: ```typescript db.string().validate( - ({ value }) => value.includes("@"), - [({ value }) => value.length >= 5, "Email must be at least 5 characters"], + ({ value }) => (value.includes("@") ? undefined : "Must contain @"), + ({ value }) => (value.length >= 5 ? undefined : "Must be at least 5 characters"), ); ``` #### Type-level Validation -Set validators for multiple fields at once using `db.type().validate()`: +Set a validator across all fields using `db.type().validate()`. The validator receives `{ newRecord, oldRecord, invoker }` and an `issues()` callback to report errors per field: ```typescript export const user = db @@ -371,35 +366,13 @@ export const user = db name: db.string(), email: db.string(), }) - .validate({ - name: [({ value }) => value.length > 5, "Name must be longer than 5 characters"], - email: [ - ({ value }) => value.includes("@"), - [({ value }) => value.length >= 5, "Email must be at least 5 characters"], - ], - }); -``` - -**Important:** Field-level and type-level validation cannot coexist on the same field. TypeScript will prevent this at compile time: - -```typescript -// Compile error - cannot set validation on the same field twice -export const user = db - .type("User", { - name: db.string().validate(({ value }) => value.length > 0), // Field-level - }) - .validate({ - name: [({ value }) => value.length < 100, "Too long"], // Type-level - ERROR - }); - -// OK - set validation on different fields -export const user = db - .type("User", { - name: db.string().validate(({ value }) => value.length > 0), // Field-level on name - email: db.string(), - }) - .validate({ - email: [({ value }) => value.includes("@"), "Invalid email"], // Type-level on email + .validate(({ newRecord }, issues) => { + if (newRecord.name.length <= 5) { + issues("name", "Name must be longer than 5 characters"); + } + if (!newRecord.email.includes("@")) { + issues("email", "Must contain @"); + } }); ``` @@ -432,6 +405,8 @@ export const user = db.type("User", { }); ``` +`db.fields.timestamps()` adds non-null `createdAt` and `updatedAt` datetime fields. Both fields are populated when a record is created; provided values are preserved so seed data can use historical timestamps. `updatedAt` is also refreshed automatically when a record is updated. + ## Type Modifiers ### Composite Indexes @@ -484,7 +459,7 @@ db.type("User", { - When `publishEvents: true`, record creation/update/deletion events are published - When not specified, it is **automatically set to `true`** if an executor uses this type with `recordCreatedTrigger`, `recordUpdatedTrigger`, or `recordDeletedTrigger` -- When explicitly set to `false` while an executor uses this type, an error is thrown during `tailor apply` +- When explicitly set to `false` while an executor uses this type, an error is thrown during `tailor deploy` **Use cases:** @@ -539,8 +514,8 @@ const user = db.type("User", { ...db.fields.timestamps(), }); -// Pick id and createdAt, making them optional -user.pickFields(["id", "createdAt"], { optional: true }); +// Pick id, createdAt, and updatedAt, making them optional +user.pickFields(["id", "createdAt", "updatedAt"], { optional: true }); ``` Available options: @@ -555,8 +530,8 @@ Available options: Return all fields except the specified ones: ```typescript -// All fields except id and createdAt -user.omitFields(["id", "createdAt"]); +// All fields except id, createdAt, and updatedAt +user.omitFields(["id", "createdAt", "updatedAt"]); ``` #### Common Pattern: Input Schema Composition @@ -571,9 +546,9 @@ export default createResolver({ name: "createUser", operation: "mutation", input: { - // id/createdAt are optional (auto-generated), other fields are required - ...user.pickFields(["id", "createdAt"], { optional: true }), - ...user.omitFields(["id", "createdAt"]), + // id/createdAt/updatedAt are optional (auto-generated), other fields are required + ...user.pickFields(["id", "createdAt", "updatedAt"], { optional: true }), + ...user.omitFields(["id", "createdAt", "updatedAt"]), }, output: t.object({ id: t.uuid() }), body: async (context) => { @@ -590,8 +565,8 @@ import { t } from "@tailor-platform/sdk"; import { invoice } from "../../tailordb/invoice"; const schemaType = t.object({ - ...invoice.pickFields(["id", "createdAt"], { optional: true }), - ...invoice.omitFields(["id", "createdAt", "invoiceNumber", "sequentialId"]), + ...invoice.pickFields(["id", "createdAt", "updatedAt"], { optional: true }), + ...invoice.omitFields(["id", "createdAt", "updatedAt", "invoiceNumber", "sequentialId"]), }); ``` @@ -643,6 +618,6 @@ db.type("User", { ## Migrations -When you change a TailorDB type definition, the SDK can generate a migration that captures the diff and, for breaking changes, runs a data transformation script during `tailor-sdk deploy`. See the [TailorDB Migrations guide](./tailordb-migration.md) for the full workflow, configuration, supported change types, team coordination, and troubleshooting. +When you change a TailorDB type definition, the SDK can generate a migration that captures the diff and, for breaking changes, runs a data transformation script during `tailor deploy`. See the [TailorDB Migrations guide](./tailordb-migration.md) for the full workflow, configuration, supported change types, team coordination, and troubleshooting. For the CLI command reference, see [`tailordb migration`](../cli/tailordb.md#tailordb-migration). diff --git a/packages/sdk/docs/services/workflow.md b/packages/sdk/docs/services/workflow.md index 022c963d4e..df83edd94b 100644 --- a/packages/sdk/docs/services/workflow.md +++ b/packages/sdk/docs/services/workflow.md @@ -105,11 +105,11 @@ import { sendNotification } from "./jobs/send-notification"; export const mainJob = createWorkflowJob({ name: "main-job", - body: async (input: { customerId: string }) => { - const customer = await fetchCustomer.trigger({ + body: (input: { customerId: string }) => { + const customer = fetchCustomer.trigger({ customerId: input.customerId, }); - const notification = await sendNotification.trigger({ + const notification = sendNotification.trigger({ message: "Order processed", recipient: customer.email, }); @@ -130,31 +130,31 @@ Using `.trigger()` inside a loop works correctly, as long as the loop is determi // ✅ OK: deterministic loop — same calls in the same order on every execution const regions = ["us", "eu", "ap"]; for (const region of regions) { - const result = await fetchData.trigger({ region }); + const result = fetchData.trigger({ region }); results.push(result); } ``` ```typescript // ❌ Bad: non-deterministic — argument changes between executions -await processJob.trigger({ timestamp: Date.now() }); +processJob.trigger({ timestamp: Date.now() }); // ✅ OK: call Date.now() in separated job -const timestamp = await timestampJob.trigger(); -await processJob.trigger({ timestamp }); +const timestamp = timestampJob.trigger(); +processJob.trigger({ timestamp }); ``` ```typescript // ❌ Bad: non-deterministic — external data may change between executions const items = await fetch("https://api.example.com/items").then((r) => r.json()); for (const item of items) { - await processItem.trigger({ id: item.id }); + processItem.trigger({ id: item.id }); } // ✅ OK: call fetch("https://api.example.com/items").then((r) => r.json()); in separated job -const items = await fetchItemsJob.trigger(); +const items = fetchItemsJob.trigger(); for (const item of items) { - await processItem.trigger({ id: item.id }); + processItem.trigger({ id: item.id }); } ``` @@ -178,15 +178,15 @@ import { sendNotification } from "./jobs/send-notification"; // Jobs must be named exports export const processOrder = createWorkflowJob({ name: "process-order", - body: async (input: { customerId: string }, { env, invoker }) => { + body: (input: { customerId: string }, { env, invoker }) => { // `env` contains values from `tailor.config.ts` -> `env`. - // `invoker` is the principal running this job, overridden by `authInvoker` - // when set; `null` for anonymous calls. + // `invoker` is the principal running this job, or the machine user + // configured through the trigger `invoker` option; `null` for anonymous calls. // Trigger other jobs by calling .trigger() on the job object. - const customer = await fetchCustomer.trigger({ + const customer = fetchCustomer.trigger({ customerId: input.customerId, }); - await sendNotification.trigger({ + sendNotification.trigger({ message: "Order processed", recipient: customer.email, }); @@ -207,23 +207,23 @@ Wait points allow a workflow job to suspend execution and wait for an external s ### Defining Wait Points -Use `defineWaitPoint` to declare a single typed wait point: +Use `createWaitPoint` to create a single typed wait point: ```typescript -import { defineWaitPoint } from "@tailor-platform/sdk"; +import { createWaitPoint } from "@tailor-platform/sdk"; -export const approval = defineWaitPoint< +export const approval = createWaitPoint< { message: string; requestId: string }, { approved: boolean } >("approval"); ``` -For multiple wait points, use `defineWaitPoints` with a builder callback. Property names become wait point keys, and JSDoc on each property is preserved in IDE autocompletion: +For multiple wait points, use `createWaitPoints` with a builder callback. Property names become wait point keys, and JSDoc on each property is preserved in IDE autocompletion: ```typescript -import { defineWaitPoints } from "@tailor-platform/sdk"; +import { createWaitPoints } from "@tailor-platform/sdk"; -export const waitPoints = defineWaitPoints((define) => ({ +export const waitPoints = createWaitPoints((define) => ({ /** Manager approval step */ managerApproval: define<{ amount: number }, { approved: boolean }>(), /** Finance review step */ @@ -245,9 +245,9 @@ Both must be JsonValue-compatible (plain objects/arrays; no class instances or f Call `.wait()` inside a workflow job body to suspend execution: ```typescript -import { createWorkflow, createWorkflowJob, defineWaitPoint } from "@tailor-platform/sdk"; +import { createWaitPoint, createWorkflow, createWorkflowJob } from "@tailor-platform/sdk"; -export const approval = defineWaitPoint< +export const approval = createWaitPoint< { message: string; requestId: string }, { approved: boolean } >("approval"); @@ -356,7 +356,7 @@ export default createWorkflow({ You can start a workflow execution from a resolver using `workflow.trigger()`. - `workflow.trigger(args, options?)` returns a workflow run ID (`Promise`). -- To run with machine-user permissions, pass `{ authInvoker: "" }`. The name is type-narrowed to the machine users defined in your auth config. +- To run with machine-user permissions, pass `{ invoker: "" }`. The name is type-narrowed to the machine users defined in your auth config. ```typescript import { createResolver, t } from "@tailor-platform/sdk"; @@ -372,7 +372,7 @@ export default createResolver({ body: async ({ input }) => { const workflowRunId = await orderProcessingWorkflow.trigger( { orderId: input.orderId, customerId: input.customerId }, - { authInvoker: "manager-machine-user" }, + { invoker: "manager-machine-user" }, ); return { workflowRunId }; @@ -383,8 +383,6 @@ export default createResolver({ }); ``` -> **Deprecated:** `auth.invoker("manager-machine-user")` still works but is deprecated. Using the string form avoids importing `auth` into runtime code. - See the full working example in the repository: [example/resolvers/triggerWorkflow.ts](https://github.com/tailor-platform/sdk/blob/main/example/resolvers/triggerWorkflow.ts). ## File Organization @@ -408,22 +406,22 @@ Manage workflows using the CLI: ```bash # List workflows -tailor-sdk workflow list +tailor workflow list # Get workflow details -tailor-sdk workflow get +tailor workflow get # Start a workflow -tailor-sdk workflow start -m -a '{"key": "value"}' +tailor workflow start -m -a '{"key": "value"}' # List executions -tailor-sdk workflow executions +tailor workflow executions # Get execution details with logs -tailor-sdk workflow executions --logs +tailor workflow executions --logs # Resume a failed execution -tailor-sdk workflow resume +tailor workflow resume ``` See [Workflow CLI Commands](../cli/workflow.md) for full documentation. diff --git a/packages/sdk/docs/testing.md b/packages/sdk/docs/testing.md index 4cd904995b..8a363eb089 100644 --- a/packages/sdk/docs/testing.md +++ b/packages/sdk/docs/testing.md @@ -11,18 +11,21 @@ Lean on unit tests for the day-to-day feedback loop — they run fast and exerci Unit-test entrypoints exposed by the SDK: -- `resolver.body({ input, user, env })` — invoke a resolver -- `workflowJob.body(input, { env })` / `workflowJob.trigger(input)` — invoke or chain a workflow job -- `executor.operation.body(args)` — invoke a function-kind executor +- `resolver.body({ input, caller, invoker, env })` — invoke a resolver +- `workflowJob.body(input, { env, invoker })` — invoke a workflow job body directly +- `workflowJob.trigger(input)` — chain a workflow job through the workflow runtime +- `runWorkflowLocally(workflow, args)` — run a workflow chain locally with real job bodies +- `executor.operation.body({ ...args, invoker })` — invoke a function-kind executor -Helpers under `@tailor-platform/sdk/test`: +For anonymous direct calls: -- `unauthenticatedTailorUser` — default `user` value for resolver contexts +- Pass `null` for anonymous `caller` / `invoker` context in direct unit tests. Platform API mocks under `@tailor-platform/sdk/vitest` (for use with the [`tailor-runtime` Vitest environment](#runtime-environment-emulation-beta) below): - `mockTailordb` — TailorDB query stubs and call recording - `mockWorkflow` — `tailor.workflow` job / wait / resolve mocks +- `runWorkflowLocally` — local full-chain workflow runner - `mockSecretmanager`, `mockAuthconnection`, `mockIdp`, `mockFile`, `mockIconv`, `mockAigateway` — corresponding platform API mocks For tighter alignment with the production runtime — Node.js module blocking, Web-only globals, and platform API mocks — pair the resolver helpers with the [`tailor-runtime` Vitest environment](#runtime-environment-emulation-beta) below. @@ -96,7 +99,12 @@ test("resolver queries the database", async () => { [], // COMMIT ); - const result = await resolver.body({ input: { email: "test@example.com" } }); + const result = await resolver.body({ + input: { email: "test@example.com" }, + caller: null, + invoker: null, + env: {}, + }); expect(result).toEqual({ oldAge: 30, newAge: 31 }); expect(db.executedQueries).toHaveLength(3); @@ -118,7 +126,12 @@ test("content-based mock", async () => { return []; }); - const result = await resolver.body({ input: { userId: "1" } }); + const result = await resolver.body({ + input: { userId: "1" }, + caller: null, + invoker: null, + env: {}, + }); expect(db.executedQueries[0].query).toContain("SELECT"); }); @@ -126,7 +139,7 @@ test("content-based mock", async () => { ### Workflow Mock -`.trigger()` runs the real job bodies locally out of the box (see [Running a full workflow locally](#running-a-full-workflow-locally)). Acquire `mockWorkflow()` when you want to override responses with `setJobHandler` / `enqueueResult` or assert on `triggeredJobs`: +Workflow job `.trigger()` calls use the platform workflow runtime. Acquire `mockWorkflow()` when you want to provide trigger responses with `setJobHandler` / `enqueueResult` or assert on `triggeredJobs`. If no response is configured, the mock throws so missing job mocks fail loudly: ```typescript import { mockWorkflow } from "@tailor-platform/sdk/vitest"; @@ -160,6 +173,8 @@ wf.enqueueResult({ valid: true }); wf.enqueueResults({ valid: true }, { txnId: "txn-1" }); ``` +Use `wf.setEnv(...)` when locally-run workflow job bodies need configuration values. Per-run `runWorkflowLocally(..., { env })` options take precedence over the mock's env. + ### SecretManager Mock ```typescript @@ -262,28 +277,6 @@ test("mock file download stream", async () => { }); ``` -For the deprecated `openDownloadStream`, enqueue an iterable of `StreamValue` items — `metadata`, one or more `chunk` items, and a terminal `complete`. Raw `Uint8Array` / `ArrayBuffer` chunks are rejected so tests stay aligned with the platform's structured stream contract. - -```typescript -test("mock file download stream (deprecated openDownloadStream)", async () => { - using file = mockFile(); - file.enqueueResult([ - { - type: "metadata", - metadata: { contentType: "image/png", fileSize: 3, sha256sum: "abc" }, - }, - { type: "chunk", data: new Uint8Array([1, 2]), position: 0 }, - { type: "chunk", data: new Uint8Array([3]), position: 2 }, - { type: "complete" }, - ]); - - const stream = await tailordb.file.openDownloadStream("ns", "Doc", "attachment", "r-1"); - const items = []; - for await (const item of stream) items.push(item); - expect(items).toHaveLength(4); -}); -``` - ### Iconv Mock ```typescript @@ -381,7 +374,6 @@ Unit tests call `.body()` (or `.trigger()`) directly on a resolver, workflow job For pure logic with no external dependencies, invoke `.body()` directly: ```typescript -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { describe, expect, test } from "vitest"; import resolver from "../src/resolver/add"; @@ -389,7 +381,8 @@ describe("add resolver", () => { test("adds two numbers", async () => { const result = await resolver.body({ input: { left: 1, right: 2 }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: {}, }); expect(result).toBe(3); @@ -406,7 +399,6 @@ Stub the global `tailordb.Client` and queue raw query results in order. Best for > If you are running with the [`tailor-runtime` Vitest environment](#runtime-environment-emulation-beta), acquire `using db = mockTailordb()` to install and drive the mock `tailordb.Client` instead of `vi.stubGlobal()`. ```typescript -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import resolver from "../src/resolver/incrementUserAge"; @@ -436,7 +428,8 @@ describe("incrementUserAge resolver", () => { const result = await resolver.body({ input: { email: "test@example.com" }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: {}, }); @@ -507,7 +500,6 @@ describe("decrementUserAge", () => { Pass `mock.db` to functions that take a Kysely instance. When a resolver or executor calls `getDB()` internally there is no such seam, so spy the generated `getDB` and point it at the mock: ```typescript -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { createKyselyMock } from "@tailor-platform/sdk/vitest"; import { describe, expect, test, vi } from "vitest"; import { getDB, type Namespace } from "../generated/db"; @@ -539,7 +531,8 @@ describe("upsertUsers resolver", () => { { name: "Existing", email: "exists@example.com", age: 41 }, ], }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: { appName: "Resolver Template", version: 1 }, }); @@ -558,7 +551,6 @@ Reach for [`mockTailordb`](#mocking-the-tailordb-client) instead when you want t Resolvers that call `waitPoint.resolve(...)` delegate to `tailor.workflow.resolve` at runtime. With the `tailor-runtime` environment active, use `mockWorkflow().setResolveHandler` to drive the user-supplied callback and inspect `resolveCalls`: ```typescript -import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; import { mockWorkflow } from "@tailor-platform/sdk/vitest"; import { describe, expect, test } from "vitest"; import resolver from "./resolveApproval"; @@ -573,7 +565,8 @@ describe("resolveApproval resolver", () => { const result = await resolver.body({ input: { executionId: "exec-1", approved: true }, - user: unauthenticatedTailorUser, + caller: null, + invoker: null, env: {}, }); @@ -616,7 +609,7 @@ describe("retryFailedWorkflow resolver", () => { ### Testing Executors -Function-kind executors expose their handler as `executor.operation.body(args)`. The shape of `args` is determined by the trigger — for example, `recordCreatedTrigger({ type: user })` produces `{ newRecord }` typed against the type's output. GraphQL, webhook, and workflow operation kinds are declarative and don't expose a user-authored body to test. +Function-kind executors expose their handler as `executor.operation.body(args)`. The shape of `args` is determined by the trigger — for example, `recordCreatedTrigger({ type: user })` produces `{ newRecord }` typed against the type's output, plus runtime fields such as `env`, `actor`, and `invoker`. GraphQL, webhook, and workflow operation kinds are declarative and don't expose a user-authored body to test. The `executor` template extracts shared DB access into a helper (`shared.ts`) and tests the helper directly against a mocked `tailordb.Client` (same TailorDB-mocking pattern as the resolver section). Executor handlers themselves stay thin and can be tested by spying on the helper: @@ -633,6 +626,14 @@ describe("onUserCreated executor", () => { throw new Error("expected function operation"); } await onUserCreated.operation.body({ + workspaceId: "workspace-1", + appNamespace: "app", + env: {}, + actor: null, + invoker: null, + event: "created", + rawEvent: "tailordb.type_record.created", + typeName: "User", newRecord: { id: "user-1", name: "Alice", @@ -661,7 +662,7 @@ Workflow jobs expose the same `.body()` entrypoint as resolvers, plus `.trigger( #### Simple job -Call `.body()` with the input and a stub `{ env: {} }`: +Call `.body()` with the input and a stub `{ env: {}, invoker: null }`: ```typescript import { describe, expect, test } from "vitest"; @@ -669,14 +670,17 @@ import { validateOrder } from "./order-fulfillment"; describe("validateOrder", () => { test("accepts a valid order", () => { - const result = validateOrder.body({ orderId: "order-1", amount: 100 }, { env: {} }); + const result = validateOrder.body( + { orderId: "order-1", amount: 100 }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ valid: true, orderId: "order-1" }); }); test("rejects a non-positive amount", () => { - expect(() => validateOrder.body({ orderId: "order-1", amount: 0 }, { env: {} })).toThrow( - "Order amount must be positive", - ); + expect(() => + validateOrder.body({ orderId: "order-1", amount: 0 }, { env: {}, invoker: null }), + ).toThrow("Order amount must be positive"); }); }); ``` @@ -691,22 +695,25 @@ import { fulfillOrder, processPayment, sendConfirmation, validateOrder } from ". describe("fulfillOrder", () => { test("chains validate → pay → confirm", async () => { - using _validateSpy = vi.spyOn(validateOrder, "trigger").mockResolvedValue({ + using _validateSpy = vi.spyOn(validateOrder, "trigger").mockReturnValue({ valid: true, orderId: "order-1", }); - using _paymentSpy = vi.spyOn(processPayment, "trigger").mockResolvedValue({ + using _paymentSpy = vi.spyOn(processPayment, "trigger").mockReturnValue({ transactionId: "txn-order-1", amount: 100, status: "completed", }); - using _confirmSpy = vi.spyOn(sendConfirmation, "trigger").mockResolvedValue({ + using _confirmSpy = vi.spyOn(sendConfirmation, "trigger").mockReturnValue({ orderId: "order-1", transactionId: "txn-order-1", confirmed: true, }); - const result = await fulfillOrder.body({ orderId: "order-1", amount: 100 }, { env: {} }); + const result = await fulfillOrder.body( + { orderId: "order-1", amount: 100 }, + { env: {}, invoker: null }, + ); expect(validateOrder.trigger).toHaveBeenCalledWith({ orderId: "order-1", amount: 100 }); expect(result).toMatchObject({ confirmed: true, paymentStatus: "completed" }); @@ -730,7 +737,10 @@ describe("processWithApproval", () => { using wf = mockWorkflow(); wf.setWaitHandler({ approved: true }); - const result = await processWithApproval.body({ orderId: "order-1" }, { env: {} }); + const result = await processWithApproval.body( + { orderId: "order-1" }, + { env: {}, invoker: null }, + ); expect(result).toEqual({ orderId: "order-1", status: "approved" }); expect(wf.waitCalls[0]).toEqual({ @@ -743,7 +753,10 @@ describe("processWithApproval", () => { using wf = mockWorkflow(); wf.setWaitHandler({ approved: false }); - const result = await processWithApproval.body({ orderId: "order-2" }, { env: {} }); + const result = await processWithApproval.body( + { orderId: "order-2" }, + { env: {}, invoker: null }, + ); expect(result.status).toBe("rejected"); }); @@ -754,22 +767,29 @@ describe("processWithApproval", () => { #### Running a full workflow locally -To exercise the full chain with real job bodies, just call `workflow.mainJob.trigger()` — no `mockWorkflow()` needed. Dependent jobs run their real `.body()` functions, and trigger args/results cross the same JSON boundary as the platform, so a non-serializable payload fails the test exactly as it would in production: +To exercise the full chain with real job bodies, call `runWorkflowLocally(workflow, args)`. Dependent jobs run their real `.body()` functions, and trigger args/results cross the same JSON boundary as the platform, so a non-serializable payload fails the test exactly as it would in production: ```typescript +import { runWorkflowLocally } from "@tailor-platform/sdk/vitest"; import { describe, expect, test } from "vitest"; import workflow from "./order-fulfillment"; describe("order-fulfillment workflow", () => { - test("mainJob.trigger() executes all jobs", async () => { - const result = await workflow.mainJob.trigger({ orderId: "order-3", amount: 300 }); + test("runWorkflowLocally() executes all jobs", async () => { + const result = await runWorkflowLocally(workflow, { orderId: "order-3", amount: 300 }); expect(result).toMatchObject({ confirmed: true, paymentStatus: "completed" }); }); }); ``` -Acquire `mockWorkflow()` only when you need to override a dependent job with `wf.setJobHandler(...)` / `wf.enqueueResult(...)` (the rest still run their real bodies), control the env via `wf.setEnv(...)`, or assert on `wf.triggeredJobs`. +Pass `{ env }` as the third argument when job bodies need configuration values during the local run. + +If you already acquired `mockWorkflow()`, you can also call `wf.setEnv(...)` to reuse the same env across local workflow runs. + +Like the platform runtime, the local runner re-runs the orchestrator body once per `.trigger()` call (N triggers means N+1 passes), so any side effects outside the trigger results fire on every pass. Keep the body deterministic and move repeatable side effects into the triggered jobs. + +This helper is still a local runner. Use E2E tests when you need to verify deployed workflow scheduling, suspension, or replay behavior. **Use when:** you want to verify orchestration end to end without the cost of a real deployment. @@ -869,14 +889,13 @@ Use `startWorkflow` from the CLI helpers. It starts the workflow on the deployed import { randomUUID } from "node:crypto"; import { startWorkflow } from "@tailor-platform/sdk/cli"; import { describe, expect, test } from "vitest"; -import config from "../tailor.config"; import userProfileSync from "../src/workflow/sync-profile"; describe("user-profile-sync workflow", () => { test("executes end to end", { timeout: 180_000 }, async () => { const { executionId, wait } = await startWorkflow({ workflow: userProfileSync, - authInvoker: config.auth.invoker("admin"), + invoker: "admin", arg: { name: "workflow-test", email: `wf-${randomUUID()}@example.com`, diff --git a/packages/sdk/e2e/deploy.test.ts b/packages/sdk/e2e/deploy.test.ts index b2507a493c..fad7a44ff6 100644 --- a/packages/sdk/e2e/deploy.test.ts +++ b/packages/sdk/e2e/deploy.test.ts @@ -8,7 +8,7 @@ * The fix ensures services are deleted AFTER the Application is deleted. * * Prerequisites: - * - Authentication via TAILOR_PLATFORM_TOKEN env var or `tailor-sdk login` + * - Authentication via TAILOR_PLATFORM_TOKEN env var or `tailor login` * - TAILOR_PLATFORM_ORGANIZATION_ID environment variable must be set */ diff --git a/packages/sdk/e2e/fixtures/migration/config.template.ts b/packages/sdk/e2e/fixtures/migration/config.template.ts index 1e08b0332a..d569d729f6 100644 --- a/packages/sdk/e2e/fixtures/migration/config.template.ts +++ b/packages/sdk/e2e/fixtures/migration/config.template.ts @@ -1,4 +1,5 @@ -import { defineConfig, defineAuth, defineGenerators } from "@tailor-platform/sdk"; +import { defineConfig, defineAuth, definePlugins } from "@tailor-platform/sdk"; +import { kyselyTypePlugin } from "@tailor-platform/sdk/plugin/kysely-type"; import { user } from "./tailordb/user"; const auth = defineAuth("migration-test-auth", { @@ -16,10 +17,7 @@ const auth = defineAuth("migration-test-auth", { }, }); -export const generators = defineGenerators([ - "@tailor-platform/kysely-type", - { distPath: "./generated/tailordb.ts" }, -]); +export const plugins = definePlugins(kyselyTypePlugin({ distPath: "./generated/tailordb.ts" })); export default defineConfig({ id: "dd52af75-a667-4751-806f-a6f3d16ee4c2", diff --git a/packages/sdk/e2e/function-test-run.test.ts b/packages/sdk/e2e/function-test-run.test.ts index 1baaf81878..42bdb743dd 100644 --- a/packages/sdk/e2e/function-test-run.test.ts +++ b/packages/sdk/e2e/function-test-run.test.ts @@ -5,11 +5,11 @@ * different function types on the Tailor Platform server via TestExecScript API. * * Uses internal APIs directly (detectFunctionType, bundleForTestRun, executeScript) - * instead of spawning CLI subprocesses. The `apply` step still uses subprocess + * instead of spawning CLI subprocesses. The deploy step still uses subprocess * since it orchestrates multiple services. * * Prerequisites: - * - Authentication via TAILOR_PLATFORM_TOKEN env var or `tailor-sdk login` + * - Authentication via TAILOR_PLATFORM_TOKEN env var or `tailor login` * - TAILOR_PLATFORM_ORGANIZATION_ID environment variable must be set * - packages/sdk must be built (dist/cli/index.mjs must exist) * @@ -29,7 +29,6 @@ import { import { describe, test, expect, beforeAll } from "vitest"; import { bundleForTestRun, type ResolvedMachineUser } from "../src/cli/commands/function/bundle"; import { detectFunctionType } from "../src/cli/commands/function/detect"; -import { resolveResolverArg } from "../src/cli/commands/function/test-run"; import { initOperatorClient, type OperatorClient } from "../src/cli/shared/client"; import { loadAccessToken } from "../src/cli/shared/context"; import { executeScript, type ScriptExecutionResult } from "../src/cli/shared/script-executor"; @@ -49,7 +48,7 @@ const exampleDir = path.resolve(sdkRoot, "..", "..", "example"); let workspaceId: string; let client: OperatorClient; -let authInvoker: AuthInvoker; +let invoker: AuthInvoker; let machineUser: ResolvedMachineUser; const env = { foo: 1, bar: "hello", baz: true }; const AUTH_NAMESPACE = "my-auth"; @@ -79,8 +78,8 @@ async function runTestRun( workspaceId, name: scriptName, code, - arg: options?.arg, - invoker: authInvoker, + arg: options?.arg === undefined ? undefined : JSON.parse(options.arg), + invoker, }); return { ...result, scriptName }; } @@ -91,12 +90,8 @@ async function runTestRun( }); let resolvedArg = options?.arg; - if (detected.type === "resolver" && resolvedArg) { - if (!detected.hasInput) { - resolvedArg = undefined; - } else if (detected.inputSchema) { - resolvedArg = resolveResolverArg(resolvedArg, detected.inputSchema, machineUser, workspaceId); - } + if (detected.type === "resolver" && resolvedArg && !detected.hasInput) { + resolvedArg = undefined; } const { bundledCode, scriptName } = await bundleForTestRun({ @@ -112,8 +107,8 @@ async function runTestRun( workspaceId, name: scriptName, code: bundledCode, - arg: resolvedArg, - invoker: authInvoker, + arg: resolvedArg === undefined ? undefined : JSON.parse(resolvedArg), + invoker, }); return { ...result, scriptName, functionType: detected.type, functionName: detected.name }; @@ -138,11 +133,11 @@ describe.sequential("E2E: function test-run", () => { console.log(`Workspace created: ${workspaceId}`); trackWorkspace(workspaceId); - // Apply example config to deploy DB schema, auth, machine users - console.log("Applying example config..."); + // Deploy example config to create DB schema, auth, machine users + console.log("Deploying example config..."); execFileSync( "node", - [cliPath, "apply", "--config", "tailor.config.ts", "--workspace-id", workspaceId, "--yes"], + [cliPath, "deploy", "--config", "tailor.config.ts", "--workspace-id", workspaceId, "--yes"], { cwd: exampleDir, stdio: ["ignore", "pipe", "pipe"], @@ -151,7 +146,7 @@ describe.sequential("E2E: function test-run", () => { timeout: 120000, }, ); - console.log("Apply completed."); + console.log("Deploy completed."); // Resolve machine user from API + config let machineUserId = "00000000-0000-0000-0000-000000000000"; @@ -174,7 +169,7 @@ describe.sequential("E2E: function test-run", () => { attributeList: [], }; - authInvoker = create(AuthInvokerSchema, { + invoker = create(AuthInvokerSchema, { namespace: AUTH_NAMESPACE, machineUserName: MACHINE_USER_NAME, }); @@ -183,7 +178,7 @@ describe.sequential("E2E: function test-run", () => { describe("resolver", () => { test("runs add resolver with input arguments", async () => { const result = await runTestRun("resolvers/add.ts", { - arg: '{"input":{"a":1,"b":2}}', + arg: '{"a":1,"b":2}', }); expect(result.success).toBe(true); @@ -201,11 +196,11 @@ describe.sequential("E2E: function test-run", () => { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const nilUuid = "00000000-0000-0000-0000-000000000000"; - expect(parsed.user.type).toBe("machine_user"); - expect(parsed.user.workspaceId).toBe(workspaceId); - expect(parsed.user.role).toBe("MANAGER"); - expect(parsed.user.id).toMatch(uuidRegex); - expect(parsed.user.id).not.toBe(nilUuid); + expect(parsed.caller.type).toBe("machine_user"); + expect(parsed.caller.workspaceId).toBe(workspaceId); + expect(parsed.caller.role).toBe("MANAGER"); + expect(parsed.caller.id).toMatch(uuidRegex); + expect(parsed.caller.id).not.toBe(nilUuid); expect(parsed.invoker.type).toBe("machine_user"); expect(parsed.invoker.workspaceId).toBe(workspaceId); @@ -216,7 +211,7 @@ describe.sequential("E2E: function test-run", () => { test("injects environment variables into resolver", async () => { const result = await runTestRun("resolvers/env.ts", { - arg: '{"input":{"multiplier":3}}', + arg: '{"multiplier":3}', }); expect(result.success).toBe(true); @@ -229,7 +224,7 @@ describe.sequential("E2E: function test-run", () => { test("supports getDB in resolver (stepChain)", async () => { const result = await runTestRun("resolvers/stepChain.ts", { - arg: '{"input":{"user":{"name":{"first":"John","last":"Doe"}}}}', + arg: '{"user":{"name":{"first":"John","last":"Doe"}}}', }); expect(result.success).toBe(true); @@ -242,7 +237,7 @@ describe.sequential("E2E: function test-run", () => { test("inserts nested object with Date and verifies round-trip", async () => { const result = await runTestRun("resolvers/insertNestedProfileWithDate.ts", { - arg: '{"input":{"name":"Test User","email":"test@example.com"}}', + arg: '{"name":"Test User","email":"test@example.com"}', }); expect(result.success).toBe(true); @@ -257,7 +252,7 @@ describe.sequential("E2E: function test-run", () => { test("reports validation errors for invalid input", async () => { const result = await runTestRun("resolvers/add.ts", { - arg: '{"input":{"a":100,"b":2}}', + arg: '{"a":100,"b":2}', }); expect(result.success).toBe(false); @@ -316,8 +311,8 @@ describe.sequential("E2E: function test-run", () => { workspaceId, name: "add.js", code, - arg: '{"a":5,"b":7}', - invoker: authInvoker, + arg: { a: 5, b: 7 }, + invoker, }); expect(result.success).toBe(true); @@ -346,7 +341,7 @@ describe.sequential("E2E: function test-run", () => { workspaceId, name: scriptName, code: bundledCode, - invoker: authInvoker, + invoker, }); expect(result.success).toBe(false); diff --git a/packages/sdk/e2e/globalSetup.ts b/packages/sdk/e2e/globalSetup.ts index 4dbcac5661..e2b74f02fe 100644 --- a/packages/sdk/e2e/globalSetup.ts +++ b/packages/sdk/e2e/globalSetup.ts @@ -8,7 +8,7 @@ import * as path from "node:path"; import { initOperatorClient, type OperatorClient } from "../src/cli/shared/client"; import { loadAccessToken } from "../src/cli/shared/context"; -// e2e must authenticate as the machine user from `tailor-sdk login --machineuser`, +// e2e must authenticate as the machine user from `tailor login --machine-user`, // never as the developer's locally configured profile. delete process.env.TAILOR_PLATFORM_PROFILE; diff --git a/packages/sdk/e2e/migration.test.ts b/packages/sdk/e2e/migration.test.ts index f6fa2d14ba..8779b08e45 100644 --- a/packages/sdk/e2e/migration.test.ts +++ b/packages/sdk/e2e/migration.test.ts @@ -8,7 +8,7 @@ * - Apply with migrations * * Prerequisites: - * - Authentication via TAILOR_PLATFORM_TOKEN env var or `tailor-sdk login` + * - Authentication via TAILOR_PLATFORM_TOKEN env var or `tailor login` * - TAILOR_PLATFORM_ORGANIZATION_ID environment variable must be set * * Running Tests: @@ -103,17 +103,17 @@ function runGenerateCli(configPath: string, cwd: string): void { } /** - * Run the apply CLI command via subprocess + * Run the deploy CLI command via subprocess * @param {string} configPath - Path to the config file * @param {string} workspaceId - Workspace ID * @param {string} cwd - Working directory */ -function runApplyCli(configPath: string, workspaceId: string, cwd: string): void { +function runDeployCli(configPath: string, workspaceId: string, cwd: string): void { const sdkRoot = path.resolve(__dirname, ".."); const cliPath = path.join(sdkRoot, "dist", "cli", "index.mjs"); try { - execSync(`node ${cliPath} apply --config ${configPath} --workspace-id ${workspaceId} --yes`, { + execSync(`node ${cliPath} deploy --config ${configPath} --workspace-id ${workspaceId} --yes`, { cwd, stdio: ["ignore", "pipe", "pipe"], // stdin ignored, stdout/stderr piped env: { @@ -121,27 +121,27 @@ function runApplyCli(configPath: string, workspaceId: string, cwd: string): void NODE_OPTIONS: "--experimental-vm-modules", }, encoding: "utf-8", - timeout: 120000, // 120 second timeout for apply operations + timeout: 120000, // 120 second timeout for deploy operations }); // Success - output captured but not logged to keep test output clean } catch (error: unknown) { // Log error details for debugging if (error && typeof error === "object" && "stderr" in error) { - console.error("Apply CLI error:", (error as { stderr?: Buffer }).stderr?.toString()); + console.error("Deploy CLI error:", (error as { stderr?: Buffer }).stderr?.toString()); } throw error; } } /** - * Run the apply CLI command, returning the result instead of throwing so callers + * Run the deploy CLI command, returning the result instead of throwing so callers * can assert on an expected failure. * @param {string} configPath - Path to the config file * @param {string} workspaceId - Workspace ID * @param {string} cwd - Working directory - * @returns {{ ok: boolean; output: string }} Whether apply succeeded and its combined output + * @returns {{ ok: boolean; output: string }} Whether deploy succeeded and its combined output */ -function tryApplyCli( +function tryDeployCli( configPath: string, workspaceId: string, cwd: string, @@ -151,7 +151,7 @@ function tryApplyCli( try { const out = execSync( - `node ${cliPath} apply --config ${configPath} --workspace-id ${workspaceId} --yes`, + `node ${cliPath} deploy --config ${configPath} --workspace-id ${workspaceId} --yes`, { cwd, stdio: ["ignore", "pipe", "pipe"], @@ -429,7 +429,7 @@ describe.sequential("E2E: TailorDB Migrations", () => { test("applies initial migration to workspace", async () => { const configPath = createConfig(); - runApplyCli(configPath, workspaceId, tempDir); + runDeployCli(configPath, workspaceId, tempDir); // Verify: TailorDB service should exist const services = await listTailorDBServiceNames(); @@ -488,7 +488,7 @@ export type user = typeof user; test("applies non-breaking migration to workspace", async () => { const configPath = createConfig(); - runApplyCli(configPath, workspaceId, tempDir); + runDeployCli(configPath, workspaceId, tempDir); // Verify: phone field should be added to User type const fields = await getTailorDBTypeFields(tailordbName, "User"); @@ -552,7 +552,7 @@ export type user = typeof user; const configPath = createConfig(); - runApplyCli(configPath, workspaceId, tempDir); + runDeployCli(configPath, workspaceId, tempDir); // Verify: requiredField should be added to User type const fields = await getTailorDBTypeFields(tailordbName, "User"); @@ -616,7 +616,7 @@ export type user = typeof user; test("applies type addition to workspace", async () => { const configPath = createConfig(); - runApplyCli(configPath, workspaceId, tempDir); + runDeployCli(configPath, workspaceId, tempDir); // Verify: Post type should be added const types = await listTailorDBTypeNames(tailordbName); @@ -670,7 +670,7 @@ export type user = typeof user; test("applies field removal to workspace", async () => { const configPath = createConfig(); - runApplyCli(configPath, workspaceId, tempDir); + runDeployCli(configPath, workspaceId, tempDir); // Verify: requiredField should be removed from User type const fields = await getTailorDBTypeFields(tailordbName, "User"); @@ -725,9 +725,9 @@ export type user = typeof user; expect(checkpointBefore).toBe(4); const configPath = createConfig(); - const result = tryApplyCli(configPath, workspaceId, tempDir); + const result = tryDeployCli(configPath, workspaceId, tempDir); - // The apply must fail for the injected reason, not an unrelated error. + // The deploy must fail for the injected reason, not an unrelated error. expect(result.ok).toBe(false); expect(result.output).toContain("simulated migration failure for rollback e2e"); @@ -755,8 +755,8 @@ export type user = typeof user; ); const configPath = createConfig(); - // No drift error: the failed apply left a consistent baseline at checkpoint 4. - runApplyCli(configPath, workspaceId, tempDir); + // No drift error: the failed deploy left a consistent baseline at checkpoint 4. + runDeployCli(configPath, workspaceId, tempDir); const fields = await getTailorDBTypeFields(tailordbName, "User"); expect(fields).toContain("loyaltyTier"); diff --git a/packages/sdk/knip.ts b/packages/sdk/knip.ts index 06f7637c77..c28bae0cca 100644 --- a/packages/sdk/knip.ts +++ b/packages/sdk/knip.ts @@ -10,9 +10,11 @@ export default { "e2e/fixtures/**", "src/cli/commands/deploy/__test_fixtures__/**", "src/cli/commands/tailordb/erd/viewer-assets/**", + "src/cli/ts-hook.d.mts", "src/types/*.ts", "src/vitest/integration/vitest.config.ts", "zinfer.config.ts", ], + ignoreDependencies: ["undici", "vite"], ignoreBinaries: ["knip", "publint", "actionlint"], } satisfies KnipConfig; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a20191e3b8..0d52053cf0 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@tailor-platform/sdk", - "version": "1.74.0", + "version": "2.0.0-next.2", "description": "Tailor Platform SDK - The SDK to work with Tailor Platform", "license": "MIT", "repository": { @@ -9,8 +9,7 @@ "directory": "packages/sdk" }, "bin": { - "tailor-sdk": "./dist/cli/index.mjs", - "tailor-sdk-skills": "./dist/cli/skills.mjs" + "tailor": "./dist/cli/index.mjs" }, "files": [ "CHANGELOG.md", @@ -150,11 +149,11 @@ "test:coverage": "vitest --coverage", "docs:check": "vitest run --project=unit* src/cli/docs.test.ts", "docs:update": "POLITTY_DOCS_UPDATE=true vitest run --project=unit* src/cli/docs.test.ts", - "build": "tsdown && politty generate-worker --bin dist/cli/index.mjs --program tailor-sdk --shell zsh --verify", + "build": "tsdown && politty generate-worker --bin dist/cli/index.mjs --program tailor --shell zsh --verify", "lint": "oxlint --type-aware .", - "check:public-api-jsdoc": "tsx scripts/check-public-api-jsdoc.ts", - "check:zod-isolation": "tsx scripts/check-zod-isolation.ts", - "check:import-cycles": "tsx scripts/check-import-cycles.ts", + "check:public-api-jsdoc": "node --experimental-strip-types scripts/check-public-api-jsdoc.ts", + "check:zod-isolation": "node --experimental-strip-types scripts/check-zod-isolation.ts", + "check:import-cycles": "node --experimental-strip-types scripts/check-import-cycles.ts", "lint:fix": "oxlint --type-aware . --fix", "typecheck": "tsc --noEmit", "typecheck:go": "tsgo", @@ -189,6 +188,7 @@ "@toiroakr/lines-db": "0.10.0", "@toiroakr/read-multiline": "0.4.1", "@urql/core": "6.0.3", + "amaro": "1.1.10", "chalk": "5.6.2", "chokidar": "5.0.0", "confbox": "0.2.4", @@ -214,7 +214,6 @@ "std-env": "4.1.0", "table": "6.9.0", "ts-cron-validator": "1.1.5", - "tsx": "4.22.5", "type-fest": "5.7.0", "undici": "8.5.0", "xdg-basedir": "5.1.0", @@ -229,6 +228,7 @@ "@types/semver": "7.7.1", "@typescript/native-preview": "7.0.0-dev.20260621.1", "@vitest/coverage-v8": "4.1.9", + "eslint-plugin-zod": "4.7.0", "oxfmt": "0.57.0", "oxlint": "1.72.0", "oxlint-tsgolint": "0.24.0", @@ -252,6 +252,6 @@ }, "engines": { "bun": ">=1.2.0", - "node": ">=22" + "node": ">=22.15.0" } } diff --git a/packages/sdk/postinstall.mjs b/packages/sdk/postinstall.mjs index 7ac8733d6f..76e72bf743 100644 --- a/packages/sdk/postinstall.mjs +++ b/packages/sdk/postinstall.mjs @@ -1,7 +1,6 @@ #!/usr/bin/env node import { existsSync } from "node:fs"; -import { register } from "node:module"; import { dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { findUpSync } from "find-up-simple"; @@ -12,9 +11,9 @@ const DEFAULT_CONFIG_FILENAME = "tailor.config.ts"; async function install() { const cwd = process.env.INIT_CWD || process.cwd(); - // Skip if running in the tailor-sdk package itself + // Skip if running in the @tailor-platform/sdk package itself if (cwd === __dirname || cwd === resolve(__dirname, "..", "..")) { - console.log("⚠️ Skipping postinstall in tailor-sdk package itself"); + console.log("⚠️ Skipping postinstall in @tailor-platform/sdk package itself"); return; } @@ -22,8 +21,8 @@ async function install() { // Try to find and load the user's tailor.config.ts // Priority: env/config > search parent directories - const configPath = process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH - ? resolve(cwd, process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH) + const configPath = process.env.TAILOR_CONFIG_PATH + ? resolve(cwd, process.env.TAILOR_CONFIG_PATH) : findUpSync(DEFAULT_CONFIG_FILENAME, { cwd }); if (!configPath || !existsSync(configPath)) { @@ -34,7 +33,6 @@ async function install() { try { const configDir = dirname(configPath); process.chdir(configDir); - register("tsx", import.meta.url, { data: {} }); const { generateUserTypes, loadConfig } = await import( pathToFileURL(resolve(__dirname, "dist", "cli", "lib.mjs")).href diff --git a/packages/sdk/scripts/build-entries.mjs b/packages/sdk/scripts/build-entries.mjs index 551f2d56e4..33f20e8e9a 100644 --- a/packages/sdk/scripts/build-entries.mjs +++ b/packages/sdk/scripts/build-entries.mjs @@ -2,7 +2,6 @@ export const entry = [ "src/configure/index.ts", "src/cli/index.ts", "src/cli/lib.ts", - "src/cli/skills.ts", "src/utils/test/index.ts", "src/kysely/index.ts", "src/plugin/index.ts", diff --git a/packages/sdk/scripts/check-import-cycles.ts b/packages/sdk/scripts/check-import-cycles.ts index 24f9c572ff..917d4b630a 100644 --- a/packages/sdk/scripts/check-import-cycles.ts +++ b/packages/sdk/scripts/check-import-cycles.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env -S pnpm exec tsx // Verify the src/ module graph is acyclic — including type-only edges. // // oxlint's import/no-cycle (ignoreTypes: false) already rejects cycles formed diff --git a/packages/sdk/scripts/check-public-api-jsdoc.ts b/packages/sdk/scripts/check-public-api-jsdoc.ts index 6871035d15..38e2d53d85 100644 --- a/packages/sdk/scripts/check-public-api-jsdoc.ts +++ b/packages/sdk/scripts/check-public-api-jsdoc.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env -S pnpm exec tsx // Verify every public API export has JSDoc. // // "Public API" is derived from package.json#exports — each `types` entry diff --git a/packages/sdk/scripts/check-zod-isolation.ts b/packages/sdk/scripts/check-zod-isolation.ts index e3c3b2b45a..85599bc3e9 100644 --- a/packages/sdk/scripts/check-zod-isolation.ts +++ b/packages/sdk/scripts/check-zod-isolation.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env -S pnpm exec tsx // Verify zod stays isolated to the CLI entry point. // // zinfer exists so that user-facing entry points never depend on zod: diff --git a/packages/sdk/scripts/perf/features/tailordb-validate.ts b/packages/sdk/scripts/perf/features/tailordb-validate.ts index e4eacf5054..888b41f059 100644 --- a/packages/sdk/scripts/perf/features/tailordb-validate.ts +++ b/packages/sdk/scripts/perf/features/tailordb-validate.ts @@ -6,61 +6,81 @@ import { db } from "../../../src/configure"; export const type0 = db.type("Type0", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); export const type1 = db.type("Type1", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); export const type2 = db.type("Type2", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); export const type3 = db.type("Type3", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); export const type4 = db.type("Type4", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); export const type5 = db.type("Type5", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); export const type6 = db.type("Type6", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); export const type7 = db.type("Type7", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); export const type8 = db.type("Type8", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); export const type9 = db.type("Type9", { - name: db.string().validate(({ value }) => value.length > 0), - email: db.string().validate([({ value }) => value.includes("@"), "Must be valid email"]), - age: db.int().validate(({ value }) => value >= 0), + name: db.string().validate(({ value }) => (value.length <= 0 ? "Must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Must be valid email" : undefined)), + age: db.int().validate(({ value }) => (value < 0 ? "Must be non-negative" : undefined)), }); diff --git a/packages/sdk/scripts/perf/runtime-perf.sh b/packages/sdk/scripts/perf/runtime-perf.sh index 14f65b3dc7..8f84af7268 100644 --- a/packages/sdk/scripts/perf/runtime-perf.sh +++ b/packages/sdk/scripts/perf/runtime-perf.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Runtime performance benchmark for tailor-sdk generate and apply -d commands +# Runtime performance benchmark for tailor generate and apply -d commands # These options ensure the script fails on errors set -euo pipefail @@ -46,7 +46,7 @@ if ! pnpm generate > "${LOG_DIR}/generate-warmup.log" 2>&1; then fi echo "Warmup: Running apply (build-only)..." -if ! TAILOR_PLATFORM_SDK_BUILD_ONLY=true pnpm exec tailor-sdk deploy -c tailor.config.ts > "${LOG_DIR}/apply-warmup.log" 2>&1; then +if ! TAILOR_DEPLOY_BUILD_ONLY=true pnpm exec tailor deploy -c tailor.config.ts > "${LOG_DIR}/apply-warmup.log" 2>&1; then echo "ERROR: apply warmup failed" cat "${LOG_DIR}/apply-warmup.log" exit 1 @@ -78,7 +78,7 @@ echo "Measuring apply (build-only) command..." for i in $(seq 1 $ITERATIONS); do echo " apply iteration $i/$ITERATIONS..." START=$(get_timestamp_ms) - if ! TAILOR_PLATFORM_SDK_BUILD_ONLY=true pnpm exec tailor-sdk deploy -c tailor.config.ts > "${LOG_DIR}/apply-iter-${i}.log" 2>&1; then + if ! TAILOR_DEPLOY_BUILD_ONLY=true pnpm exec tailor deploy -c tailor.config.ts > "${LOG_DIR}/apply-iter-${i}.log" 2>&1; then echo "ERROR: apply iteration $i failed" cat "${LOG_DIR}/apply-iter-${i}.log" exit 1 diff --git a/packages/sdk/src/cli/bundler/query/query-bundler.test.ts b/packages/sdk/src/cli/bundler/query/query-bundler.test.ts index 1fa2c515ec..8322b60343 100644 --- a/packages/sdk/src/cli/bundler/query/query-bundler.test.ts +++ b/packages/sdk/src/cli/bundler/query/query-bundler.test.ts @@ -12,11 +12,11 @@ describe("query-bundler", () => { `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ); fs.mkdirSync(testDir, { recursive: true }); - process.env.TAILOR_SDK_OUTPUT_DIR = testDir; + process.env.TAILOR_BUILD_OUTPUT_DIR = testDir; }); afterAll(() => { - delete process.env.TAILOR_SDK_OUTPUT_DIR; + delete process.env.TAILOR_BUILD_OUTPUT_DIR; try { fs.rmSync(TEST_BUNDLER_BASE, { recursive: true, force: true }); } catch { @@ -63,7 +63,7 @@ describe("query-bundler", () => { }); test("writes entry files to query output directory (bundle output is in-memory only)", async () => { - const outputDir = path.join(process.env.TAILOR_SDK_OUTPUT_DIR!, "query"); + const outputDir = path.join(process.env.TAILOR_BUILD_OUTPUT_DIR!, "query"); await bundleQueryScript("sql"); await bundleQueryScript("gql"); diff --git a/packages/sdk/src/cli/cache/types.ts b/packages/sdk/src/cli/cache/types.ts index 2ecf7e6038..4ff752b683 100644 --- a/packages/sdk/src/cli/cache/types.ts +++ b/packages/sdk/src/cli/cache/types.ts @@ -1,10 +1,12 @@ import { z } from "zod"; +// strip unknown keys const cacheOutputFileSchema = z.object({ outputPath: z.string(), contentHash: z.string(), }); +// strip unknown keys const cacheEntrySchema = z.object({ kind: z.literal("bundle"), inputHash: z.string(), @@ -13,6 +15,7 @@ const cacheEntrySchema = z.object({ createdAt: z.string(), }); +// strip unknown keys const cacheManifestSchema = z.object({ version: z.literal(1), sdkVersion: z.string(), diff --git a/packages/sdk/src/cli/commands/api/index.ts b/packages/sdk/src/cli/commands/api/index.ts index b92d7537cf..53fe2035a9 100644 --- a/packages/sdk/src/cli/commands/api/index.ts +++ b/packages/sdk/src/cli/commands/api/index.ts @@ -182,7 +182,7 @@ const fieldArg = z.string().transform((val, ctx): ParsedField => { export const apiCommand = defineAppCommand({ name: "api", description: "Call Tailor Platform API endpoints directly.", - notes: `Use \`tailor-sdk api list\` to enumerate invocable methods and \`tailor-sdk api inspect \` to print an endpoint's input message tree (combine with \`--json\` for machine-readable output). + notes: `Use \`tailor api list\` to enumerate invocable methods and \`tailor api inspect \` to print an endpoint's input message tree (combine with \`--json\` for machine-readable output). The request body is inferred from the target endpoint's request schema, and commonly required fields are auto-injected so they can be omitted from \`--body\`: @@ -216,36 +216,34 @@ Use \`--field key=value\` (repeatable) to set request body fields without writin list: listCommand, inspect: inspectCommand, }, - args: z - .object({ - ...workspaceArgs, - ...configArg, - body: arg(z.string().default("{}"), { - alias: "b", - description: "Request body as JSON.", - }), - field: arg(fieldArg.array().optional(), { - alias: "f", - description: - "Set a body field as `key=value` (repeatable; dotted keys nest). Overrides --body.", - completion: { - custom: { - expand: { - dependsOn: ["endpoint"], - enumerate: ({ endpoint }) => - enumerateAllFieldCompletions(extractMethodName(endpoint ?? "")), - }, + args: z.strictObject({ + ...workspaceArgs, + ...configArg, + body: arg(z.string().default("{}"), { + alias: "b", + description: "Request body as JSON.", + }), + field: arg(fieldArg.array().optional(), { + alias: "f", + description: + "Set a body field as `key=value` (repeatable; dotted keys nest). Overrides --body.", + completion: { + custom: { + expand: { + dependsOn: ["endpoint"], + enumerate: ({ endpoint }) => + enumerateAllFieldCompletions(extractMethodName(endpoint ?? "")), }, }, - }), - endpoint: arg(z.string(), { - positional: true, - description: - "API endpoint to call (e.g., 'GetApplication' or 'tailor.v1.OperatorService/GetApplication').", - completion: { custom: { choices: listMethodChoices() } }, - }), - }) - .strict(), + }, + }), + endpoint: arg(z.string(), { + positional: true, + description: + "API endpoint to call (e.g., 'GetApplication' or 'tailor.v1.OperatorService/GetApplication').", + completion: { custom: { choices: listMethodChoices() } }, + }), + }), run: async (args) => { // Direct API calls can target any OperatorService method, including // Create/Update/Delete. Block all of them under a readonly profile rather diff --git a/packages/sdk/src/cli/commands/api/inspect.ts b/packages/sdk/src/cli/commands/api/inspect.ts index bcbbe7fe2b..76ebd3aea4 100644 --- a/packages/sdk/src/cli/commands/api/inspect.ts +++ b/packages/sdk/src/cli/commands/api/inspect.ts @@ -10,7 +10,7 @@ export const inspectCommand = defineAppCommand({ name: "inspect", description: "Print the input message tree of an OperatorService endpoint.", notes: - "Combine with the global `--json` flag for a machine-readable descriptor. Recursive type references and `oneof` membership are annotated. Use `tailor-sdk api list` to discover endpoint names.", + "Combine with the global `--json` flag for a machine-readable descriptor. Recursive type references and `oneof` membership are annotated. Use `tailor api list` to discover endpoint names.", examples: [ { cmd: "GetApplication", desc: "Show fields of GetApplicationRequest." }, { @@ -18,23 +18,21 @@ export const inspectCommand = defineAppCommand({ desc: "Inspect a deeply nested input with `(oneof config)` annotations.", }, ], - args: z - .object({ - endpoint: arg(z.string(), { - positional: true, - description: - "API endpoint to inspect (e.g., 'GetApplication' or 'tailor.v1.OperatorService/GetApplication').", - completion: { custom: { choices: listMethodNames() } }, - }), - }) - .strict(), + args: z.strictObject({ + endpoint: arg(z.string(), { + positional: true, + description: + "API endpoint to inspect (e.g., 'GetApplication' or 'tailor.v1.OperatorService/GetApplication').", + completion: { custom: { choices: listMethodNames() } }, + }), + }), run: (args) => { const methodName = extractMethodName(args.endpoint); const method = getMethodDescriptor(methodName); if (!method) { throw CLIError({ message: `unknown method: ${methodName}`, - suggestion: "Run `tailor-sdk api list` to see available methods.", + suggestion: "Run `tailor api list` to see available methods.", command: "api inspect", }); } diff --git a/packages/sdk/src/cli/commands/api/list.ts b/packages/sdk/src/cli/commands/api/list.ts index c4cc5c464b..e8f370e30a 100644 --- a/packages/sdk/src/cli/commands/api/list.ts +++ b/packages/sdk/src/cli/commands/api/list.ts @@ -8,7 +8,7 @@ export const listCommand = defineAppCommand({ description: "List all invocable OperatorService methods.", notes: "Only single-request (non-streaming) methods are listed, because the CLI issues a single JSON request and reads one JSON response.", - args: z.object({}).strict(), + args: z.strictObject({}), run: () => { const names = listMethodNames(); if (logger.jsonMode) { diff --git a/packages/sdk/src/cli/commands/api/proto-reflect.ts b/packages/sdk/src/cli/commands/api/proto-reflect.ts index 1ab5bf0210..906714d387 100644 --- a/packages/sdk/src/cli/commands/api/proto-reflect.ts +++ b/packages/sdk/src/cli/commands/api/proto-reflect.ts @@ -2,7 +2,7 @@ import { ScalarType } from "@bufbuild/protobuf"; import { OperatorService } from "@tailor-platform/tailor-proto/service_pb"; import type { DescField, DescMessage, DescMethodUnary } from "@bufbuild/protobuf"; -// `tailor-sdk api` issues a single JSON POST and reads one JSON response, so +// `tailor api` issues a single JSON POST and reads one JSON response, so // only unary RPCs can be invoked. Streaming methods are filtered out of all // discovery surfaces (`api list`, `api inspect`). `OperatorService.methods` // is invariant at runtime, so we filter once and reuse — completion-script diff --git a/packages/sdk/src/cli/commands/auth/index.ts b/packages/sdk/src/cli/commands/auth/index.ts new file mode 100644 index 0000000000..34d32fb148 --- /dev/null +++ b/packages/sdk/src/cli/commands/auth/index.ts @@ -0,0 +1,10 @@ +import { defineCommand } from "politty"; +import { tokenCommand } from "./token"; + +export const authCommand = defineCommand({ + name: "auth", + description: "Authentication helpers for scripts and plugins.", + subCommands: { + token: tokenCommand, + }, +}); diff --git a/packages/sdk/src/cli/commands/auth/token.ts b/packages/sdk/src/cli/commands/auth/token.ts new file mode 100644 index 0000000000..f0af9cea15 --- /dev/null +++ b/packages/sdk/src/cli/commands/auth/token.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; +import { workspaceArgs } from "#/cli/shared/args"; +import { defineAppCommand } from "#/cli/shared/command"; +import { loadAccessToken } from "#/cli/shared/context"; +import { logger } from "#/cli/shared/logger"; + +export const tokenCommand = defineAppCommand({ + name: "token", + description: + "Print a valid Tailor Platform access token to stdout, refreshing it first if expired.", + args: z.strictObject({ + profile: workspaceArgs.profile, + }), + run: async ({ profile }) => { + const token = await loadAccessToken({ profile }); + logger.out(token); + }, +}); diff --git a/packages/sdk/src/cli/commands/authconnection/authorize.ts b/packages/sdk/src/cli/commands/authconnection/authorize.ts index 80deb76346..c296091e17 100644 --- a/packages/sdk/src/cli/commands/authconnection/authorize.ts +++ b/packages/sdk/src/cli/commands/authconnection/authorize.ts @@ -38,27 +38,21 @@ function randomState() { export const authorizeAuthConnectionCommand = defineAppCommand({ name: "authorize", description: "Authorize an auth connection via OAuth2 flow.", - args: z - .object({ - ...workspaceArgs, - ...connectionNameArgs, - scopes: z - .string() - .optional() - .default(defaultScopes) - .describe("OAuth2 scopes to request (comma-separated)"), - port: z.coerce - .number() - .optional() - .default(defaultPort) - .describe("Local callback server port"), - "no-browser": z - .boolean() - .optional() - .default(false) - .describe("Don't open browser automatically"), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...connectionNameArgs, + scopes: z + .string() + .optional() + .default(defaultScopes) + .describe("OAuth2 scopes to request (comma-separated)"), + port: z.coerce.number().optional().default(defaultPort).describe("Local callback server port"), + "no-browser": z + .boolean() + .optional() + .default(false) + .describe("Don't open browser automatically"), + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ @@ -185,7 +179,7 @@ export const authorizeAuthConnectionCommand = defineAppCommand({ logger.warn( `Could not start the local callback server on port ${args.port}${code ? ` (${code})` : ""}.\n` + `${portHint}\n` + - ` tailor-sdk authconnection open`, + ` tailor authconnection open`, ); reject(err); }); @@ -199,7 +193,7 @@ export const authorizeAuthConnectionCommand = defineAppCommand({ ); logger.info( `If this flow doesn't complete, you can authorize via the Console instead:\n` + - ` tailor-sdk authconnection open`, + ` tailor authconnection open`, ); if (!args["no-browser"]) { try { diff --git a/packages/sdk/src/cli/commands/authconnection/delete.ts b/packages/sdk/src/cli/commands/authconnection/delete.ts index 67887408bb..94245b842f 100644 --- a/packages/sdk/src/cli/commands/authconnection/delete.ts +++ b/packages/sdk/src/cli/commands/authconnection/delete.ts @@ -12,13 +12,11 @@ import { connectionNameArgs } from "./args"; export const deleteAuthConnectionCommand = defineAppCommand({ name: "delete", description: "Delete an auth connection entirely.", - args: z - .object({ - ...workspaceArgs, - ...connectionNameArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...connectionNameArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/authconnection/list.ts b/packages/sdk/src/cli/commands/authconnection/list.ts index d260e2ae43..8495a6768a 100644 --- a/packages/sdk/src/cli/commands/authconnection/list.ts +++ b/packages/sdk/src/cli/commands/authconnection/list.ts @@ -36,7 +36,7 @@ function connectionInfo(connection: AuthConnection): ConnectionInfo { export const listAuthConnectionCommand = defineAppCommand({ name: "list", description: "List all auth connections.", - args: z.object({ ...workspaceArgs, ...paginationArgs() }).strict(), + args: z.strictObject({ ...workspaceArgs, ...paginationArgs() }), run: async (args) => { const accessToken = await loadAccessToken({ profile: args.profile, diff --git a/packages/sdk/src/cli/commands/authconnection/open.ts b/packages/sdk/src/cli/commands/authconnection/open.ts index d5d4d23f8e..80e859e334 100644 --- a/packages/sdk/src/cli/commands/authconnection/open.ts +++ b/packages/sdk/src/cli/commands/authconnection/open.ts @@ -8,7 +8,7 @@ import { logger } from "#/cli/shared/logger"; export const openAuthConnectionCommand = defineAppCommand({ name: "open", description: "Open the auth connections page in the Tailor Platform Console.", - args: z.object({ ...workspaceArgs }).strict(), + args: z.strictObject({ ...workspaceArgs }), run: async (args) => { const workspaceId = await loadWorkspaceId({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/authconnection/revoke.ts b/packages/sdk/src/cli/commands/authconnection/revoke.ts index 7d84867919..70213a6bd6 100644 --- a/packages/sdk/src/cli/commands/authconnection/revoke.ts +++ b/packages/sdk/src/cli/commands/authconnection/revoke.ts @@ -15,13 +15,11 @@ export const revokeAuthConnectionCommand = defineAppCommand({ "Revoke an auth connection's tokens (keeps the connection; use 'delete' to remove it).", notes: "Revoke invalidates the connection's active session and tokens but keeps the connection and its stored credentials, so it can be re-authorized later. Use `delete` to remove the connection entirely.", - args: z - .object({ - ...workspaceArgs, - ...connectionNameArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...connectionNameArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/crashreport/index.ts b/packages/sdk/src/cli/commands/crashreport/index.ts index 2a405e78e7..dc68be178a 100644 --- a/packages/sdk/src/cli/commands/crashreport/index.ts +++ b/packages/sdk/src/cli/commands/crashreport/index.ts @@ -4,7 +4,6 @@ import { sendCommand } from "./send"; export const crashReportCommand = defineCommand({ name: "crashreport", - aliases: ["crash-report"], description: "Manage crash reports.", subCommands: { list: listCommand, diff --git a/packages/sdk/src/cli/commands/crashreport/list.ts b/packages/sdk/src/cli/commands/crashreport/list.ts index 3d7d0c03b7..6d307efcbd 100644 --- a/packages/sdk/src/cli/commands/crashreport/list.ts +++ b/packages/sdk/src/cli/commands/crashreport/list.ts @@ -26,11 +26,9 @@ function formatCrashReportFiles(files: string[], localDir: string) { export const listCommand = defineAppCommand({ name: "list", description: "List local crash report files.", - args: z - .object({ - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...paginationArgs(), + }), run: async (args) => { const config = parseCrashReportConfig(); const jsonOutput = logger.jsonMode; diff --git a/packages/sdk/src/cli/commands/crashreport/send.test.ts b/packages/sdk/src/cli/commands/crashreport/send.test.ts index 37fd42904a..c3ee428985 100644 --- a/packages/sdk/src/cli/commands/crashreport/send.test.ts +++ b/packages/sdk/src/cli/commands/crashreport/send.test.ts @@ -16,7 +16,7 @@ function makeCrashReport(overrides?: Partial): CrashReport { osRelease: "25.3.0", arch: "arm64", command: "apply", - argv: ["node", "tailor-sdk", "apply"], + argv: ["node", "tailor", "apply"], errorName: "TypeError", errorMessage: "Cannot read properties of undefined", stackTrace: diff --git a/packages/sdk/src/cli/commands/crashreport/send.ts b/packages/sdk/src/cli/commands/crashreport/send.ts index 44a08b1125..18c1240ecf 100644 --- a/packages/sdk/src/cli/commands/crashreport/send.ts +++ b/packages/sdk/src/cli/commands/crashreport/send.ts @@ -11,15 +11,13 @@ import type { CrashReport } from "#/cli/crashreport/report"; export const sendCommand = defineAppCommand({ name: "send", description: "Submit a crash report to help improve the SDK.", - args: z - .object({ - file: arg(z.string(), { - description: "Path to the crash report file", - required: true, - completion: { type: "file", extensions: ["log"] }, - }), - }) - .strict(), + args: z.strictObject({ + file: arg(z.string(), { + description: "Path to the crash report file", + required: true, + completion: { type: "file", extensions: ["log"] }, + }), + }), run: async (args) => { let content: string; try { diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/integration.test.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/integration.test.ts index c71492038c..415aa6c47a 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/integration.test.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/integration.test.ts @@ -62,7 +62,7 @@ describe("deploy command integration tests", () => { }, 120000); afterAll(() => { - delete process.env.TAILOR_SDK_OUTPUT_DIR; + delete process.env.TAILOR_BUILD_OUTPUT_DIR; vi.useRealTimers(); }); diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/prepare.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/prepare.ts index 3fb31169e2..e115a54a04 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/prepare.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/prepare.ts @@ -25,7 +25,7 @@ export async function prepareFixtures(): Promise<{ const configPath = path.join(fixtureDir, "tailor.config.ts"); - process.env.TAILOR_SDK_OUTPUT_DIR = outputDir; + process.env.TAILOR_BUILD_OUTPUT_DIR = outputDir; // Generate plugin output (db.ts, enums.ts) await generate({ configPath }); diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/add.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/add.ts index 2656931ca6..f3a0460b70 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/add.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/add.ts @@ -1,9 +1,9 @@ import { createResolver, t } from "@tailor-platform/sdk"; -const validators: [(a: { value: number }) => boolean, string][] = [ - [({ value }) => value >= 0, "Value must be non-negative"], - [({ value }) => value < 10, "Value must be less than 10"], -]; +const validators = [ + ({ value }: { value: number }) => (value >= 0 ? undefined : "Value must be non-negative"), + ({ value }: { value: number }) => (value < 10 ? undefined : "Value must be less than 10"), +] as const; export default createResolver({ name: "add", description: "Addition operation", diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/showInfo.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/showInfo.ts index efac352193..0407f7fe1d 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/showInfo.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/resolvers/showInfo.ts @@ -6,12 +6,12 @@ export default createResolver({ operation: "query", body: (context) => { return { - id: context.user.id, - type: context.user.type, - workspaceId: context.user.workspaceId, + id: context.caller?.id ?? "", + type: context.caller?.type ?? "", + workspaceId: context.caller?.workspaceId ?? "", // platform response may omit the field // oxlint-disable-next-line typescript/no-unnecessary-condition - role: (context.user.attributes?.role as string) ?? "ADMIN", + role: (context.caller?.attributes.role as string | undefined) ?? "ADMIN", }; }, output: t diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.generators-compat.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.generators-compat.ts deleted file mode 100644 index fcf2c1a1b3..0000000000 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.generators-compat.ts +++ /dev/null @@ -1,14 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -- test fixture runs as standalone config, needs node:path -import * as path from "node:path"; -import * as url from "node:url"; -import { defineGenerators } from "@tailor-platform/sdk"; -import config from "./tailor.config"; - -const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); -const outDir = path.join(__dirname, "generators-compat-out"); - -export default config; -export const generators = defineGenerators( - ["@tailor-platform/kysely-type", { distPath: path.join(outDir, "db.ts") }], - ["@tailor-platform/enum-constants", { distPath: path.join(outDir, "enums.ts") }], -); diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.ts index 68bd0ce06f..3bec3f5ac6 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/tailor.config.ts @@ -6,7 +6,7 @@ import { enumConstantsPlugin } from "@tailor-platform/sdk/plugin/enum-constants" import { kyselyTypePlugin } from "@tailor-platform/sdk/plugin/kysely-type"; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); -const outDir = process.env.TAILOR_SDK_OUTPUT_DIR ?? path.join(__dirname, "dist"); +const outDir = process.env.TAILOR_BUILD_OUTPUT_DIR ?? path.join(__dirname, "dist"); export default defineConfig({ // SDK-managed app id — do not edit, except when copying this config to a separate app. diff --git a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/workflows/processOrder.ts b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/workflows/processOrder.ts index 7bd682c829..69b24d1697 100644 --- a/packages/sdk/src/cli/commands/deploy/__test_fixtures__/workflows/processOrder.ts +++ b/packages/sdk/src/cli/commands/deploy/__test_fixtures__/workflows/processOrder.ts @@ -3,14 +3,14 @@ import { format } from "date-fns"; export const fetchDetails = createWorkflowJob({ name: "fetch-details", - body: async (input: { orderId: string }) => { + body: (input: { orderId: string }) => { return { orderId: input.orderId, status: "found" }; }, }); export const notifyUser = createWorkflowJob({ name: "notify-user", - body: async (_input: { message: string; recipient: string }) => { + body: (_input: { message: string; recipient: string }) => { const timestamp = format(new Date(), "yyyy-MM-dd HH:mm:ss"); return { sent: true, timestamp }; }, @@ -18,15 +18,15 @@ export const notifyUser = createWorkflowJob({ export const processOrder = createWorkflowJob({ name: "process-order", - body: async (input: { orderId: string; userEmail: string }) => { - const details = await fetchDetails.trigger({ orderId: input.orderId }); + body: (input: { orderId: string; userEmail: string }) => { + const details = fetchDetails.trigger({ orderId: input.orderId }); // trigger return may be undefined when the upstream job produces no value // oxlint-disable-next-line typescript/no-unnecessary-condition if (!details) { throw new Error(`Order ${input.orderId} not found`); } - const notification = await notifyUser.trigger({ + const notification = notifyUser.trigger({ message: `Order ${input.orderId} processed`, recipient: input.userEmail, }); diff --git a/packages/sdk/src/cli/commands/deploy/auth-connection.test.ts b/packages/sdk/src/cli/commands/deploy/auth-connection.test.ts index 62fd896640..145315bcfd 100644 --- a/packages/sdk/src/cli/commands/deploy/auth-connection.test.ts +++ b/packages/sdk/src/cli/commands/deploy/auth-connection.test.ts @@ -259,7 +259,7 @@ describe("applyAuthConnections", () => { await applyAuthConnections(client, result, "create-update"); expect(mockLoggerInfo).toHaveBeenCalledWith( - expect.stringContaining("tailor-sdk authconnection authorize --name new-conn"), + expect.stringContaining("tailor authconnection authorize --name new-conn"), ); }); @@ -352,7 +352,7 @@ describe("applyAuthConnections", () => { await applyAuthConnections(client, result, "create-update"); expect(mockLoggerWarn).toHaveBeenCalledWith( - expect.stringContaining("tailor-sdk authconnection authorize --name conn"), + expect.stringContaining("tailor authconnection authorize --name conn"), ); }); }); diff --git a/packages/sdk/src/cli/commands/deploy/auth-connection.ts b/packages/sdk/src/cli/commands/deploy/auth-connection.ts index 726157e182..502ce49942 100644 --- a/packages/sdk/src/cli/commands/deploy/auth-connection.ts +++ b/packages/sdk/src/cli/commands/deploy/auth-connection.ts @@ -259,8 +259,8 @@ export async function applyAuthConnections( await client.setMetadata(create.metaRequest); logger.info( `Connection "${create.name}" was created. Authorize it with:\n` + - ` tailor-sdk authconnection authorize --name ${create.name}\n` + - `Or via the Console: tailor-sdk authconnection open`, + ` tailor authconnection authorize --name ${create.name}\n` + + `Or via the Console: tailor authconnection open`, ); }), ); @@ -270,8 +270,8 @@ export async function applyAuthConnections( if (resp.connection?.status === AuthConnection_Status.UNAUTHORIZED) { logger.warn( `Connection "${replace.name}" requires re-authorization. Authorize with:\n` + - ` tailor-sdk authconnection authorize --name ${replace.name}\n` + - `Or via the Console: tailor-sdk authconnection open`, + ` tailor authconnection authorize --name ${replace.name}\n` + + `Or via the Console: tailor authconnection open`, ); } await client.setMetadata(replace.metaRequest); diff --git a/packages/sdk/src/cli/commands/deploy/auth-invoker.test.ts b/packages/sdk/src/cli/commands/deploy/auth-invoker.test.ts deleted file mode 100644 index b79b5d4e95..0000000000 --- a/packages/sdk/src/cli/commands/deploy/auth-invoker.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { normalizeAuthInvoker } from "./auth-invoker"; - -describe("normalizeAuthInvoker", () => { - test("returns undefined when authInvoker is undefined", () => { - expect(normalizeAuthInvoker(undefined, "my-auth", "ctx")).toBeUndefined(); - }); - - test("expands a string to the object form using authNamespace", () => { - expect(normalizeAuthInvoker("kiosk", "my-auth", "ctx")).toEqual({ - namespace: "my-auth", - machineUserName: "kiosk", - }); - }); - - test("passes through object form unchanged", () => { - expect( - normalizeAuthInvoker({ namespace: "other-auth", machineUserName: "kiosk" }, "my-auth", "ctx"), - ).toEqual({ - namespace: "other-auth", - machineUserName: "kiosk", - }); - }); - - test("throws when string authInvoker is given without an authNamespace", () => { - expect(() => normalizeAuthInvoker("kiosk", undefined, 'Resolver "foo"')).toThrow( - /Resolver "foo".*no Auth service is configured/, - ); - }); -}); diff --git a/packages/sdk/src/cli/commands/deploy/auth-invoker.ts b/packages/sdk/src/cli/commands/deploy/auth-invoker.ts deleted file mode 100644 index 6a494dcb40..0000000000 --- a/packages/sdk/src/cli/commands/deploy/auth-invoker.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { AuthInvoker } from "#/types/auth.generated"; - -/** - * Normalize an authInvoker value to the object form required by the proto payload. - * - * Accepts either: - * - `undefined` — returns undefined - * - a plain string (machine user name) — expands to `{ namespace, machineUserName }` using `authNamespace` - * - an object `{ namespace, machineUserName }` — returned as-is - * @param authInvoker - String machine user name or object form - * @param authNamespace - Auth service namespace (required when authInvoker is a string) - * @param context - Contextual label used in error messages (e.g. `resolver "foo"`) - * @returns Object form of auth invoker, or undefined - */ -export function normalizeAuthInvoker( - authInvoker: string | AuthInvoker | undefined, - authNamespace: string | undefined, - context: string, -): { namespace: string; machineUserName: string } | undefined { - if (authInvoker === undefined) return undefined; - if (typeof authInvoker === "string") { - if (!authNamespace) { - throw new Error( - `${context} uses a string authInvoker ("${authInvoker}"), but no Auth service is configured. ` + - `Configure an Auth service or use the object form { namespace, machineUserName }.`, - ); - } - return { namespace: authNamespace, machineUserName: authInvoker }; - } - return authInvoker; -} diff --git a/packages/sdk/src/cli/commands/deploy/config-id-ci-guard.test.ts b/packages/sdk/src/cli/commands/deploy/config-id-ci-guard.test.ts index e8beaae77e..025029a0f3 100644 --- a/packages/sdk/src/cli/commands/deploy/config-id-ci-guard.test.ts +++ b/packages/sdk/src/cli/commands/deploy/config-id-ci-guard.test.ts @@ -46,7 +46,7 @@ describe("ensureConfigIdForDeploy", () => { const { ensureConfigIdForDeploy } = await load(true); await expect( ensureConfigIdForDeploy({ configPath: filePath, dryRun: false, buildOnly: false }), - ).rejects.toThrow(/missing an 'id'|tailor-sdk setup|apply/); + ).rejects.toThrow(/missing an 'id'|tailor setup|deploy/); // Must not have injected anything in CI. expect(await fs.promises.readFile(filePath, "utf-8")).toBe(configWithoutId); }); @@ -100,8 +100,8 @@ export default defineConfig({ ).rejects.toThrow(/must be a UUID/); }); - test("CI + missing id + TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION: injects an id", async () => { - vi.stubEnv("TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION", "true"); + test("CI + missing id + TAILOR_CI_ALLOW_ID_INJECTION: injects an id", async () => { + vi.stubEnv("TAILOR_CI_ALLOW_ID_INJECTION", "true"); const filePath = await writeConfig(configWithoutId); const { ensureConfigIdForDeploy } = await load(true); await ensureConfigIdForDeploy({ configPath: filePath, dryRun: false, buildOnly: false }); diff --git a/packages/sdk/src/cli/commands/deploy/config-id-injector.ts b/packages/sdk/src/cli/commands/deploy/config-id-injector.ts index d0ced400ee..4065acaf9e 100644 --- a/packages/sdk/src/cli/commands/deploy/config-id-injector.ts +++ b/packages/sdk/src/cli/commands/deploy/config-id-injector.ts @@ -169,7 +169,7 @@ async function assertConfigIdInCI(configPath: string): Promise { throw new Error( `tailor.config.ts is missing an 'id'. CI does not auto-generate one ` + `(each run would be treated as a separate app and break resource ownership). ` + - `Run 'tailor-sdk setup' or 'tailor-sdk apply' locally and commit the injected id.`, + `Run 'tailor setup' or 'tailor deploy' locally and commit the injected id.`, ); } // Keep CI and local behavior aligned: ensureConfigId() enforces the same @@ -190,7 +190,7 @@ async function assertConfigIdInCI(configPath: string): Promise { * ownership. CI dry-runs (plan) perform the same check read-only, so a * forgotten id fails at PR time instead of at deploy. Ephemeral pipelines that * intentionally deploy a fresh app per run (such as e2e harnesses) can opt - * back into injection with `TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION=true`. + * back into injection with `TAILOR_CI_ALLOW_ID_INJECTION=true`. * Local dry-run and build-only flows skip both injection and the check (no * on-disk side effects are expected, and build-only never talks to the * platform). @@ -207,8 +207,7 @@ export async function ensureConfigIdForDeploy(obj: { const { configPath, dryRun, buildOnly } = obj; if (buildOnly) return; - const allowCIInjection = - parseBoolean(process.env.TAILOR_PLATFORM_SDK_ALLOW_CI_ID_INJECTION) === true; + const allowCIInjection = parseBoolean(process.env.TAILOR_CI_ALLOW_ID_INJECTION) === true; const strictCI = isCI && !allowCIInjection; if (dryRun) { diff --git a/packages/sdk/src/cli/commands/deploy/confirm.ts b/packages/sdk/src/cli/commands/deploy/confirm.ts index a805f970ff..2159c59a3c 100644 --- a/packages/sdk/src/cli/commands/deploy/confirm.ts +++ b/packages/sdk/src/cli/commands/deploy/confirm.ts @@ -118,7 +118,7 @@ async function confirmNameMismatch( } /** - * Confirm allowing tailor-sdk to manage previously unmanaged resources. + * Confirm allowing tailor to manage previously unmanaged resources. * @param resources - Unmanaged resources * @param appName - Target application name * @param yes - Whether to auto-confirm without prompting @@ -131,7 +131,7 @@ export async function confirmUnmanagedResources( ): Promise { if (resources.length === 0) return; - logger.warn("Existing resources not tracked by tailor-sdk were found:"); + logger.warn("Existing resources not tracked by tailor were found:"); logger.log(` ${styles.info("Resources")}:`); for (const r of resources) { @@ -139,7 +139,7 @@ export async function confirmUnmanagedResources( } logger.newline(); logger.log(" These resources may have been created by older SDK versions, Terraform, or CUE."); - logger.log(" To continue, confirm that tailor-sdk should manage them."); + logger.log(" To continue, confirm that tailor should manage them."); logger.log( " If they are managed by another tool (e.g., Terraform), cancel and manage them there instead.", ); @@ -152,7 +152,7 @@ export async function confirmUnmanagedResources( } const confirmed = await prompt.confirm({ - message: `Allow tailor-sdk to manage these resources for "${appName}"?`, + message: `Allow tailor to manage these resources for "${appName}"?`, default: false, }); if (!confirmed) { diff --git a/packages/sdk/src/cli/commands/deploy/deploy.ts b/packages/sdk/src/cli/commands/deploy/deploy.ts index ca6c1d4df9..3378a4dda2 100644 --- a/packages/sdk/src/cli/commands/deploy/deploy.ts +++ b/packages/sdk/src/cli/commands/deploy/deploy.ts @@ -1755,7 +1755,7 @@ export async function deploy(options?: DeployOptions) { const { targets, buildOnly } = await withSpan("build", async () => { const dryRun = options?.dryRun ?? false; const buildOnly = - options?.buildOnly ?? parseBoolean(process.env.TAILOR_PLATFORM_SDK_BUILD_ONLY) === true; + options?.buildOnly ?? parseBoolean(process.env.TAILOR_DEPLOY_BUILD_ONLY) === true; const noCache = options?.noCache ?? false; const packageJson = await readPackageJson(); const cacheDir = path.resolve(getDistDir(), "cache"); @@ -1785,7 +1785,7 @@ export async function deploy(options?: DeployOptions) { assertUniqueGlobalResourceNames(targets); // Note: the normal apply path intentionally skips writing bundle files to - // .tailor-sdk/. Bundles are kept in memory and uploaded directly to the + // .tailor/. Bundles are kept in memory and uploaded directly to the // function registry. To test a function locally, use `function test-run` // with a .ts source file instead of a pre-bundled .js file. diff --git a/packages/sdk/src/cli/commands/deploy/executor.test.ts b/packages/sdk/src/cli/commands/deploy/executor.test.ts index 99192ee448..474f7f3ca2 100644 --- a/packages/sdk/src/cli/commands/deploy/executor.test.ts +++ b/packages/sdk/src/cli/commands/deploy/executor.test.ts @@ -16,7 +16,7 @@ vi.mock("node:fs", () => ({ // Mock dist-dir to avoid getDistDir issues vi.mock("#/cli/shared/dist-dir", () => ({ - getDistDir: vi.fn().mockReturnValue(".tailor-sdk"), + getDistDir: vi.fn().mockReturnValue(".tailor"), })); // Mock config values for tests @@ -145,6 +145,7 @@ describe("planExecutor", () => { resolverNames?: Record; idpNames?: ReadonlyArray; authName?: string; + externalAuthName?: string; }, ): Application { const tailorDBServices = Object.entries( @@ -165,7 +166,6 @@ describe("planExecutor", () => { return { name: appName, - config: {}, subgraphs: idpServices.map((idp) => ({ Type: "idp", Name: idp.name })), env: {}, executorService: createMockExecutorService(executors), @@ -173,6 +173,9 @@ describe("planExecutor", () => { resolverServices, idpServices, authService: options?.authName ? { config: { name: options.authName } } : undefined, + config: options?.externalAuthName + ? { auth: { name: options.externalAuthName, external: true } } + : {}, } as unknown as Application; } @@ -560,6 +563,46 @@ describe("planExecutor", () => { expect(variablesExpr).toContain("result: args.succeeded?.result.resolver"); expect(variablesExpr).toContain("error: args.failed?.error"); }); + + test("string invoker uses external auth config name", async () => { + const client = createMockClient([]); + const executor: Executor = { + name: "test-executor", + description: "Executor test-executor", + disabled: false, + trigger: { + kind: "schedule", + timezone: "UTC", + cron: "0 * * * *", + }, + operation: { + kind: "function", + body: () => {}, + invoker: "batch-user", + }, + }; + const application = createMockApplication([executor], { + externalAuthName: "external-auth", + }); + + const result = await planExecutor({ + client, + workspaceId, + application, + forRemoval: false, + config: mockConfig, + }); + + const create = result.changeSet.creates[0]!; + const targetConfig = create.request.executor?.targetConfig?.config as { + case: "function"; + value: { invoker: { namespace: string; machineUserName: string } }; + }; + expect(targetConfig.value.invoker).toEqual({ + namespace: "external-auth", + machineUserName: "batch-user", + }); + }); }); describe("typed event config", () => { diff --git a/packages/sdk/src/cli/commands/deploy/executor.ts b/packages/sdk/src/cli/commands/deploy/executor.ts index c8f203bdef..79603056f8 100644 --- a/packages/sdk/src/cli/commands/deploy/executor.ts +++ b/packages/sdk/src/cli/commands/deploy/executor.ts @@ -13,11 +13,11 @@ import { type ExecutorTriggerEventConfigSchema, ExecutorTriggerType, } from "@tailor-platform/tailor-proto/executor_resource_pb"; +import { getApplicationAuthNamespace } from "#/cli/shared/auth-namespace"; import { type OperatorClient } from "#/cli/shared/client"; import { buildExecutorArgsExpr } from "#/cli/shared/runtime-exprs"; import { stringifyFunction } from "#/parser/service/tailordb/index"; import { assertDefined } from "#/utils/assert"; -import { normalizeAuthInvoker } from "./auth-invoker"; import { createChangeSet, type ChangeSet } from "./change-set"; import { areNormalizedEqual, normalizeProtoConfig } from "./compare"; import { executorFunctionName } from "./function-registry"; @@ -26,6 +26,7 @@ import { type GroupedDisplayEntry, type RelatedFunctionRegistryChanges, } from "./grouped-display"; +import { normalizeInvoker } from "./invoker"; import { buildMetaRequest, hasMatchingSdkVersion, resourceTrn } from "./label"; import { fetchExistingResourcesWithLabels, @@ -478,7 +479,7 @@ function resolveIdpNamespace( } function resolveAuthNamespace(application: Readonly): string { - const authNamespace = application.authService?.config.name ?? application.config.auth?.name; + const authNamespace = getApplicationAuthNamespace(application); if (!authNamespace) { throw new Error("No Auth service configured"); } @@ -610,7 +611,7 @@ function protoExecutor( let targetType: ExecutorTargetType; let targetConfig: MessageInitShape; - const authNamespace = application.authService?.config.name; + const authNamespace = getApplicationAuthNamespace(application); const invokerContext = `Executor "${executor.name}"`; switch (target.kind) { @@ -666,7 +667,7 @@ function protoExecutor( expr: `(${stringifyFunction(target.variables)})(${argsExpr})`, } : undefined, - invoker: normalizeAuthInvoker(target.authInvoker, authNamespace, invokerContext), + invoker: normalizeInvoker(target.invoker, authNamespace, invokerContext), }, }, }; @@ -689,7 +690,7 @@ function protoExecutor( variables: { expr: argsExpr, }, - invoker: normalizeAuthInvoker(target.authInvoker, authNamespace, invokerContext), + invoker: normalizeInvoker(target.invoker, authNamespace, invokerContext), }, }, }; @@ -707,7 +708,7 @@ function protoExecutor( ? { expr: `(${stringifyFunction(target.args)})(${argsExpr})` } : { expr: JSON.stringify(target.args) } : undefined, - invoker: normalizeAuthInvoker(target.authInvoker, authNamespace, invokerContext), + invoker: normalizeInvoker(target.invoker, authNamespace, invokerContext), }, }, }; diff --git a/packages/sdk/src/cli/commands/deploy/index.test.ts b/packages/sdk/src/cli/commands/deploy/index.test.ts deleted file mode 100644 index 804beea5fc..0000000000 --- a/packages/sdk/src/cli/commands/deploy/index.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { deployCommand } from "#/cli/commands/deploy/index"; - -describe("deployCommand", () => { - test("exposes 'apply' as an alias", () => { - expect(deployCommand.aliases).toContain("apply"); - }); -}); diff --git a/packages/sdk/src/cli/commands/deploy/index.ts b/packages/sdk/src/cli/commands/deploy/index.ts index 032120b941..1c93151f87 100644 --- a/packages/sdk/src/cli/commands/deploy/index.ts +++ b/packages/sdk/src/cli/commands/deploy/index.ts @@ -7,31 +7,28 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; export const deployCommand = defineAppCommand({ name: "deploy", - aliases: ["apply"], description: "Deploy your application by applying the Tailor configuration.", - args: z - .object({ - ...workspaceArgs, - ...multiConfigArg, - ...confirmationArgs, - "dry-run": arg(z.boolean().optional(), { - alias: "d", - description: "Run the command without making any changes", - }), - "no-schema-check": arg(z.boolean().optional(), { - description: "Skip schema diff check against migration snapshots", - }), - "no-validate": arg(z.boolean().optional(), { - description: "Skip client-side validation against platform resource constraints", - }), - "no-cache": arg(z.boolean().optional(), { - description: "Disable bundle caching for this run", - }), - "clean-cache": arg(z.boolean().optional(), { - description: "Clean the bundle cache before building", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...multiConfigArg, + ...confirmationArgs, + "dry-run": arg(z.boolean().optional(), { + alias: "d", + description: "Run the command without making any changes", + }), + "no-schema-check": arg(z.boolean().optional(), { + description: "Skip schema diff check against migration snapshots", + }), + "no-validate": arg(z.boolean().optional(), { + description: "Skip client-side validation against platform resource constraints", + }), + "no-cache": arg(z.boolean().optional(), { + description: "Disable bundle caching for this run", + }), + "clean-cache": arg(z.boolean().optional(), { + description: "Clean the bundle cache before building", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); const { initTelemetry } = await import("#/cli/telemetry/index"); diff --git a/packages/sdk/src/cli/commands/deploy/invoker.test.ts b/packages/sdk/src/cli/commands/deploy/invoker.test.ts new file mode 100644 index 0000000000..0726d39219 --- /dev/null +++ b/packages/sdk/src/cli/commands/deploy/invoker.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; +import { normalizeInvoker } from "./invoker"; + +describe("normalizeInvoker", () => { + test("returns undefined when invoker is undefined", () => { + expect(normalizeInvoker(undefined, "my-auth", "ctx")).toBeUndefined(); + }); + + test("expands a string to the object form using authNamespace", () => { + expect(normalizeInvoker("kiosk", "my-auth", "ctx")).toEqual({ + namespace: "my-auth", + machineUserName: "kiosk", + }); + }); + + test("passes through object form unchanged", () => { + expect( + normalizeInvoker({ namespace: "other-auth", machineUserName: "kiosk" }, "my-auth", "ctx"), + ).toEqual({ + namespace: "other-auth", + machineUserName: "kiosk", + }); + }); + + test("throws when string invoker is given without an authNamespace", () => { + expect(() => normalizeInvoker("kiosk", undefined, 'Resolver "foo"')).toThrow( + /Resolver "foo".*Configure an Auth service before using invoker/, + ); + }); +}); diff --git a/packages/sdk/src/cli/commands/deploy/invoker.ts b/packages/sdk/src/cli/commands/deploy/invoker.ts new file mode 100644 index 0000000000..0109bc4eaa --- /dev/null +++ b/packages/sdk/src/cli/commands/deploy/invoker.ts @@ -0,0 +1,31 @@ +import type { AuthInvoker } from "#/types/auth.generated"; + +/** + * Normalize an invoker value to the object form required by the proto payload. + * + * Accepts either: + * - `undefined` — returns undefined + * - a plain string (machine user name) — expands to `{ namespace, machineUserName }` using `authNamespace` + * - an internal object `{ namespace, machineUserName }` — returned as-is + * @param invoker - String machine user name or internal object form + * @param authNamespace - Auth service namespace (required when invoker is a string) + * @param context - Contextual label used in error messages (e.g. `resolver "foo"`) + * @returns Object form of the invoker, or undefined + */ +export function normalizeInvoker( + invoker: string | AuthInvoker | undefined, + authNamespace: string | undefined, + context: string, +): { namespace: string; machineUserName: string } | undefined { + if (invoker === undefined) return undefined; + if (typeof invoker === "string") { + if (!authNamespace) { + throw new Error( + `${context} uses a string invoker ("${invoker}"), but no Auth service is configured. ` + + `Configure an Auth service before using invoker.`, + ); + } + return { namespace: authNamespace, machineUserName: invoker }; + } + return invoker; +} diff --git a/packages/sdk/src/cli/commands/deploy/resolver.test.ts b/packages/sdk/src/cli/commands/deploy/resolver.test.ts index e41cb20717..84f9d0d44e 100644 --- a/packages/sdk/src/cli/commands/deploy/resolver.test.ts +++ b/packages/sdk/src/cli/commands/deploy/resolver.test.ts @@ -345,13 +345,13 @@ describe("planPipeline (resolver service level)", () => { expect(result.changeSet.resolver.unchanged).toHaveLength(0); }); - test("resolver is updated when authInvoker differs", async () => { + test("resolver is updated when invoker differs", async () => { const pipeline = createPipeline({ name: "test-resolver", operation: 0, body: () => "hello", output: { type: "string", metadata: {} }, - authInvoker: { namespace: "my-auth", machineUserName: "batch-user" }, + invoker: { namespace: "my-auth", machineUserName: "batch-user" }, }); const desiredResolver = structuredClone(await getDesiredResolver(pipeline)); expect(desiredResolver).toBeDefined(); @@ -371,9 +371,9 @@ describe("planPipeline (resolver service level)", () => { }); }); -describe("processResolver authInvoker mapping", () => { +describe("processResolver invoker mapping", () => { function createResolverServiceWithAuthInvoker( - authInvoker?: Record | string, + invoker?: Record | string, ): ResolverService { return { namespace: "test-ns", @@ -384,7 +384,7 @@ describe("processResolver authInvoker mapping", () => { operation: "query", body: () => "hello", output: { type: "string", metadata: {}, fields: {} }, - ...(authInvoker !== undefined ? { authInvoker } : {}), + ...(invoker !== undefined ? { invoker } : {}), }, }, loadResolvers: vi.fn().mockResolvedValue(undefined), @@ -400,7 +400,7 @@ describe("processResolver authInvoker mapping", () => { return planPipeline(buildCtx({ client, application })); } - test("authInvoker is mapped to proto invoker field", async () => { + test("invoker is mapped to proto invoker field", async () => { const resolverService = createResolverServiceWithAuthInvoker({ namespace: "my-auth", machineUserName: "batch-user", @@ -418,7 +418,7 @@ describe("processResolver authInvoker mapping", () => { }); }); - test("string authInvoker is normalized using the configured auth service name", async () => { + test("string invoker is normalized using the configured auth service name", async () => { const resolverService = createResolverServiceWithAuthInvoker("batch-user"); const result = await planWithResolverService(resolverService, { @@ -435,7 +435,7 @@ describe("processResolver authInvoker mapping", () => { }); }); - test("string authInvoker without auth service configured throws", async () => { + test("string invoker without auth service configured throws", async () => { const resolverService = createResolverServiceWithAuthInvoker("batch-user"); await expect(planWithResolverService(resolverService)).rejects.toThrow( @@ -443,7 +443,7 @@ describe("processResolver authInvoker mapping", () => { ); }); - test("invoker is undefined when authInvoker is not set", async () => { + test("invoker is undefined when invoker is not set", async () => { const resolverService = createResolverServiceWithAuthInvoker(); const result = await planWithResolverService(resolverService); diff --git a/packages/sdk/src/cli/commands/deploy/resolver.ts b/packages/sdk/src/cli/commands/deploy/resolver.ts index 003e5c0a34..3c41cdb22f 100644 --- a/packages/sdk/src/cli/commands/deploy/resolver.ts +++ b/packages/sdk/src/cli/commands/deploy/resolver.ts @@ -17,10 +17,10 @@ import { } from "@tailor-platform/tailor-proto/pipeline_resource_pb"; import * as inflection from "inflection"; import { type ResolverService } from "#/cli/services/resolver/service"; +import { getApplicationAuthNamespace } from "#/cli/shared/auth-namespace"; import { fetchAll, type OperatorClient } from "#/cli/shared/client"; import { buildResolverOperationHookExpr } from "#/cli/shared/runtime-exprs"; import { assertDefined } from "#/utils/assert"; -import { normalizeAuthInvoker } from "./auth-invoker"; import { createChangeSet, type ChangeSet } from "./change-set"; import { areNormalizedEqual, normalizeProtoConfig } from "./compare"; import { resolverFunctionName } from "./function-registry"; @@ -29,6 +29,7 @@ import { type GroupedDisplayEntry, type RelatedFunctionRegistryChanges, } from "./grouped-display"; +import { normalizeInvoker } from "./invoker"; import { buildMetaRequest, hasMatchingSdkVersion, resourceTrn } from "./label"; import { fetchExistingResourcesWithLabels, @@ -136,7 +137,7 @@ export async function planPipeline(context: PlanContext) { context.executorUsedResolvers ?? new Set(), deletedServices, application.env, - application.authService?.config.name, + getApplicationAuthNamespace(application), forceApplyAll, ); @@ -560,11 +561,7 @@ function processResolver( expr: buildResolverOperationHookExpr(env), }, postScript: `args.body`, - invoker: normalizeAuthInvoker( - resolver.authInvoker, - authNamespace, - `Resolver "${resolver.name}"`, - ), + invoker: normalizeInvoker(resolver.invoker, authNamespace, `Resolver "${resolver.name}"`), }, ]; diff --git a/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts b/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts index 4758e86dfd..fce8d7850b 100644 --- a/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts +++ b/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts @@ -9,7 +9,7 @@ import { } from "./secrets-state"; vi.mock("#/cli/shared/dist-dir", () => ({ - getDistDir: () => "/tmp/tailor-sdk-test-secrets-state", + getDistDir: () => "/tmp/tailor-test-secrets-state", })); function removeStateFile(): void { diff --git a/packages/sdk/src/cli/commands/deploy/secrets-state.ts b/packages/sdk/src/cli/commands/deploy/secrets-state.ts index c1d18481dd..d75db88836 100644 --- a/packages/sdk/src/cli/commands/deploy/secrets-state.ts +++ b/packages/sdk/src/cli/commands/deploy/secrets-state.ts @@ -4,6 +4,7 @@ import * as path from "pathe"; import { z } from "zod"; import { getDistDir } from "#/cli/shared/dist-dir"; +// strip unknown keys const SecretsStateSchema = z.object({ vaults: z.record(z.string(), z.record(z.string(), z.string())), connections: z.record(z.string(), z.string()).optional(), diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts index 8c630a4083..9d721817d5 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.test.ts @@ -437,20 +437,22 @@ describe("planTailorDB (service level)", () => { const displayNameField = profileField?.fields?.displayName; const contactEmailField = profileField?.fields?.contact!.fields?.email; - expect(displayNameField?.validate).toHaveLength(1); - expect(displayNameField?.validate?.[0]?.errorMessage).toBe("Display name is required"); - expect(displayNameField?.validate?.[0]?.script?.expr).toContain( - "!((_value ?? '').length > 0)", - ); - expect(displayNameField?.hooks?.create?.expr).toBe("(_value ?? '').trim()"); - expect(displayNameField?.hooks?.update?.expr).toBe("(_value ?? '').trim()"); - - expect(contactEmailField?.validate).toHaveLength(1); - expect(contactEmailField?.validate?.[0]?.errorMessage).toBe("Email must contain @"); - expect(contactEmailField?.validate?.[0]?.script?.expr).toContain( - "!((_value ?? '').includes('@'))", - ); - expect(contactEmailField?.hooks?.create?.expr).toBe("(_value ?? '').toLowerCase()"); + expect(displayNameField?.optionalOnCreate).toBe(true); + expect(displayNameField?.hooks).toBeUndefined(); + expect(displayNameField?.validate ?? []).toHaveLength(0); + expect(contactEmailField?.optionalOnCreate).toBe(true); + expect(contactEmailField?.hooks).toBeUndefined(); + expect(contactEmailField?.validate ?? []).toHaveLength(0); + + const hookExpr = createdType?.schema?.typeHook?.create?.expr ?? ""; + expect(hookExpr).toContain('"profile": Object.assign({}, _input["profile"], {'); + expect(hookExpr).toContain("(_value ?? '').trim()"); + expect(hookExpr).toContain("(_value ?? '').toLowerCase()"); + + const validateExpr = createdType?.schema?.typeValidate?.create?.expr ?? ""; + expect(validateExpr).toContain('__errs["profile.displayName"]'); + expect(validateExpr).toContain('__errs["profile.contact.email"]'); + expect(validateExpr).toContain('if (typeof __r === "string")'); }); }); diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts index 6d8c6a5b90..2ed26dd604 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/index.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/index.ts @@ -408,7 +408,7 @@ async function validateAndDetectMigrations( logger.error("Schema changes detected that are not in migration files:"); logger.log(formatMigrationCheckResults(migrationResults)); logger.newline(); - logger.info("Run 'tailor-sdk tailordb migration generate' to create migration files."); + logger.info("Run 'tailor tailordb migration generate' to create migration files."); logger.info("Or use '--no-schema-check' to skip this check."); throw new Error("Schema migration check failed"); } @@ -433,7 +433,7 @@ async function validateAndDetectMigrations( logger.info(" - Migration history is out of sync", { mode: "plain" }); logger.newline(); logger.info("To resolve:"); - logger.info(" - Run 'tailor-sdk tailordb migration status' to compare local vs remote.", { + logger.info(" - Run 'tailor tailordb migration status' to compare local vs remote.", { mode: "plain", }); logger.info(" - If remote is correct, update local types and run 'migration generate'.", { @@ -705,7 +705,7 @@ export async function applyTailorDB( } } catch (error) { handleOptionalToRequiredError(error, [ - "Run 'tailor-sdk tailordb migration generate' to create migration files.", + "Run 'tailor tailordb migration generate' to create migration files.", "Migration scripts allow you to handle existing data before applying the schema change.", ]); } @@ -1556,7 +1556,7 @@ async function planServices( request: { workspaceId, namespaceName: tailordb.namespace, - // Set UTC to match tailorctl/terraform + // Keep generated TailorDB services aligned with Terraform defaults. defaultTimezone: "UTC", }, metaRequest, @@ -1758,6 +1758,8 @@ function normalizeComparableTailorDBType(type: unknown) { indexes?: Record; files?: Record; permission?: Record; + typeHook?: Record; + typeValidate?: Record; }; } | null; return normalizeTailorDBCompareValue( @@ -1771,6 +1773,10 @@ function normalizeComparableTailorDBType(type: unknown) { indexes: normalized?.schema?.indexes ?? {}, files: normalized?.schema?.files ?? {}, permission: normalized?.schema?.permission ?? {}, + // Hooks/validators are sent as type-level scripts; include them so a + // changed hook or validator is detected as an update. + typeHook: normalized?.schema?.typeHook ?? {}, + typeValidate: normalized?.schema?.typeValidate ?? {}, }, }, [], @@ -1785,13 +1791,17 @@ function normalizeTailorDBCompareValue( return value; } + if (typeof value === "boolean") { + if (path.at(-1) === "optionalOnCreate" && value === false) { + return undefined; + } + return value; + } + if (typeof value === "number" || typeof value === "bigint" || typeof value === "string") { if (matchesNumericStringPath(path) && isNumericLikeValue(value)) { return String(value); } - // Platform returns an empty string for `expr` (validate scripts) and field/type - // `description` when the SDK omitted them, while local manifests omit the key - // entirely. Treat the empty string as unset so it matches an omitted value. if ( (path.at(-1) === "expr" || path.at(-1) === "description") && value === tailordbCompareKnownDefaults.emptyExpression @@ -1802,9 +1812,16 @@ function normalizeTailorDBCompareValue( } if (Array.isArray(value)) { - return value + const items = value .map((item, index) => normalizeTailorDBCompareValue(item, [...path, index])) .filter((item) => item !== undefined); + // Field-level validators are no longer emitted by the SDK (they are aggregated + // into type-level type_validate). The platform still returns an empty `validate` + // array per field; treat it as unset so it matches the omitted local value. + if (items.length === 0 && path.at(-1) === "validate") { + return undefined; + } + return items; } if (!isPlainObject(value)) { @@ -2064,9 +2081,7 @@ function formatMigrationCheckResults(results: MigrationCheckResult[]): string { lines.push(`Namespace: ${result.namespace}`); if (!result.diff) { - lines.push( - " No migration snapshot found. Run 'tailor-sdk tailordb migration generate' first.", - ); + lines.push(" No migration snapshot found. Run 'tailor tailordb migration generate' first."); } else { lines.push(` ${formatDiffSummary(result.diff)}`); lines.push(""); diff --git a/packages/sdk/src/cli/commands/deploy/tailordb/migration.ts b/packages/sdk/src/cli/commands/deploy/tailordb/migration.ts index ad45a9a9c7..a127f5224e 100644 --- a/packages/sdk/src/cli/commands/deploy/tailordb/migration.ts +++ b/packages/sdk/src/cli/commands/deploy/tailordb/migration.ts @@ -37,7 +37,7 @@ import type { TailorDBServiceConfig } from "#/types/tailordb.generated"; export interface MigrationExecutionOptions { client: OperatorClient; workspaceId: string; - authInvoker: AuthInvoker; + invoker: AuthInvoker; env: Record; } @@ -177,7 +177,7 @@ async function executeSingleMigration( options: MigrationExecutionOptions, migration: PendingMigration, ): Promise { - const { client, workspaceId, authInvoker, env } = options; + const { client, workspaceId, invoker, env } = options; const migrationName = `migration-${migration.namespace}-${formatMigrationNumber(migration.number)}.js`; @@ -195,7 +195,7 @@ async function executeSingleMigration( workspaceId, name: migrationName, code: bundleResult.bundledCode, - invoker: authInvoker, + invoker, }); return { @@ -274,8 +274,7 @@ export async function executeMigrations( ); } - // Create authInvoker for this namespace - const authInvoker = create(AuthInvokerSchema, { + const invoker = create(AuthInvokerSchema, { namespace: context.authNamespace, machineUserName, }); @@ -283,7 +282,7 @@ export async function executeMigrations( const options: MigrationExecutionOptions = { client: context.client, workspaceId: context.workspaceId, - authInvoker, + invoker, env: context.env, }; diff --git a/packages/sdk/src/cli/commands/executor/get.ts b/packages/sdk/src/cli/commands/executor/get.ts index 4cfddb9ac0..f41894210d 100644 --- a/packages/sdk/src/cli/commands/executor/get.ts +++ b/packages/sdk/src/cli/commands/executor/get.ts @@ -93,12 +93,10 @@ export async function getExecutor( export const getCommand = defineAppCommand({ name: "get", description: "Get executor details", - args: z - .object({ - ...workspaceArgs, - ...nameArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...nameArgs, + }), run: async (args) => { const executor = await getExecutor({ name: args.name, diff --git a/packages/sdk/src/cli/commands/executor/jobs.ts b/packages/sdk/src/cli/commands/executor/jobs.ts index ca75add48e..bc33af85ca 100644 --- a/packages/sdk/src/cli/commands/executor/jobs.ts +++ b/packages/sdk/src/cli/commands/executor/jobs.ts @@ -739,48 +739,46 @@ export const jobsCommand = defineAppCommand({ desc: "Wait for job with logs", }, ], - args: z - .object({ - ...workspaceArgs, - "executor-name": arg(z.string(), { - positional: true, - description: "Executor name", - }), - "job-id": arg(z.string().optional(), { - positional: true, - description: "Job ID (if provided, shows job details)", - }), - status: arg(z.string().optional(), { - alias: "s", - description: - "Filter by status (PENDING, RUNNING, SUCCESS, FAILED, CANCELED) (list mode only)", - }), - attempts: arg(z.boolean().default(false), { - description: "Show job attempts (only with job ID) (detail mode only)", - }), - wait: arg(z.boolean().default(false), { - alias: "W", - description: - "Wait for job completion and downstream execution (workflow/function) if applicable (detail mode only)", - }), - interval: arg(durationArg.default("3s"), { - alias: "i", - description: "Polling interval when using --wait (e.g., '3s', '500ms', '1m')", - }), - timeout: arg(durationArg.default("5m"), { - alias: "t", - description: "Maximum time to wait when using --wait (e.g., '30s', '5m')", - }), - ...pagedLogArgs, - limit: arg(nonNegativeIntArg.default(50), { - description: "Maximum number of jobs to list (0: unlimited, default: 50) (list mode only)", - }), - logs: arg(z.boolean().default(false), { - alias: "l", - description: "Display function execution logs after completion (requires --wait)", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + "executor-name": arg(z.string(), { + positional: true, + description: "Executor name", + }), + "job-id": arg(z.string().optional(), { + positional: true, + description: "Job ID (if provided, shows job details)", + }), + status: arg(z.string().optional(), { + alias: "s", + description: + "Filter by status (PENDING, RUNNING, SUCCESS, FAILED, CANCELED) (list mode only)", + }), + attempts: arg(z.boolean().default(false), { + description: "Show job attempts (only with job ID) (detail mode only)", + }), + wait: arg(z.boolean().default(false), { + alias: "W", + description: + "Wait for job completion and downstream execution (workflow/function) if applicable (detail mode only)", + }), + interval: arg(durationArg.default("3s"), { + alias: "i", + description: "Polling interval when using --wait (e.g., '3s', '500ms', '1m')", + }), + timeout: arg(durationArg.default("5m"), { + alias: "t", + description: "Maximum time to wait when using --wait (e.g., '30s', '5m')", + }), + ...pagedLogArgs, + limit: arg(nonNegativeIntArg.default(50), { + description: "Maximum number of jobs to list (0: unlimited, default: 50) (list mode only)", + }), + logs: arg(z.boolean().default(false), { + alias: "l", + description: "Display function execution logs after completion (requires --wait)", + }), + }), run: async (args) => { const jsonOutput = logger.jsonMode || args.json; if (args.jobId) { diff --git a/packages/sdk/src/cli/commands/executor/list.ts b/packages/sdk/src/cli/commands/executor/list.ts index 9cf743074b..b6d96f985f 100644 --- a/packages/sdk/src/cli/commands/executor/list.ts +++ b/packages/sdk/src/cli/commands/executor/list.ts @@ -48,12 +48,10 @@ export async function listExecutors(options?: ListExecutorsOptions): Promise { const jsonOutput = logger.jsonMode; const executors = await listExecutors({ @@ -81,7 +79,7 @@ export const listCommand = defineAppCommand({ if (!jsonOutput) { const hasWebhook = executors.some((e) => e.triggerType === "webhook"); if (hasWebhook) { - logger.info("To see webhook URLs, run: tailor-sdk executor webhook list"); + logger.info("To see webhook URLs, run: tailor executor webhook list"); } } }, diff --git a/packages/sdk/src/cli/commands/executor/trigger.ts b/packages/sdk/src/cli/commands/executor/trigger.ts index ec36913f35..deecd3aa2c 100644 --- a/packages/sdk/src/cli/commands/executor/trigger.ts +++ b/packages/sdk/src/cli/commands/executor/trigger.ts @@ -184,41 +184,39 @@ The \`--logs\` option displays logs from the downstream execution when available { cmd: "my-executor -W", desc: "Trigger and wait for completion" }, { cmd: "my-executor -W -l", desc: "Trigger, wait, and show logs" }, ], - args: z - .object({ - ...workspaceArgs, - "executor-name": arg(z.string(), { - positional: true, - description: "Executor name", - }), - data: arg(jsonDataArg.optional(), { - alias: "d", - description: "Request body (JSON string)", - }), - header: arg(headerArg.array().optional(), { - alias: "H", - overrideBuiltinAlias: true, - description: "Request header (format: 'Key: Value', can be specified multiple times)", - }), - wait: arg(z.boolean().default(false), { - alias: "W", - description: - "Wait for job completion and downstream execution (workflow/function) if applicable", - }), - interval: arg(durationArg.default("3s"), { - alias: "i", - description: "Polling interval when using --wait (e.g., '3s', '500ms', '1m')", - }), - timeout: arg(durationArg.default("5m"), { - alias: "t", - description: "Maximum time to wait when using --wait (e.g., '30s', '5m')", - }), - logs: arg(z.boolean().default(false), { - alias: "l", - description: "Display function execution logs after completion (requires --wait)", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + "executor-name": arg(z.string(), { + positional: true, + description: "Executor name", + }), + data: arg(jsonDataArg.optional(), { + alias: "d", + description: "Request body (JSON string)", + }), + header: arg(headerArg.array().optional(), { + alias: "H", + overrideBuiltinAlias: true, + description: "Request header (format: 'Key: Value', can be specified multiple times)", + }), + wait: arg(z.boolean().default(false), { + alias: "W", + description: + "Wait for job completion and downstream execution (workflow/function) if applicable", + }), + interval: arg(durationArg.default("3s"), { + alias: "i", + description: "Polling interval when using --wait (e.g., '3s', '500ms', '1m')", + }), + timeout: arg(durationArg.default("5m"), { + alias: "t", + description: "Maximum time to wait when using --wait (e.g., '30s', '5m')", + }), + logs: arg(z.boolean().default(false), { + alias: "l", + description: "Display function execution logs after completion (requires --wait)", + }), + }), run: async (args) => { const jsonOutput = logger.jsonMode || args.json; await assertWritable({ profile: args.profile }); diff --git a/packages/sdk/src/cli/commands/executor/webhook.ts b/packages/sdk/src/cli/commands/executor/webhook.ts index e6fa4d6a2b..0fcf0e627a 100644 --- a/packages/sdk/src/cli/commands/executor/webhook.ts +++ b/packages/sdk/src/cli/commands/executor/webhook.ts @@ -60,12 +60,10 @@ export async function listWebhookExecutors( const listWebhookCommand = defineAppCommand({ name: "list", description: "List executors with incoming webhook triggers", - args: z - .object({ - ...workspaceArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...paginationArgs(), + }), run: async (args) => { const jsonOutput = logger.jsonMode; const executors = await listWebhookExecutors({ @@ -90,9 +88,7 @@ const listWebhookCommand = defineAppCommand({ }); if (!jsonOutput) { - logger.info( - 'To test a webhook, run: tailor-sdk executor trigger -d \'{"key":"value"}\'', - ); + logger.info('To test a webhook, run: tailor executor trigger -d \'{"key":"value"}\''); } }, }); diff --git a/packages/sdk/src/cli/commands/function/bundle.test.ts b/packages/sdk/src/cli/commands/function/bundle.test.ts index c636695f5c..ab21a35471 100644 --- a/packages/sdk/src/cli/commands/function/bundle.test.ts +++ b/packages/sdk/src/cli/commands/function/bundle.test.ts @@ -22,11 +22,11 @@ describe("bundleForTestRun", () => { beforeEach(() => { testDir = path.join(TEST_BASE, `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); fs.mkdirSync(testDir, { recursive: true }); - process.env.TAILOR_SDK_OUTPUT_DIR = testDir; + process.env.TAILOR_BUILD_OUTPUT_DIR = testDir; }); afterAll(() => { - delete process.env.TAILOR_SDK_OUTPUT_DIR; + delete process.env.TAILOR_BUILD_OUTPUT_DIR; try { fs.rmSync(TEST_BASE, { recursive: true, force: true }); } catch { @@ -234,7 +234,7 @@ export default { detected, ); - expect(result.bundledCode).toContain("USER_TYPE_MACHINE_USER"); + expect(result.bundledCode).toContain("machine_user"); expect(result.bundledCode).toContain("ADMIN"); expect(result.bundledCode).toContain(defaultMachineUser.id); expect(result.bundledCode).toContain(defaultWorkspaceId); diff --git a/packages/sdk/src/cli/commands/function/bundle.ts b/packages/sdk/src/cli/commands/function/bundle.ts index e0ad41d59e..929fce9c54 100644 --- a/packages/sdk/src/cli/commands/function/bundle.ts +++ b/packages/sdk/src/cli/commands/function/bundle.ts @@ -24,7 +24,7 @@ import ml from "#/utils/multiline"; import type { LogLevelInput } from "#/configure/config/types"; import type { DetectedFunction } from "./detect"; -/** Machine user info resolved from config and API for bundle-time user context. */ +/** Machine user info resolved from config and API for bundle-time principal context. */ export interface ResolvedMachineUser { /** Machine user name */ name: string; @@ -47,7 +47,7 @@ interface BundleForTestRunOptions { inlineSourcemap?: boolean; /** Log level config value from defineConfig */ logLevel?: LogLevelInput; - /** Machine user info for injecting user context into the bundle */ + /** Machine user info for injecting principal context into the bundle */ machineUser: ResolvedMachineUser; /** Workspace ID for user context */ workspaceId: string; @@ -152,23 +152,23 @@ function generateEntry( case "resolver": { // Mirrors the production resolver bundler (services/resolver/bundler.ts). - // In production, the operationHook injects user/env into context. + // In production, the operationHook injects caller/env into context. // For test-run, we embed machine user info since there's no operationHook. - const userExpr = buildMachineUserExpr(machineUser, workspaceId); + const principalExpr = buildMachinePrincipalExpr(machineUser, workspaceId); return ml /* js */ ` import _internalResolver from "${absoluteSourcePath}"; import { t } from "@tailor-platform/sdk"; const _env = ${JSON.stringify(env)}; - const _user = ${userExpr}; + const _caller = ${principalExpr}; const $tailor_resolver_body = async (context) => { - const _invoker = ${INVOKER_EXPR}; + const _invoker = ${INVOKER_EXPR} ?? _caller; if (_internalResolver.input) { const result = t.object(_internalResolver.input).parse({ value: context, data: context, - user: _user, + invoker: _invoker, }); if (result.issues) { @@ -179,7 +179,7 @@ function generateEntry( } } - const enrichedContext = { input: context, env: _env, user: _user, invoker: _invoker }; + const enrichedContext = { input: context, env: _env, caller: _caller, invoker: _invoker }; return _internalResolver.body(enrichedContext); }; @@ -191,15 +191,15 @@ function generateEntry( // Mirrors the production executor bundler (services/executor/bundler.ts). // In production, buildExecutorArgsExpr injects actor/env into args. // For test-run, we embed machine user as actor. - const actorExpr = buildMachineActorExpr(machineUser, workspaceId); + const principalExpr = buildMachinePrincipalExpr(machineUser, workspaceId); return ml /* js */ ` import _internalExecutor from "${absoluteSourcePath}"; const _env = ${JSON.stringify(env)}; - const _actor = ${actorExpr}; + const _actor = ${principalExpr}; const __executor_function = async (args) => { - const _invoker = ${INVOKER_EXPR}; + const _invoker = ${INVOKER_EXPR} ?? _actor; return _internalExecutor.operation.body({ ...args, env: _env, actor: _actor, invoker: _invoker }); }; @@ -212,13 +212,15 @@ function generateEntry( // Note: user context is not available in TestExecScript for workflow jobs. // The production workflow bundler's user mapping is being fixed in fix/workflow-user. const exportName = assertDefined(detected.exportName, "workflow job export name missing"); + const principalExpr = buildMachinePrincipalExpr(machineUser, workspaceId); return ml /* js */ ` import { ${exportName} } from "${absoluteSourcePath}"; const env = ${JSON.stringify(env)}; + const fallbackInvoker = ${principalExpr}; export async function main(input) { - const invoker = ${INVOKER_EXPR}; + const invoker = ${INVOKER_EXPR} ?? fallbackInvoker; return await ${exportName}.body(input, { env, invoker }); } `; @@ -227,33 +229,17 @@ function generateEntry( } /** - * Build a JSON expression for a machine user TailorUser object. + * Build a JSON expression for a machine user TailorPrincipal object. * @param machineUser - Resolved machine user info * @param workspaceId - Workspace ID * @returns JSON string for the user expression */ -function buildMachineUserExpr(machineUser: ResolvedMachineUser, workspaceId: string): string { +function buildMachinePrincipalExpr(machineUser: ResolvedMachineUser, workspaceId: string): string { return JSON.stringify({ id: machineUser.id, type: "machine_user", workspaceId, - attributes: machineUser.attributes, + attributes: machineUser.attributes ?? {}, attributeList: machineUser.attributeList, }); } - -/** - * Build a JSON expression for a machine user TailorActor object. - * @param machineUser - Resolved machine user info - * @param workspaceId - Workspace ID - * @returns JSON string for the actor expression - */ -function buildMachineActorExpr(machineUser: ResolvedMachineUser, workspaceId: string): string { - return JSON.stringify({ - workspaceId, - userId: machineUser.id, - attributes: machineUser.attributes, - attributeList: machineUser.attributeList, - userType: "USER_TYPE_MACHINE_USER", - }); -} diff --git a/packages/sdk/src/cli/commands/function/detect.test.ts b/packages/sdk/src/cli/commands/function/detect.test.ts index e852c53ce3..7e2ddae745 100644 --- a/packages/sdk/src/cli/commands/function/detect.test.ts +++ b/packages/sdk/src/cli/commands/function/detect.test.ts @@ -45,6 +45,32 @@ export default { expect(result.type).toBe("resolver"); expect(result.name).toBe("my-resolver"); }); + + test("rejects a branded resolver with unknown helper keys", async () => { + const filePath = path.join(testDir, "resolver-with-helper.mjs"); + fs.writeFileSync( + filePath, + ` +const resolver = { + operation: "query", + name: "my-resolver", + body: (ctx) => ctx.input, + output: { type: "string", metadata: {}, fields: {} }, + trigger: () => {}, +}; + +Object.defineProperty(resolver, Symbol.for("tailor-platform/sdk"), { + value: "resolver", +}); + +export default resolver; +`, + ); + + await expect(detectFunctionType({ filePath })).rejects.toThrow( + "Could not detect function type", + ); + }); }); describe("executor detection", () => { @@ -68,6 +94,33 @@ export default { expect(result.name).toBe("my-executor"); }); + test("detects a function executor with trigger helper args", async () => { + const filePath = path.join(testDir, "executor-with-helper.mjs"); + fs.writeFileSync( + filePath, + ` +const executor = { + name: "my-executor", + trigger: { kind: "schedule", cron: "0 12 * * *", __args: [{ cron: "0 12 * * *" }] }, + operation: { + kind: "function", + body: (args) => {}, + }, +}; + +Object.defineProperty(executor, Symbol.for("tailor-platform/sdk"), { + value: "executor", +}); + +export default executor; +`, + ); + + const result = await detectFunctionType({ filePath }); + expect(result.type).toBe("executor"); + expect(result.name).toBe("my-executor"); + }); + test("does not detect a non-function executor", async () => { const filePath = writeFile( "gql-executor.mjs", diff --git a/packages/sdk/src/cli/commands/function/detect.ts b/packages/sdk/src/cli/commands/function/detect.ts index 1ef490be31..feb0b1e04b 100644 --- a/packages/sdk/src/cli/commands/function/detect.ts +++ b/packages/sdk/src/cli/commands/function/detect.ts @@ -7,6 +7,7 @@ import { pathToFileURL } from "node:url"; import * as path from "pathe"; +import { stripExecutorTriggerArgs } from "#/cli/services/executor/loader"; import { ExecutorSchema } from "#/parser/service/executor/index"; import { ResolverSchema } from "#/parser/service/resolver/index"; import { WorkflowJobSchema } from "#/parser/service/workflow/index"; @@ -16,7 +17,7 @@ export type FunctionType = "resolver" | "executor" | "workflow-job" | "plain"; /** Minimal schema interface for local format detection (subset of TailorField) */ interface InputSchema { - parse(args: { value: unknown; data: unknown; user: Record }): { + parse(args: { value: unknown; data: unknown; invoker: Record | null }): { issues?: readonly { message: string; path?: readonly (string | number | symbol)[] }[]; }; } @@ -76,7 +77,7 @@ export async function detectFunctionType( } // 2. Check executor (only function/jobFunction kinds) - const executorResult = ExecutorSchema.safeParse(module.default); + const executorResult = ExecutorSchema.safeParse(stripExecutorTriggerArgs(module.default)); if (executorResult.success) { const { operation } = executorResult.data; if (operation.kind === "function" || operation.kind === "jobFunction") { diff --git a/packages/sdk/src/cli/commands/function/get.ts b/packages/sdk/src/cli/commands/function/get.ts index ae96753136..8a24114e91 100644 --- a/packages/sdk/src/cli/commands/function/get.ts +++ b/packages/sdk/src/cli/commands/function/get.ts @@ -10,6 +10,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { functionRegistryInfo, type FunctionRegistryInfo } from "./transform"; +// strip unknown keys const getFunctionRegistryOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -71,15 +72,13 @@ export async function getFunctionRegistry( export const getCommand = defineAppCommand({ name: "get", description: "Get a function registry by name", - args: z - .object({ - ...workspaceArgs, - name: arg(z.string(), { - description: "Function name", - alias: "n", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + name: arg(z.string(), { + description: "Function name", + alias: "n", + }), + }), run: async (args) => { const fn = await getFunctionRegistry({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/function/list.ts b/packages/sdk/src/cli/commands/function/list.ts index a6a2896b69..789b50ba93 100644 --- a/packages/sdk/src/cli/commands/function/list.ts +++ b/packages/sdk/src/cli/commands/function/list.ts @@ -9,6 +9,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { functionRegistryInfo, type FunctionRegistryInfo } from "./transform"; +// strip unknown keys const listFunctionRegistriesOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -77,12 +78,10 @@ export async function listFunctionRegistries( export const listCommand = defineAppCommand({ name: "list", description: "List function registries in a workspace", - args: z - .object({ - ...workspaceArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...paginationArgs(), + }), run: async (args) => { const jsonOutput = logger.jsonMode; const registries = await listFunctionRegistries({ diff --git a/packages/sdk/src/cli/commands/function/logs.test.ts b/packages/sdk/src/cli/commands/function/logs.test.ts index 74b63cb38a..02cdd3182a 100644 --- a/packages/sdk/src/cli/commands/function/logs.test.ts +++ b/packages/sdk/src/cli/commands/function/logs.test.ts @@ -190,7 +190,6 @@ describe("downloadScriptForMapping", () => { scriptName: "test-run--throwError.js", executionType: FunctionExecution_Type.STANDARD, executionContentHash: "abc123", - executionStartedAt: new Date("2024-01-01T00:00:00Z"), }); expect(result).toBeNull(); @@ -207,7 +206,6 @@ describe("downloadScriptForMapping", () => { scriptName: "my-resolver.throwError.body.js", executionType: FunctionExecution_Type.STANDARD, executionContentHash: "abc123", - executionStartedAt: new Date("2024-02-01T00:00:00Z"), }); expect(result).toBe("pinned-code"); @@ -219,9 +217,8 @@ describe("downloadScriptForMapping", () => { }); test("returns code even when registry was redeployed after the execution", async () => { - // Registry metadata reports updatedAt newer than the execution. - // The legacy timestamp-based check would skip this, but pinning - // by contentHash asks the server for the exact bundle that ran. + // Pinning by contentHash asks the server for the exact bundle that + // ran, so a newer registry updatedAt does not affect the result. const client = makeDownloadClient([new TextEncoder().encode("pinned-code")], { updatedAt: new Date("2024-03-01T00:00:00Z"), }); @@ -232,7 +229,6 @@ describe("downloadScriptForMapping", () => { scriptName: "my-resolver.throwError.body.js", executionType: FunctionExecution_Type.STANDARD, executionContentHash: "abc123", - executionStartedAt: new Date("2024-02-01T00:00:00Z"), }); expect(result).toBe("pinned-code"); @@ -247,7 +243,6 @@ describe("downloadScriptForMapping", () => { scriptName: "billing.retry.v2", executionType: FunctionExecution_Type.JOB, executionContentHash: "deadbeef", - executionStartedAt: new Date("2024-02-01T00:00:00Z"), }); expect(result).toBe("job-code"); @@ -267,80 +262,15 @@ describe("downloadScriptForMapping", () => { scriptName: "my-resolver.throwError.body.js", executionType: FunctionExecution_Type.STANDARD, executionContentHash: "abc123", - executionStartedAt: new Date("2024-02-01T00:00:00Z"), }); expect(result).toBeNull(); }); }); - describe("with updatedAt fallback (executionContentHash empty)", () => { - test("does not pin contentHash on the RPC", async () => { - const client = makeDownloadClient([new TextEncoder().encode("code")], { - updatedAt: new Date("2024-01-01T00:00:00Z"), - }); - - await downloadScriptForMapping({ - client, - workspaceId: "ws-1", - scriptName: "my-resolver.throwError.body.js", - executionType: FunctionExecution_Type.STANDARD, - executionContentHash: "", - executionStartedAt: new Date("2024-02-01T00:00:00Z"), - }); - - expect(client.downloadFunctionRegistryScript).toHaveBeenCalledWith({ - workspaceId: "ws-1", - name: "resolver--my-resolver--throwError", - contentHash: undefined, - }); - }); - - test.each([ - { - label: "registry updatedAt is not newer than executionStartedAt", - updatedAt: new Date("2024-01-01T00:00:00Z"), - executionStartedAt: new Date("2024-02-01T00:00:00Z"), - expected: "code", - }, - { - label: "registry updatedAt is strictly newer than executionStartedAt", - updatedAt: new Date("2024-03-01T00:00:00Z"), - executionStartedAt: new Date("2024-02-01T00:00:00Z"), - expected: null, - }, - { - label: "executionStartedAt is null (no staleness check possible)", - updatedAt: new Date("2024-03-01T00:00:00Z"), - executionStartedAt: null, - expected: "code", - }, - { - label: "registry metadata omits updatedAt", - updatedAt: undefined, - executionStartedAt: new Date("2024-02-01T00:00:00Z"), - expected: "code", - }, - ])("when $label", async ({ updatedAt, executionStartedAt, expected }) => { - const client = makeDownloadClient( - [new TextEncoder().encode("code")], - updatedAt ? { updatedAt } : undefined, - ); - - const result = await downloadScriptForMapping({ - client, - workspaceId: "ws-1", - scriptName: "my-resolver.throwError.body.js", - executionType: FunctionExecution_Type.STANDARD, - executionContentHash: "", - executionStartedAt, - }); - - expect(result).toBe(expected); - }); - - test("returns null when download yields no chunks", async () => { - const client = makeDownloadClient([], { updatedAt: new Date("2024-01-01T00:00:00Z") }); + describe("without executionContentHash", () => { + test("skips mapping without downloading the current registry script", async () => { + const client = makeDownloadClient([new TextEncoder().encode("code")]); const result = await downloadScriptForMapping({ client, @@ -348,10 +278,10 @@ describe("downloadScriptForMapping", () => { scriptName: "my-resolver.throwError.body.js", executionType: FunctionExecution_Type.STANDARD, executionContentHash: "", - executionStartedAt: new Date("2024-02-01T00:00:00Z"), }); expect(result).toBeNull(); + expect(client.downloadFunctionRegistryScript).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/sdk/src/cli/commands/function/logs.ts b/packages/sdk/src/cli/commands/function/logs.ts index c108b63b28..bacf904e30 100644 --- a/packages/sdk/src/cli/commands/function/logs.ts +++ b/packages/sdk/src/cli/commands/function/logs.ts @@ -211,31 +211,20 @@ interface DownloadScriptForMappingOptions { * FunctionExecution.contentHash. When non-empty, the registry * download is pinned to this exact bundle so the stack trace maps * against the code that actually ran, regardless of subsequent - * redeploys. Empty on older servers that did not populate the - * field; in that case the caller falls back to a timestamp-based - * staleness check using `executionStartedAt`. + * redeploys. Empty values cannot be mapped safely. */ executionContentHash: string; - /** - * When the execution started. Used as a fallback staleness signal - * only when `executionContentHash` is empty: if the current registry - * entry's `updatedAt` is strictly newer, the downloaded bundle may - * differ from what was actually executed, so mapping is skipped to - * avoid misleading source locations. - */ - executionStartedAt: Date | null; } /** * Download a deployed function script for sourcemap mapping. Logs a * debug message on failure but never throws. Error display falls back - * to a plain-text format when the script cannot be retrieved or when - * the current registry entry is stale relative to the execution. + * to a plain-text format when the script cannot be retrieved. * * When `executionContentHash` is non-empty, the download is pinned to * that exact bundle so mapping stays correct across redeploys. When - * empty (older servers), falls back to comparing `registryUpdatedAt` - * against `executionStartedAt`. + * empty, mapping is skipped because the exact bundle cannot be + * identified. * * `FunctionExecution.scriptName` does not match the function registry * name directly; `scriptNameToRegistryName` translates between the two @@ -246,20 +235,12 @@ interface DownloadScriptForMappingOptions { * @param options.scriptName - Script name (matches FunctionExecution.scriptName) * @param options.executionType - Execution type used to discriminate registry name translation * @param options.executionContentHash - Content hash of the bundle that ran; pins the download when non-empty - * @param options.executionStartedAt - Execution start timestamp used as fallback staleness signal - * @returns Bundled script content, or null when unavailable / stale + * @returns Bundled script content, or null when unavailable */ export async function downloadScriptForMapping( options: DownloadScriptForMappingOptions, ): Promise { - const { - client, - workspaceId, - scriptName, - executionType, - executionContentHash, - executionStartedAt, - } = options; + const { client, workspaceId, scriptName, executionType, executionContentHash } = options; const registryName = scriptNameToRegistryName(scriptName, executionType); if (registryName == null) { logger.debug( @@ -268,44 +249,26 @@ export async function downloadScriptForMapping( return null; } - if (executionContentHash !== "") { - const pinned = await downloadFunctionScript({ - client, - workspaceId, - name: registryName, - contentHash: executionContentHash, - }); - if (pinned == null) { - logger.debug( - `Could not download pinned script "${scriptName}" (registry: "${registryName}", contentHash: "${executionContentHash}") for stack trace mapping; showing raw stack trace.`, - ); - return null; - } - return pinned.code; - } - - // Fallback for older servers that did not populate - // FunctionExecution.contentHash: download the current bundle and - // skip mapping if the registry was updated after the execution - // started. - const result = await downloadFunctionScript({ client, workspaceId, name: registryName }); - if (result == null) { + if (executionContentHash === "") { logger.debug( - `Could not download script "${scriptName}" (registry: "${registryName}") for stack trace mapping; showing raw stack trace.`, + `Function execution "${scriptName}" has no contentHash; skipping sourcemap mapping because the exact bundle cannot be identified.`, ); return null; } - if ( - executionStartedAt != null && - result.registryUpdatedAt != null && - result.registryUpdatedAt.getTime() > executionStartedAt.getTime() - ) { + + const pinned = await downloadFunctionScript({ + client, + workspaceId, + name: registryName, + contentHash: executionContentHash, + }); + if (pinned == null) { logger.debug( - `Registry script "${registryName}" was updated at ${result.registryUpdatedAt.toISOString()} after execution started at ${executionStartedAt.toISOString()}; skipping sourcemap mapping to avoid stale source locations.`, + `Could not download pinned script "${scriptName}" (registry: "${registryName}", contentHash: "${executionContentHash}") for stack trace mapping; showing raw stack trace.`, ); return null; } - return result.code; + return pinned.code; } export const logsCommand = defineAppCommand({ @@ -313,7 +276,7 @@ export const logsCommand = defineAppCommand({ description: "List or get function execution logs.", notes: `When viewing a specific execution that failed, the command displays error details with the stack trace mapped back to your original source files (clickable file links and code snippets, matching \`function test-run\` output). -Stack traces stay accurate even after later redeploys, because the trace is resolved against the exact build that produced the execution. If that build is no longer available, the command falls back to a plain-text error display.`, +Stack traces are mapped only when the execution includes a content hash for the exact build that ran. If the content hash is missing or the build is no longer available, the command falls back to a plain-text error display.`, examples: [ { cmd: "", @@ -332,16 +295,14 @@ Stack traces stay accurate even after later redeploys, because the trace is reso desc: "Get execution details as JSON", }, ], - args: z - .object({ - ...workspaceArgs, - ...pagedLogArgs, - "execution-id": arg(z.string().optional(), { - positional: true, - description: "Execution ID (if provided, shows details with logs)", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...pagedLogArgs, + "execution-id": arg(z.string().optional(), { + positional: true, + description: "Execution ID (if provided, shows details with logs)", + }), + }), run: async (args) => { const accessToken = await loadAccessToken({ profile: args.profile, @@ -378,7 +339,6 @@ Stack traces stay accurate even after later redeploys, because the trace is reso scriptName: detail.scriptName, executionType: execution.type, executionContentHash: execution.contentHash, - executionStartedAt: detail.startedAt, }) : null; printFunctionExecutionDetail({ detail, bundledCode }); diff --git a/packages/sdk/src/cli/commands/function/test-run.ts b/packages/sdk/src/cli/commands/function/test-run.ts index 55e043d6c7..3cc8d875aa 100644 --- a/packages/sdk/src/cli/commands/function/test-run.ts +++ b/packages/sdk/src/cli/commands/function/test-run.ts @@ -1,5 +1,5 @@ /** - * `tailor-sdk function test-run` command + * `tailor function test-run` command * * Bundles and executes a function on the Tailor Platform server * without deploying (applying) the application. @@ -21,11 +21,13 @@ import { executeScript } from "#/cli/shared/script-executor"; import { formatErrorWithSourcemap } from "#/cli/shared/stack-trace"; import { assertDefined } from "#/utils/assert"; import { bundleForTestRun, type ResolvedMachineUser } from "./bundle"; -import { detectFunctionType, type DetectedFunction } from "./detect"; +import { detectFunctionType } from "./detect"; +import type { Jsonifiable } from "type-fest"; export const testRunCommand = defineAppCommand({ name: "test-run", description: "Run a function on the Tailor Platform server without deploying.", + // strip unknown keys args: z.object({ ...workspaceArgs, file: arg(z.string(), { @@ -134,15 +136,11 @@ When a \`.js\` file is provided, detection and bundling are skipped and the file functionName = detected.name; logger.info(`Detected: ${styles.bold(detected.type)} ${styles.info(`"${detected.name}"`)}`); - if (detected.type === "resolver" && args.arg) { - if (!detected.hasInput) { - logger.warn( - '--arg is ignored because this resolver has no input schema. Define "input" in your resolver to use --arg.', - ); - args.arg = undefined; - } else if (detected.inputSchema) { - args.arg = resolveResolverArg(args.arg, detected.inputSchema, machineUser, workspaceId); - } + if (detected.type === "resolver" && args.arg && !detected.hasInput) { + logger.warn( + '--arg is ignored because this resolver has no input schema. Define "input" in your resolver to use --arg.', + ); + args.arg = undefined; } logger.info("Bundling..."); @@ -159,20 +157,31 @@ When a \`.js\` file is provided, detection and bundling are skipped and the file } // 5. Execute via TestExecScript - const authInvoker = create(AuthInvokerSchema, { + const invoker = create(AuthInvokerSchema, { namespace: authNamespace, machineUserName: machineUser.name, }); logger.info(`Executing on workspace ${styles.dim(workspaceId)}...`); + let parsedArg: Jsonifiable | undefined; + if (args.arg !== undefined) { + try { + parsedArg = JSON.parse(args.arg); + } catch (error) { + throw new Error(`Invalid --arg JSON: ${error instanceof Error ? error.message : error}`, { + cause: error, + }); + } + } + const result = await executeScript({ client, workspaceId, name: scriptName, code: bundledCode, - arg: args.arg, - invoker: authInvoker, + arg: parsedArg, + invoker, }); // 7. Display result @@ -273,7 +282,7 @@ async function resolveMachineUserName(options: ResolveMachineUserNameOptions): P } } throw new Error( - "Machine user is required. Provide --machine-user, set TAILOR_PLATFORM_MACHINE_USER_NAME, set a profile default with 'tailor-sdk profile update --machine-user ', or ensure tailor.config.ts has machine users configured.", + "Machine user is required. Provide --machine-user, set TAILOR_PLATFORM_MACHINE_USER_NAME, set a profile default with 'tailor profile update --machine-user ', or ensure tailor.config.ts has machine users configured.", ); } @@ -327,53 +336,3 @@ async function resolveMachineUser( return { name: machineUserName, id, attributes, attributeList }; } - -/** - * Resolve resolver arg format: detect and unwrap deprecated {"input":{...}} wrapper. - * Tries new format (arg = input fields) first via schema parse. - * If that fails and arg looks like old format, tries unwrapping. - * @param argStr - JSON string of the arg - * @param inputSchema - Pre-built schema object from detect (has .parse()) - * @param machineUser - Resolved machine user info - * @param workspaceId - Workspace ID - * @returns Resolved JSON string (unwrapped if old format) - */ -export function resolveResolverArg( - argStr: string, - inputSchema: NonNullable, - machineUser: ResolvedMachineUser, - workspaceId: string, -): string { - const parsed = JSON.parse(argStr); - const user = { - id: machineUser.id, - type: "machine_user" as const, - workspaceId, - attributes: machineUser.attributes ?? null, - attributeList: machineUser.attributeList, - }; - - const newResult = inputSchema.parse({ value: parsed, data: parsed, user }); - if (!newResult.issues) { - return argStr; - } - - // New format failed — check if old format works - if ( - Object.keys(parsed).length === 1 && - parsed.input != null && - typeof parsed.input === "object" && - !Array.isArray(parsed.input) - ) { - const oldResult = inputSchema.parse({ value: parsed.input, data: parsed.input, user }); - if (!oldResult.issues) { - logger.warn( - '[DEPRECATED] Wrapping args with "input" key (e.g. {"input":{...}}) is deprecated. Pass input fields directly (e.g. {"a":1}). The "input" wrapper will be removed in v2.', - ); - return JSON.stringify(parsed.input); - } - } - - // Both failed — pass as-is, let server report the validation error - return argStr; -} diff --git a/packages/sdk/src/cli/commands/generate/index.ts b/packages/sdk/src/cli/commands/generate/index.ts index a3a44ecdbc..358a711aff 100644 --- a/packages/sdk/src/cli/commands/generate/index.ts +++ b/packages/sdk/src/cli/commands/generate/index.ts @@ -6,18 +6,16 @@ import { defineAppCommand } from "#/cli/shared/command"; export const generateCommand = defineAppCommand({ name: "generate", description: "Generate files using Tailor configuration.", - args: z - .object({ - config: arg(z.string().default("tailor.config.ts"), { - alias: "c", - description: "Path to SDK config file", - }), - watch: arg(z.boolean().default(false), { - alias: "W", - description: "Watch for type/resolver changes and regenerate", - }), - }) - .strict(), + args: z.strictObject({ + config: arg(z.string().default("tailor.config.ts"), { + alias: "c", + description: "Path to SDK config file", + }), + watch: arg(z.boolean().default(false), { + alias: "W", + description: "Watch for type/resolver changes and regenerate", + }), + }), run: async (args) => { const { initTelemetry } = await import("#/cli/telemetry/index"); await initTelemetry(); diff --git a/packages/sdk/src/cli/commands/generate/plugin-executor-generator.ts b/packages/sdk/src/cli/commands/generate/plugin-executor-generator.ts index 19311d8edc..a30e8a2e11 100644 --- a/packages/sdk/src/cli/commands/generate/plugin-executor-generator.ts +++ b/packages/sdk/src/cli/commands/generate/plugin-executor-generator.ts @@ -46,7 +46,7 @@ interface TypeImportInfo { * Generate TypeScript files for plugin-generated executors. * These files will be processed by the standard executor bundler. * @param executors - Array of plugin executor information - * @param outputDir - Base output directory (e.g., .tailor-sdk) + * @param outputDir - Base output directory (e.g., .tailor) * @param typeGenerationResult - Result from plugin type generation (for import resolution) * @param sourceTypeInfoMap - Map of source type names to their source info * @param configPath - Path to tailor.config.ts (used for resolving plugin import paths) @@ -88,7 +88,7 @@ export function generatePluginExecutorFiles( /** * Generate a single executor file. * @param info - Plugin executor metadata and definition - * @param outputDir - Base output directory (e.g., .tailor-sdk) + * @param outputDir - Base output directory (e.g., .tailor) * @param typeGenerationResult - Result from plugin type generation * @param sourceTypeInfoMap - Map of source type names to their source info * @param baseDirs - Base directories for resolving plugin import paths diff --git a/packages/sdk/src/cli/commands/generate/plugin-type-generator.ts b/packages/sdk/src/cli/commands/generate/plugin-type-generator.ts index ecc2da6b1a..44d9e032ab 100644 --- a/packages/sdk/src/cli/commands/generate/plugin-type-generator.ts +++ b/packages/sdk/src/cli/commands/generate/plugin-type-generator.ts @@ -34,7 +34,7 @@ function isFieldDefinition(value: unknown): value is FieldDefinition { * Generate TypeScript files for plugin-generated types. * These files export the type definition and can be imported by executor files. * @param types - Array of plugin type information - * @param outputDir - Base output directory (e.g., .tailor-sdk) + * @param outputDir - Base output directory (e.g., .tailor) * @returns Generation result with file paths */ export function generatePluginTypeFiles( diff --git a/packages/sdk/src/cli/commands/generate/seed/bundler.test.ts b/packages/sdk/src/cli/commands/generate/seed/bundler.test.ts index 71ff535b9d..cb079b3abc 100644 --- a/packages/sdk/src/cli/commands/generate/seed/bundler.test.ts +++ b/packages/sdk/src/cli/commands/generate/seed/bundler.test.ts @@ -7,17 +7,17 @@ const TEST_BUNDLER_BASE = path.join(__dirname, "__test_bundler__"); describe("seed-bundler", () => { beforeEach(() => { - // Set TAILOR_SDK_OUTPUT_DIR to test directory so bundled output goes into test directory + // Set TAILOR_BUILD_OUTPUT_DIR to test directory so bundled output goes into test directory const testDir = path.join( TEST_BUNDLER_BASE, `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ); fs.mkdirSync(testDir, { recursive: true }); - process.env.TAILOR_SDK_OUTPUT_DIR = testDir; + process.env.TAILOR_BUILD_OUTPUT_DIR = testDir; }); afterAll(() => { - delete process.env.TAILOR_SDK_OUTPUT_DIR; + delete process.env.TAILOR_BUILD_OUTPUT_DIR; try { fs.rmSync(TEST_BUNDLER_BASE, { recursive: true, force: true }); } catch { diff --git a/packages/sdk/src/cli/commands/generate/seed/bundler.ts b/packages/sdk/src/cli/commands/generate/seed/bundler.ts index dd6e26e3cb..26b528f622 100644 --- a/packages/sdk/src/cli/commands/generate/seed/bundler.ts +++ b/packages/sdk/src/cli/commands/generate/seed/bundler.ts @@ -112,7 +112,7 @@ export async function bundleSeedScript( namespace: string, typeNames: string[], ): Promise { - // Output directory in .tailor-sdk (relative to project root) + // Output directory in .tailor (relative to project root) const outputDir = path.resolve(getDistDir(), "seed"); fs.mkdirSync(outputDir, { recursive: true }); diff --git a/packages/sdk/src/cli/commands/generate/service.test.ts b/packages/sdk/src/cli/commands/generate/service.test.ts index 00306f4c9b..38f412e17b 100644 --- a/packages/sdk/src/cli/commands/generate/service.test.ts +++ b/packages/sdk/src/cli/commands/generate/service.test.ts @@ -3,19 +3,14 @@ import * as os from "node:os"; import * as path from "pathe"; import { describe, expect, test, beforeEach, afterEach, vi, afterAll } from "vitest"; import { defineApplication } from "#/cli/services/application"; -import { createResolver } from "#/configure/services/resolver/resolver"; -import { db } from "#/configure/services/tailordb/schema"; -import { t } from "#/configure/types/index"; -import { parseTypes } from "#/parser/service/tailordb/index"; -import { toSchemaOutputs } from "#/utils/test/internal"; +import { PluginManager } from "#/plugin/manager"; import { createGenerationManager } from "./service"; import type { Application } from "#/cli/services/application"; import type { TailorDBService } from "#/cli/services/tailordb/service"; -import type { LoadedConfig, Generator } from "#/cli/shared/config-loader"; -import type { TailorDBType } from "#/configure/services/tailordb/schema"; -import type { Resolver } from "#/types/resolver.generated"; +import type { LoadedConfig } from "#/cli/shared/config-loader"; +import type { TailorDBType } from "#/parser/service/tailordb/types"; +import type { Plugin } from "#/plugin/types"; -// ESM-safe explicit mock for Node's fs vi.mock("node:fs", () => { return { writeFile: vi.fn((_, _2, callback) => { @@ -51,55 +46,16 @@ vi.mock("#/cli/shared/logger", async (importOriginal) => { }; }); -class TestGenerator { - readonly id = "test-generator"; - readonly description = "Test generator for unit tests"; - readonly dependencies = ["tailordb", "resolver", "executor"] as const; - - async processType(args: { - type: TailorDBType; - namespace: string; - source: { filePath: string; exportName: string }; - }) { - return { name: args.type.name, processed: true, source: args.source }; - } - - async processResolver(args: { resolver: Resolver; namespace: string }) { - return { name: args.resolver.name, processed: true }; - } - - async processExecutor(executor: { name: T }) { - return { name: executor.name, processed: true }; - } - - async processTailorDBNamespace(args: { namespace: string; types: Record }) { - return { processed: true, count: Object.keys(args.types).length }; - } - - async processResolverNamespace(args: { namespace: string; resolvers: Record }) { - return { processed: true, count: Object.keys(args.resolvers).length }; - } - - async aggregate(args: { input: object; baseDir: string }) { - return { - files: [ - { - path: path.join(args.baseDir, "test-output.txt"), - content: `Input: ${JSON.stringify(args.input)}`, - }, - ], - }; - } -} - function loadedTailorDBService(namespace: string, typeNames: string[]): TailorDBService { - const types = Object.fromEntries(typeNames.map((typeName) => [typeName, {}])); + const types = Object.fromEntries( + typeNames.map((typeName) => [typeName, { name: typeName } as TailorDBType]), + ); const typeSourceInfo = Object.fromEntries( typeNames.map((typeName) => [ typeName, { filePath: `${namespace}/${typeName}.ts`, - exportName: typeName.toLowerCase(), + exportName: typeName, }, ]), ); @@ -125,35 +81,8 @@ function applicationWithTailorDBServices( }; } -function emptyGeneratorResult() { - return { - tailordbResults: {}, - resolverResults: {}, - tailordbNamespaceResults: {}, - resolverNamespaceResults: {}, - executorResults: {}, - }; -} - -function testResolver(name: string) { - return createResolver({ - name, - operation: "query", - body: () => ({ string: "" }), - output: t.object({ string: t.string() }), - }); -} - -function parsedTestTypes(typeNames: string[], namespace = "test-namespace", sourceInfo = {}) { - const types = Object.fromEntries(typeNames.map((name) => [name, db.type(name, {})])); - return parseTypes(toSchemaOutputs(types), namespace, sourceInfo); -} - describe("GenerationManager", () => { let tempDir: string; - // For test-only access to private members - // oxlint-disable-next-line no-explicit-any - let manager: any; let mockConfig: LoadedConfig; afterAll(() => { @@ -169,79 +98,96 @@ describe("GenerationManager", () => { db: { main: { files: ["src/types/*.ts"] } }, resolver: { main: { files: ["src/resolvers/*.ts"] } }, }; - - // for minimal mock - const application = defineApplication({ config: mockConfig }); - manager = createGenerationManager({ - application, - config: mockConfig, - // oxlint-disable-next-line no-explicit-any - generators: [new TestGenerator()] as any, - }); }); afterEach(() => { + vi.mocked(fs.writeFile).mockClear(); + vi.mocked(fs.mkdirSync).mockClear(); if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } }); describe("constructor", () => { - test("initializes correctly", () => { - expect(manager.application).toBeDefined(); + test("initializes without legacy generator API", () => { + const application = defineApplication({ config: mockConfig }); + const manager = createGenerationManager({ + application, + config: mockConfig, + }); + + expect(manager.application).toBe(application); expect(manager.baseDir).toContain("generated"); + expect(manager.services).toEqual({ tailordb: {}, resolver: {}, executor: {} }); + expect("generators" in manager).toBe(false); + expect("generatorResults" in manager).toBe(false); + expect("processGenerator" in manager).toBe(false); }); - test("base directory is created", () => { + test("creates base directory", () => { + const application = defineApplication({ config: mockConfig }); + createGenerationManager({ + application, + config: mockConfig, + }); + expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringContaining("generated"), { recursive: true, }); }); }); - describe("generators", () => { - test("generators are passed correctly", () => { - expect(manager.generators.length).toBeGreaterThan(0); - }); - - test("receives custom generator", () => { - const customApp = defineApplication({ config: mockConfig }); - // For test-only - TestGenerator doesn't have brand symbol - // oxlint-disable-next-line no-explicit-any - const managerWithCustom: any = createGenerationManager({ - application: customApp, + describe("generate", () => { + test("executes complete generation process", async () => { + const application = defineApplication({ config: mockConfig }); + const manager = createGenerationManager({ + application, config: mockConfig, - generators: [new TestGenerator()] as unknown as Generator[], }); - expect( - managerWithCustom.generators.some((gen: { id: string }) => gen.id === "test-generator"), - ).toBe(true); - }); - }); - describe("generate", () => { - test("executes complete generation process", async () => { await manager.generate(false); - expect(manager.generators.length).toBeGreaterThan(0); expect(manager.services).toBeDefined(); }); - test("processes single application", async () => { - const singleAppConfig = { - ...mockConfig, - name: "single-app", + test("runs plugin generation hooks after TailorDB load", async () => { + const onTailorDBReady = vi.fn().mockResolvedValue({ + files: [{ path: path.join(tempDir, "generated.txt"), content: "generated" }], + }); + const plugin: Plugin = { + id: "test-plugin", + description: "Test plugin", + onTailorDBReady, }; - // For test-only access to private members - const singleApp = defineApplication({ config: singleAppConfig }); - // oxlint-disable-next-line no-explicit-any - const singleAppManager: any = createGenerationManager({ - application: singleApp, - config: singleAppConfig, + const application = applicationWithTailorDBServices(mockConfig, [ + loadedTailorDBService("main", ["User"]), + ]); + const pluginManager = new PluginManager([plugin]); + const manager = createGenerationManager({ + application, + config: mockConfig, + pluginManager, }); - await singleAppManager.generate(false); - expect(singleAppManager.services).toBeDefined(); + await manager.generate(false); + + expect(onTailorDBReady).toHaveBeenCalledWith( + expect.objectContaining({ + tailordb: [ + expect.objectContaining({ + namespace: "main", + types: expect.objectContaining({ User: expect.objectContaining({ name: "User" }) }), + }), + ], + baseDir: expect.stringContaining("test-plugin"), + configPath: "tailor.config.ts", + }), + ); + expect(fs.writeFile).toHaveBeenCalledWith( + path.join(tempDir, "generated.txt"), + "generated", + expect.any(Function), + ); }); test("rejects duplicate TailorDB type names between namespaces", async () => { @@ -273,453 +219,41 @@ describe("GenerationManager", () => { }); }); - describe("runGenerators (via generate)", () => { - beforeEach(() => { - manager.services = { - tailordb: { - "test-namespace": { - types: parsedTestTypes(["TestType"]), - sourceInfo: {}, - }, - }, - resolver: { - "test-namespace": { - testResolver: testResolver("testResolver"), - }, - }, - executor: {}, - }; - }); - - test("processes all generators through generate method", async () => { - const testGenerator = manager.generators[0]; - using aggregateSpy = vi.spyOn(testGenerator, "aggregate"); - - await manager.generate(false); - - expect(aggregateSpy).toHaveBeenCalled(); - }); - - test("errors in generator processing do not affect others", async () => { - const errorGenerator = { - id: "error-generator", - description: "Error generator", - dependencies: ["tailordb", "resolver", "executor"] as const, - processType: vi - .fn() - .mockImplementation(() => Promise.reject(new Error("Type processing error"))), - processResolver: vi - .fn() - .mockImplementation(() => Promise.reject(new Error("Resolver processing error"))), - processExecutor: vi - .fn() - .mockImplementation(() => Promise.reject(new Error("Executor processing error"))), - aggregate: vi.fn().mockImplementation(() => Promise.resolve({ files: [] })), - }; - - manager.generators.push(errorGenerator); - - await manager.generate(false); - - expect(errorGenerator.aggregate).toHaveBeenCalled(); - }); - }); - - describe("processGenerator", () => { - let testGenerator: TestGenerator; - - beforeEach(() => { - testGenerator = new TestGenerator(); - manager.generators = [testGenerator]; - - manager.services.tailordb["test-namespace"] = { - types: parsedTestTypes(["TestType"]), - sourceInfo: {}, - pluginAttachments: new Map(), - }; - manager.services.resolver["test-namespace"] = { - testResolver: testResolver("testResolver"), - }; - }); - - test("complete processing of single generator", async () => { - Object.keys(manager.generatorResults).forEach((key) => { - delete manager.generatorResults[key]; - }); - - using processTypeSpy = vi.spyOn(testGenerator, "processType"); - using processResolverSpy = vi.spyOn(testGenerator, "processResolver"); - using aggregateSpy = vi.spyOn(testGenerator, "aggregate"); - - await manager.processGenerator(testGenerator); - - expect(processTypeSpy).toHaveBeenCalled(); - expect(processResolverSpy).toHaveBeenCalled(); - expect(aggregateSpy).toHaveBeenCalled(); - }); - - test("types and resolvers are processed in parallel", async () => { - Object.keys(manager.generatorResults).forEach((key) => { - delete manager.generatorResults[key]; - }); - - const start = Date.now(); - await manager.processGenerator(testGenerator); - const duration = Date.now() - start; - - expect(duration).toBeLessThan(1000); - }); - }); - - describe("processTailorDBNamespace", () => { - let testGenerator: TestGenerator; - - beforeEach(() => { - testGenerator = new TestGenerator(); - manager.generatorResults[testGenerator.id] = emptyGeneratorResult(); - }); - - test("processes all types", async () => { - using processTypeSpy = vi.spyOn(testGenerator, "processType"); - const parsedTypes = parsedTestTypes(["Type1", "Type2", "Type3"]); - - manager.generatorResults[testGenerator.id] = emptyGeneratorResult(); - - await manager.processTailorDBNamespace(testGenerator, "test-namespace", { - types: parsedTypes, - sourceInfo: {}, - pluginAttachments: new Map(), - }); - - expect(processTypeSpy).toHaveBeenCalledTimes(3); - expect( - manager.generatorResults[testGenerator.id].tailordbResults["test-namespace"], - ).toBeDefined(); - expect( - Object.keys(manager.generatorResults[testGenerator.id].tailordbResults["test-namespace"]), - ).toHaveLength(3); - }); - - test("does not error with empty types", async () => { - manager.generatorResults[testGenerator.id] = emptyGeneratorResult(); - - await expect( - manager.processTailorDBNamespace(testGenerator, "test-namespace", { - types: {}, - sourceInfo: {}, - pluginAttachments: new Map(), - }), - ).resolves.not.toThrow(); - }); - - test("sourceInfo is correctly passed to processType", async () => { - using processTypeSpy = vi.spyOn(testGenerator, "processType"); - const sourceInfo = { - TestType: { - filePath: "test.ts", - exportName: "TestType", - }, - }; - const parsedTypes = parsedTestTypes(["TestType"], "test-namespace", sourceInfo); - - manager.generatorResults[testGenerator.id] = emptyGeneratorResult(); - - await manager.processTailorDBNamespace(testGenerator, "test-namespace", { - types: parsedTypes, - sourceInfo, - pluginAttachments: new Map(), - }); - - expect(processTypeSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: expect.any(Object), - namespace: "test-namespace", - source: expect.objectContaining({ - filePath: "test.ts", - exportName: "TestType", - }), - plugins: [], - }), - ); - }); - }); - - describe("processResolverNamespace", () => { - let testGenerator: TestGenerator; - - beforeEach(() => { - testGenerator = new TestGenerator(); - manager.generatorResults[testGenerator.id] = emptyGeneratorResult(); - }); - - test("processes all resolvers", async () => { - using processResolverSpy = vi.spyOn(testGenerator, "processResolver"); - const resolvers = { - resolver1: testResolver("resolver1"), - resolver2: testResolver("resolver2"), - }; - - await manager.processResolverNamespace(testGenerator, "test-namespace", resolvers); - - expect(processResolverSpy).toHaveBeenCalledTimes(2); - expect( - manager.generatorResults[testGenerator.id].resolverResults["test-namespace"], - ).toBeDefined(); - expect( - Object.keys(manager.generatorResults[testGenerator.id].resolverResults["test-namespace"]), - ).toHaveLength(2); - }); - }); - - describe("aggregate", () => { - let testGenerator: TestGenerator; - - beforeEach(() => { - testGenerator = new TestGenerator(); - manager.generatorResults[testGenerator.id] = { - ...emptyGeneratorResult(), - tailordbNamespaceResults: { - "test-namespace": { types: "processed" }, - }, - resolverNamespaceResults: { - "test-namespace": { resolvers: "processed" }, - }, - }; - }); - - test("calls generator aggregate method", async () => { - using aggregateSpy = vi.spyOn(testGenerator, "aggregate"); - - await manager.aggregate(testGenerator); - - expect(aggregateSpy).toHaveBeenCalledWith({ - input: { - tailordb: [ - { - namespace: "test-namespace", - types: { types: "processed" }, - }, - ], - resolver: [ - { - namespace: "test-namespace", - resolvers: { resolvers: "processed" }, - }, - ], - executor: [], - auth: undefined, - }, - baseDir: expect.stringContaining(testGenerator.id), - configPath: expect.any(String), - }); - }); - - test("writes files correctly", async () => { - await manager.aggregate(testGenerator); - - expect(fs.writeFile).toHaveBeenCalled(); - expect(fs.mkdirSync).toHaveBeenCalled(); - }); - - test("parallel writing of multiple files", async () => { - vi.mocked(fs.writeFile).mockClear(); - - const multiFileGenerator = { - id: testGenerator.id, - description: testGenerator.description, - dependencies: testGenerator.dependencies, - aggregate: vi.fn().mockResolvedValue({ - files: [ - { path: "/test/file1.txt", content: "content1" }, - { path: "/test/file2.txt", content: "content2" }, - { path: "/test/file3.txt", content: "content3" }, - ], - }), - }; - - manager.generatorResults[multiFileGenerator.id] = emptyGeneratorResult(); - - await manager.aggregate(multiFileGenerator); - - expect(fs.writeFile).toHaveBeenCalledTimes(3); - }); - - test("handles file write errors", async () => { - const writeFileError = new Error("Write permission denied"); - vi.mocked(fs.writeFile).mockImplementationOnce((_path, _content, callback) => { - callback(writeFileError); - }); - - const errorGenerator = { - id: testGenerator.id, - description: testGenerator.description, - dependencies: testGenerator.dependencies, - aggregate: vi.fn().mockResolvedValue({ - files: [{ path: "/test/error.txt", content: "content" }], - }), - }; - - manager.generatorResults[errorGenerator.id] = emptyGeneratorResult(); - - await expect(manager.aggregate(errorGenerator)).rejects.toThrow("Write permission denied"); - }); - }); - describe("watch", () => { test("watch method exists", () => { + const application = defineApplication({ config: mockConfig }); + const manager = createGenerationManager({ + application, + config: mockConfig, + }); + expect(typeof manager.watch).toBe("function"); }); test("application has tailorDBServices for watch", () => { + const application = defineApplication({ config: mockConfig }); + const manager = createGenerationManager({ + application, + config: mockConfig, + }); + expect(manager.application.tailorDBServices).toBeDefined(); expect(manager.application.tailorDBServices.length).toBeGreaterThan(0); - expect(manager.application.tailorDBServices[0].namespace).toBe("main"); - expect(manager.application.tailorDBServices[0].config.files).toEqual(["src/types/*.ts"]); + expect(manager.application.tailorDBServices[0]?.namespace).toBe("main"); + expect(manager.application.tailorDBServices[0]?.config.files).toEqual(["src/types/*.ts"]); }); test("application has resolverServices for watch", () => { - expect(manager.application.resolverServices).toBeDefined(); - expect(manager.application.resolverServices.length).toBeGreaterThan(0); - expect(manager.application.resolverServices[0].namespace).toBe("main"); - expect(manager.application.resolverServices[0].config.files).toEqual(["src/resolvers/*.ts"]); - }); - }); -}); - -describe("generate function", () => { - let mockConfig: LoadedConfig; - - beforeEach(() => { - mockConfig = { - name: "test-workspace", - path: "tailor.config.ts", - }; - }); - - test("generate does not automatically call watch", async () => { - const app = defineApplication({ config: mockConfig }); - const manager = createGenerationManager({ application: app, config: mockConfig }); - await expect(manager.generate(false)).resolves.not.toThrow(); - expect(manager.application).toBeDefined(); - }); -}); - -describe("Integration Tests", () => { - let tempDir: string; - let fullConfig: LoadedConfig; - - beforeEach(async () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "integration-test-")); - - fullConfig = { - name: "testApp", - path: "tailor.config.ts", - db: { - main: { - files: [path.join(tempDir, "types/*.ts")], - }, - }, - resolver: { - main: { - files: [path.join(tempDir, "resolvers/*.ts")], - }, - }, - }; - }); - - afterEach(() => { - if (fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }); - - test("complete integration test with multiple generators", async () => { - const gen1 = new TestGenerator(); - const gen2 = new TestGenerator(); - // For test-only access to private members - // oxlint-disable-next-line no-explicit-any - const generators: any[] = [gen1, gen2]; - const integrationApp = defineApplication({ config: fullConfig }); - // oxlint-disable-next-line no-explicit-any - const manager: any = createGenerationManager({ - application: integrationApp, - config: fullConfig, - generators, - }); - - await expect(manager.generate(false)).resolves.not.toThrow(); - - expect(manager.generators.length).toBe(2); - expect(manager.generators.every((g: unknown) => g instanceof TestGenerator)).toBe(true); - }); - - test("integration test for error recovery and performance", async () => { - const errorApp = defineApplication({ config: fullConfig }); - const manager = createGenerationManager({ application: errorApp, config: fullConfig }); - - const start = Date.now(); - await manager.generate(false); - const duration = Date.now() - start; - - expect(duration).toBeLessThan(5000); - }); - - describe("Memory Management", () => { - test("no memory leak with large data processing", async () => { - // For test-only - TestGenerator doesn't have brand symbol - // oxlint-disable-next-line no-explicit-any - const largeGenerators: any[] = Array(10) - .fill(0) - .map(() => new TestGenerator()); - - // For test-only access to private members - const memApp = defineApplication({ config: fullConfig }); - // oxlint-disable-next-line no-explicit-any - const manager: any = createGenerationManager({ - application: memApp, - config: fullConfig, - generators: largeGenerators, + const application = defineApplication({ config: mockConfig }); + const manager = createGenerationManager({ + application, + config: mockConfig, }); - // Create large application data structure - manager.services = { - tailordb: {}, - resolver: {}, - executor: {}, - }; - - // Add multiple namespaces - Array(10) - .fill(0) - .forEach((_, nsIdx) => { - const namespace = `namespace-${nsIdx}`; - - // Add types to namespace - const types: Record = {}; - Array(50) - .fill(0) - .forEach((_, typeIdx) => { - types[`Type${nsIdx}_${typeIdx}`] = db.type(`Type${nsIdx}_${typeIdx}`, {}); - }); - - const parsedTypes = parseTypes(toSchemaOutputs(types), namespace, {}); - - manager.services.tailordb[namespace] = { - types: parsedTypes, - sourceInfo: {}, - }; - - // Add resolvers to namespace - manager.services.resolver[namespace] = {}; - Array(10) - .fill(0) - .forEach((_, resolverIdx) => { - manager.services.resolver[namespace][`resolver${nsIdx}_${resolverIdx}`] = - testResolver(`resolver${nsIdx}_${resolverIdx}`); - }); - }); - - await expect(manager.generate(false)).resolves.not.toThrow(); + expect(manager.application.resolverServices).toBeDefined(); + expect(manager.application.resolverServices.length).toBeGreaterThan(0); + expect(manager.application.resolverServices[0]?.namespace).toBe("main"); + expect(manager.application.resolverServices[0]?.config.files).toEqual(["src/resolvers/*.ts"]); }); }); }); diff --git a/packages/sdk/src/cli/commands/generate/service.ts b/packages/sdk/src/cli/commands/generate/service.ts index b1da41f047..3a1768db99 100644 --- a/packages/sdk/src/cli/commands/generate/service.ts +++ b/packages/sdk/src/cli/commands/generate/service.ts @@ -1,15 +1,6 @@ import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as path from "pathe"; -import { - type AnyCodeGenerator, - type TailorDBNamespaceResult, - type ResolverNamespaceResult, - type GeneratorAuthInput, - type GeneratorResult, - type DependencyKind, - hasDependency, -} from "#/cli/commands/generate/types"; import { defineApplication, generatePluginFilesIfNeeded, @@ -17,7 +8,7 @@ import { } from "#/cli/services/application"; import { createExecutorService } from "#/cli/services/executor/service"; import { assertUniqueLocalTailorDBTypeNames } from "#/cli/services/tailordb/type-name-validation"; -import { loadConfig, type LoadedConfig, type Generator } from "#/cli/shared/config-loader"; +import { loadConfig, type LoadedConfig } from "#/cli/shared/config-loader"; import { getDistDir } from "#/cli/shared/dist-dir"; import { logger, styles } from "#/cli/shared/logger"; import { generateUserTypes } from "#/cli/shared/type-generator"; @@ -25,12 +16,10 @@ import { withSpan } from "#/cli/telemetry/index"; import { PluginManager } from "#/plugin/manager"; import { assertDefined } from "#/utils/assert"; import { createDependencyWatcher, type DependencyWatcher } from "./watch"; +import type { TypeSourceInfo, TailorDBType } from "#/parser/service/tailordb/types"; import type { - TypeSourceInfo, - TypeSourceInfoEntry, - TailorDBType, -} from "#/parser/service/tailordb/types"; -import type { + GeneratorAuthInput, + GeneratorResult, TailorDBNamespaceData, ResolverNamespaceData, Plugin, @@ -40,8 +29,6 @@ import type { Executor } from "#/types/executor.generated"; import type { Resolver } from "#/types/resolver.generated"; import type { GenerateOptions } from "./options"; -export type { CodeGenerator } from "#/cli/commands/generate/types"; - type TypeInfo = { types: Record; sourceInfo: TypeSourceInfo; @@ -54,57 +41,29 @@ type TypeInfo = { export type GenerationManager = { readonly application: Application; readonly baseDir: string; - readonly generators: Generator[]; readonly services: { tailordb: Record; resolver: Record>; executor: Record; }; - readonly generatorResults: GeneratorResults; - processGenerator: (gen: AnyCodeGenerator) => Promise; - processTailorDBNamespace: ( - gen: AnyCodeGenerator, - namespace: string, - typeInfo: TypeInfo, - ) => Promise; - processResolverNamespace: ( - gen: AnyCodeGenerator, - namespace: string, - resolvers: Record, - ) => Promise; - processExecutors: (gen: AnyCodeGenerator) => Promise; - aggregate: (gen: AnyCodeGenerator) => Promise; generate: (watch: boolean) => Promise; watch: () => Promise; }; -type GeneratorResults = Record< - /* generator */ string, - { - tailordbResults: Record>; - resolverResults: Record>; - tailordbNamespaceResults: Record; - resolverNamespaceResults: Record; - executorResults: Record; - } ->; - /** * Creates a generation manager. * @param params - Parameters for creating the generation manager * @param params.application - Application instance to generate code for * @param params.config - Loaded configuration - * @param params.generators - Code generators to run * @param params.pluginManager - Plugin manager for processing plugins * @returns GenerationManager instance */ export function createGenerationManager(params: { application: Application; config: LoadedConfig; - generators?: Generator[]; pluginManager?: PluginManager; }): GenerationManager { - const { application, config, generators = [], pluginManager } = params; + const { application, config, pluginManager } = params; const baseDir = path.join(getDistDir(), "generated"); fs.mkdirSync(baseDir, { recursive: true }); @@ -115,16 +74,10 @@ export function createGenerationManager(params: { } = { tailordb: {}, resolver: {}, executor: {} }; let watcher: DependencyWatcher | null = null; - const generatorResults: GeneratorResults = {}; // Get plugins that have generation hooks const generationPlugins = pluginManager?.getPluginsWithGenerationHooks() ?? []; - // Returns generators that subscribe to the given dependency phase - function getReadyGenerators(dep: DependencyKind): Generator[] { - return generators.filter((g) => (g as AnyCodeGenerator).dependencies.includes(dep)); - } - function getAuthInput(): GeneratorAuthInput | undefined { const authService = application.authService; if (!authService) return undefined; @@ -146,199 +99,6 @@ export function createGenerationManager(params: { }; } - // ========================================================================= - // Generator processing (unchanged - per-type/perNS/aggregate pipeline) - // ========================================================================= - - async function processTailorDBNamespace( - gen: AnyCodeGenerator, - namespace: string, - typeInfo: TypeInfo, - ): Promise { - const results = assertDefined( - generatorResults[gen.id], - `generator result not initialized for ${gen.id}`, - ); - results.tailordbResults[namespace] = {}; - - // Check if generator has processType method - if (!gen.processType) { - return; - } - - const processType = gen.processType; - await Promise.allSettled( - Object.entries(typeInfo.types).map(async ([typeName, type]) => { - try { - assertDefined( - results.tailordbResults[namespace], - `tailordb results not initialized for namespace ${namespace}`, - )[typeName] = await processType({ - type, - namespace, - source: typeInfo.sourceInfo[typeName] as TypeSourceInfoEntry, - plugins: typeInfo.pluginAttachments.get(typeName) ?? [], - }); - } catch (error) { - logger.error( - `Error processing type ${styles.bold(typeName)} in ${namespace} with generator ${gen.id}`, - ); - logger.error(String(error)); - } - }), - ); - - // Process namespace summary if available - if ("processTailorDBNamespace" in gen && typeof gen.processTailorDBNamespace === "function") { - try { - results.tailordbNamespaceResults[namespace] = await gen.processTailorDBNamespace({ - namespace, - types: results.tailordbResults[namespace], - }); - } catch (error) { - logger.error( - `Error processing TailorDB namespace ${styles.bold(namespace)} with generator ${gen.id}`, - ); - logger.error(String(error)); - } - } else { - results.tailordbNamespaceResults[namespace] = results.tailordbResults[namespace]; - } - } - - async function processResolverNamespace( - gen: AnyCodeGenerator, - namespace: string, - resolvers: Record, - ): Promise { - const results = assertDefined( - generatorResults[gen.id], - `generator result not initialized for ${gen.id}`, - ); - results.resolverResults[namespace] = {}; - - // Check if generator has processResolver method - if (!gen.processResolver) { - return; - } - - const processResolver = gen.processResolver; - // Process individual resolvers - await Promise.allSettled( - Object.entries(resolvers).map(async ([resolverName, resolver]) => { - try { - assertDefined( - results.resolverResults[namespace], - `resolver results not initialized for namespace ${namespace}`, - )[resolverName] = await processResolver({ - resolver, - namespace, - }); - } catch (error) { - logger.error( - `Error processing resolver ${styles.bold(resolverName)} in ${namespace} with generator ${gen.id}`, - ); - logger.error(String(error)); - } - }), - ); - - // Process namespace summary if available - if ("processResolverNamespace" in gen && typeof gen.processResolverNamespace === "function") { - try { - results.resolverNamespaceResults[namespace] = await gen.processResolverNamespace({ - namespace, - resolvers: results.resolverResults[namespace], - }); - } catch (error) { - logger.error( - `Error processing Resolver namespace ${styles.bold(namespace)} with generator ${gen.id}`, - ); - logger.error(String(error)); - } - } else { - results.resolverNamespaceResults[namespace] = results.resolverResults[namespace]; - } - } - - async function processExecutors(gen: AnyCodeGenerator): Promise { - const results = assertDefined( - generatorResults[gen.id], - `generator result not initialized for ${gen.id}`, - ); - - // Check if generator has processExecutor method - if (!gen.processExecutor) { - return; - } - - const processExecutor = gen.processExecutor; - // Process individual executors - await Promise.allSettled( - Object.entries(services.executor).map(async ([executorId, executor]) => { - try { - results.executorResults[executorId] = await processExecutor(executor); - } catch (error) { - logger.error( - `Error processing executor ${styles.bold(executor.name)} with generator ${gen.id}`, - ); - logger.error(String(error)); - } - }), - ); - } - - async function aggregate(gen: AnyCodeGenerator): Promise { - const results = assertDefined( - generatorResults[gen.id], - `generator result not initialized for ${gen.id}`, - ); - - const tailordbResults: TailorDBNamespaceResult[] = []; - const resolverResults: ResolverNamespaceResult[] = []; - - // Collect TailorDB namespace results - for (const [namespace, types] of Object.entries(results.tailordbNamespaceResults)) { - tailordbResults.push({ - namespace, - types, - }); - } - - // Collect Resolver namespace results - for (const [namespace, resolvers] of Object.entries(results.resolverNamespaceResults)) { - resolverResults.push({ - namespace, - resolvers, - }); - } - - // Build input based on generator dependencies - const input: Record = { - auth: getAuthInput(), - }; - - if (hasDependency(gen, "tailordb")) { - input.tailordb = tailordbResults; - } - if (hasDependency(gen, "resolver")) { - input.resolver = resolverResults; - } - if (hasDependency(gen, "executor")) { - input.executor = Object.values(results.executorResults); - } - - // Call generator's aggregate method - const result = await gen.aggregate({ - input: input as Parameters[0]["input"], - baseDir: path.join(baseDir, gen.id), - configPath: config.path, - }); - - // Write generated files - await writeGeneratedFiles(gen.id, result); - } - // ========================================================================= // Plugin phase-complete hook runner // ========================================================================= @@ -471,8 +231,8 @@ export function createGenerationManager(params: { /** * Write generated files to disk. - * @param sourceId - Generator or plugin ID for logging - * @param result - Generator result containing files to write + * @param sourceId - Plugin ID for logging + * @param result - Generation result containing files to write */ async function writeGeneratedFiles(sourceId: string, result: GeneratorResult): Promise { await Promise.all( @@ -518,66 +278,6 @@ export function createGenerationManager(params: { ); } - // ========================================================================= - // Generator orchestration - // ========================================================================= - - async function processGenerator(gen: AnyCodeGenerator): Promise { - generatorResults[gen.id] = { - tailordbResults: {}, - resolverResults: {}, - tailordbNamespaceResults: {}, - resolverNamespaceResults: {}, - executorResults: {}, - }; - - // Process TailorDB if generator has tailordb dependency - if (hasDependency(gen, "tailordb")) { - for (const [namespace, types] of Object.entries(services.tailordb)) { - await processTailorDBNamespace(gen, namespace, types); - } - } - - // Process Resolver if generator has resolver dependency - if (hasDependency(gen, "resolver")) { - for (const [namespace, resolvers] of Object.entries(services.resolver)) { - await processResolverNamespace(gen, namespace, resolvers); - } - } - - // Process Executors if generator has executor dependency - if (hasDependency(gen, "executor")) { - await processExecutors(gen); - } - - // Aggregate all results - await aggregate(gen); - } - - async function runGenerators(gens: Generator[], watch: boolean): Promise { - const results = await Promise.allSettled( - gens.map(async (gen) => { - await withSpan(`generate.generator.${gen.id}`, async () => { - try { - await processGenerator(gen as AnyCodeGenerator); - } catch (error) { - logger.error(`Error processing generator ${styles.bold(gen.id)}`); - logger.error(String(error)); - if (!watch) { - throw error; - } - } - }); - }), - ); - if (!watch) { - const failures = results.filter((r): r is PromiseRejectedResult => r.status === "rejected"); - if (failures.length > 0) { - throw new AggregateError(failures.map((f) => f.reason)); - } - } - } - async function restartWatchProcess(): Promise { logger.newline(); logger.info("Restarting watch process to clear module cache...", { @@ -594,8 +294,8 @@ export function createGenerationManager(params: { const args = process.argv.slice(2); const env = { ...process.env, - TAILOR_WATCH_GENERATION: ( - parseInt(process.env.TAILOR_WATCH_GENERATION || "0", 10) + 1 + __TAILOR_WATCH_GENERATION: ( + parseInt(process.env.__TAILOR_WATCH_GENERATION || "0", 10) + 1 ).toString(), }; @@ -628,14 +328,7 @@ export function createGenerationManager(params: { return { application, baseDir, - generators, services, - generatorResults, - processGenerator, - processTailorDBNamespace, - processResolverNamespace, - processExecutors, - aggregate, async generate(watch: boolean): Promise { logger.newline(); @@ -715,15 +408,11 @@ export function createGenerationManager(params: { logger.newline(); } - // Run generators + plugin hooks for onTailorDBReady - const readyAfterTailorDB = getReadyGenerators("tailordb"); + // Run plugin hooks for onTailorDBReady const hasOnTailorDBReady = generationPlugins.some((p) => p.onTailorDBReady != null); - if (readyAfterTailorDB.length > 0 || hasOnTailorDBReady) { + if (hasOnTailorDBReady) { await withSpan("generate.onTailorDBReady", async () => { - await Promise.all([ - runGenerators(readyAfterTailorDB, watch), - runPluginHook("onTailorDBReady", watch), - ]); + await runPluginHook("onTailorDBReady", watch); }); logger.newline(); } @@ -753,15 +442,11 @@ export function createGenerationManager(params: { } }); - // Run generators + plugin hooks for onResolverReady - const readyAfterResolvers = getReadyGenerators("resolver"); + // Run plugin hooks for onResolverReady const hasOnResolverReady = generationPlugins.some((p) => p.onResolverReady != null); - if (readyAfterResolvers.length > 0 || hasOnResolverReady) { + if (hasOnResolverReady) { await withSpan("generate.onResolversReady", async () => { - await Promise.all([ - runGenerators(readyAfterResolvers, watch), - runPluginHook("onResolverReady", watch), - ]); + await runPluginHook("onResolverReady", watch); }); logger.newline(); } @@ -782,15 +467,11 @@ export function createGenerationManager(params: { }); }); - // Run generators + plugin hooks for onExecutorReady - const readyAfterExecutors = getReadyGenerators("executor"); + // Run plugin hooks for onExecutorReady const hasOnExecutorReady = generationPlugins.some((p) => p.onExecutorReady != null); - if (readyAfterExecutors.length > 0 || hasOnExecutorReady) { + if (hasOnExecutorReady) { await withSpan("generate.onExecutorsReady", async () => { - await Promise.all([ - runGenerators(readyAfterExecutors, watch), - runPluginHook("onExecutorReady", watch), - ]); + await runPluginHook("onExecutorReady", watch); }); logger.newline(); } @@ -832,20 +513,19 @@ export function createGenerationManager(params: { } /** - * Run code generation using the Tailor configuration and generators. + * Run code generation using the Tailor configuration. * @param options - Generation options * @returns Promise that resolves when generation (and watch, if enabled) completes */ export async function generate(options?: GenerateOptions) { return withSpan("generate", async (rootSpan) => { // Load and validate options - const { config, generators, plugins } = await withSpan("generate.loadConfig", async () => { + const { config, plugins } = await withSpan("generate.loadConfig", async () => { return loadConfig(options?.configPath); }); const watch = options?.watch ?? false; rootSpan.setAttribute("generate.watch", watch); - rootSpan.setAttribute("generate.generators.count", generators.length); // Generate user types from loaded config await withSpan("generate.generateUserTypes", async () => @@ -863,7 +543,7 @@ export async function generate(options?: GenerateOptions) { rootSpan.setAttribute("app.name", application.config.name); - const manager = createGenerationManager({ application, config, generators, pluginManager }); + const manager = createGenerationManager({ application, config, pluginManager }); await manager.generate(watch); if (watch) { await manager.watch(); diff --git a/packages/sdk/src/cli/commands/generate/types.test.ts b/packages/sdk/src/cli/commands/generate/types.test.ts deleted file mode 100644 index c9f23ac354..0000000000 --- a/packages/sdk/src/cli/commands/generate/types.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, expect, test, expectTypeOf } from "vitest"; -import { - hasDependency, - type CodeGenerator, - type TailorDBGenerator, - type ResolverGenerator, - type ExecutorGenerator, - type TailorDBResolverGenerator, - type FullCodeGenerator, - type AnyCodeGenerator, - type DependencyKind, -} from "#/cli/commands/generate/types"; -import type { CodeGeneratorBase } from "#/plugin/types"; - -describe("Generator type compatibility", () => { - describe("TailorDBGenerator", () => { - test("should have tailordb dependency", () => { - expectTypeOf().toEqualTypeOf(); - }); - - test("should have processType method", () => { - expectTypeOf().toBeFunction(); - }); - - test("should have aggregate method", () => { - expectTypeOf().toBeFunction(); - }); - - test("should not have processResolver or processExecutor", () => { - type Keys = keyof TailorDBGenerator; - expectTypeOf<"processResolver">().not.toEqualTypeOf(); - expectTypeOf<"processExecutor">().not.toEqualTypeOf(); - }); - }); - - describe("ResolverGenerator", () => { - test("should have resolver dependency", () => { - expectTypeOf().toEqualTypeOf(); - }); - - test("should have processResolver method", () => { - expectTypeOf().toBeFunction(); - }); - - test("should have aggregate method", () => { - expectTypeOf().toBeFunction(); - }); - - test("should not have processType or processExecutor", () => { - type Keys = keyof ResolverGenerator; - expectTypeOf<"processType">().not.toEqualTypeOf(); - expectTypeOf<"processExecutor">().not.toEqualTypeOf(); - }); - }); - - describe("ExecutorGenerator", () => { - test("should have executor dependency", () => { - expectTypeOf().toEqualTypeOf(); - }); - - test("should have processExecutor method", () => { - expectTypeOf().toBeFunction(); - }); - - test("should have aggregate method", () => { - expectTypeOf().toBeFunction(); - }); - - test("should not have processType or processResolver", () => { - type Keys = keyof ExecutorGenerator; - expectTypeOf<"processType">().not.toEqualTypeOf(); - expectTypeOf<"processResolver">().not.toEqualTypeOf(); - }); - }); - - describe("TailorDBResolverGenerator", () => { - test("should have tailordb and resolver dependencies", () => { - expectTypeOf().toEqualTypeOf< - readonly ["tailordb", "resolver"] - >(); - }); - - test("should have both processType and processResolver methods", () => { - expectTypeOf().toBeFunction(); - expectTypeOf().toBeFunction(); - }); - - test("should not have processExecutor", () => { - type Keys = keyof TailorDBResolverGenerator; - expectTypeOf<"processExecutor">().not.toEqualTypeOf(); - }); - }); - - describe("FullCodeGenerator", () => { - test("should have all dependencies", () => { - expectTypeOf().toEqualTypeOf< - readonly ["tailordb", "resolver", "executor"] - >(); - }); - - test("should have all process methods", () => { - expectTypeOf().toBeFunction(); - expectTypeOf().toBeFunction(); - expectTypeOf().toBeFunction(); - }); - - test("should have aggregate method", () => { - expectTypeOf().toBeFunction(); - }); - }); - - describe("AnyCodeGenerator", () => { - test("should have optional process methods", () => { - type ProcessType = AnyCodeGenerator["processType"]; - type ProcessResolver = AnyCodeGenerator["processResolver"]; - type ProcessExecutor = AnyCodeGenerator["processExecutor"]; - - // These methods are optional (can be undefined or a function) - expectTypeOf().toExtend(); - expectTypeOf().toExtend(); - expectTypeOf().toExtend(); - }); - - test("should be assignable to CodeGeneratorBase", () => { - expectTypeOf().toExtend(); - }); - }); - - describe("hasDependency runtime utility", () => { - test("should return true when dependency exists", () => { - const gen = { dependencies: ["tailordb", "resolver"] as const }; - expect(hasDependency(gen, "tailordb")).toBe(true); - expect(hasDependency(gen, "resolver")).toBe(true); - }); - - test("should return false when dependency does not exist", () => { - const gen = { dependencies: ["tailordb"] as const }; - expect(hasDependency(gen, "resolver")).toBe(false); - expect(hasDependency(gen, "executor")).toBe(false); - }); - }); - - describe("CodeGenerator generic type", () => { - test("should correctly infer dependencies from type parameter", () => { - type TestGen = CodeGenerator; - expectTypeOf().toEqualTypeOf(); - }); - - test("should be compatible with readonly dependency arrays", () => { - type ReadonlyDeps = readonly DependencyKind[]; - const deps: ReadonlyDeps = ["tailordb", "resolver"]; - expect(deps).toContain("tailordb"); - }); - }); -}); diff --git a/packages/sdk/src/cli/commands/generate/types.ts b/packages/sdk/src/cli/commands/generate/types.ts deleted file mode 100644 index 1a392d0df4..0000000000 --- a/packages/sdk/src/cli/commands/generate/types.ts +++ /dev/null @@ -1,323 +0,0 @@ -import type { TailorDBType, TypeSourceInfoEntry } from "#/parser/service/tailordb/types"; -import type { PluginAttachment } from "#/plugin/types"; -import type { IdProvider as IdProviderConfig, OAuth2Client } from "#/types/auth.generated"; -import type { Executor } from "#/types/executor.generated"; -import type { Resolver } from "#/types/resolver.generated"; - -export type { PluginAttachment } from "#/plugin/types"; - -// ======================================== -// Basic types -// ======================================== - -interface GeneratedFile { - path: string; - content: string; - skipIfExists?: boolean; // default: false - executable?: boolean; // default: false - if true, sets chmod +x -} - -export interface GeneratorResult { - files: GeneratedFile[]; - errors?: string[]; -} - -// Namespace results for TailorDB -export interface TailorDBNamespaceResult { - namespace: string; - types: T; -} - -// Namespace results for Resolver -export interface ResolverNamespaceResult { - namespace: string; - resolvers: R; -} - -// Auth configuration for generators -export interface GeneratorAuthInput { - name: string; - userProfile?: { - typeName: string; - namespace: string; - usernameField: string; - }; - machineUsers?: Record }>; - oauth2Clients?: Record; - idProvider?: IdProviderConfig; -} - -// ======================================== -// Dependency types -// ======================================== - -export type DependencyKind = "tailordb" | "resolver" | "executor"; - -// Check if array includes a specific element -type ArrayIncludes = T extends readonly [ - infer First, - ...infer Rest, -] - ? First extends U - ? true - : ArrayIncludes - : false; - -// Check if dependencies array includes a specific dependency -export type HasDependency< - Deps extends readonly DependencyKind[], - D extends DependencyKind, -> = ArrayIncludes; - -// ======================================== -// Source info type for TailorDB types -// Re-exported from parser module -// ======================================== - -export type { - UserDefinedTypeSource, - PluginGeneratedTypeSource, - TypeSourceInfoEntry, -} from "#/parser/service/tailordb/types"; - -// ======================================== -// Method interfaces for each dependency -// ======================================== - -export interface TailorDBProcessMethods { - processType(args: { - type: TailorDBType; - namespace: string; - source: TypeSourceInfoEntry; - /** Plugin attachments configured on this type via .plugin() method */ - plugins: readonly PluginAttachment[]; - }): T | Promise; - - processTailorDBNamespace?(args: { - namespace: string; - types: Record; - }): Ts | Promise; -} - -export interface ResolverProcessMethods { - processResolver(args: { resolver: Resolver; namespace: string }): R | Promise; - - processResolverNamespace?(args: { - namespace: string; - resolvers: Record; - }): Rs | Promise; -} - -export interface ExecutorProcessMethods { - processExecutor(executor: Executor): E | Promise; -} - -// ======================================== -// Conditional method selection -// ======================================== - -type SelectMethods = (HasDependency< - Deps, - "tailordb" -> extends true - ? TailorDBProcessMethods - : object) & - (HasDependency extends true ? ResolverProcessMethods : object) & - (HasDependency extends true ? ExecutorProcessMethods : object); - -// ======================================== -// Conditional input selection for aggregate -// ======================================== - -interface TailorDBInputPart { - tailordb: TailorDBNamespaceResult[]; -} - -interface ResolverInputPart { - resolver: ResolverNamespaceResult[]; -} - -interface ExecutorInputPart { - executor: E[]; -} - -// Auth is always available (resolved after TailorDB, before generators) -interface AuthPart { - auth?: GeneratorAuthInput; -} - -type SelectInput = (HasDependency< - Deps, - "tailordb" -> extends true - ? TailorDBInputPart - : object) & - (HasDependency extends true ? ResolverInputPart : object) & - (HasDependency extends true ? ExecutorInputPart : object) & - AuthPart; - -/** Input type for TailorDB-only generators */ -export type TailorDBInput = TailorDBInputPart & AuthPart; - -/** Input type for Resolver-only generators */ -export type ResolverInput = ResolverInputPart & AuthPart; - -/** Input type for Executor-only generators */ -export type ExecutorInput = ExecutorInputPart & AuthPart; - -/** Input type for full generators (TailorDB + Resolver + Executor) */ -export type FullInput = TailorDBInputPart & - ResolverInputPart & - ExecutorInputPart & - AuthPart; - -/** Arguments type for aggregate method */ -export interface AggregateArgs { - input: Input; - baseDir: string; - configPath: string; -} - -// ======================================== -// CodeGenerator type definition -// ======================================== - -interface CodeGeneratorCore { - readonly id: string; - readonly description: string; -} - -/** - * Generator interface with dependencies-based conditional methods. - * @template Deps - Dependencies array (e.g., ['tailordb'], ['tailordb', 'resolver']) - * @template T - Return type of processType - * @template R - Return type of processResolver - * @template E - Return type of processExecutor - * @template Ts - Return type of processTailorDBNamespace (default: Record) - * @template Rs - Return type of processResolverNamespace (default: Record) - */ -export type CodeGenerator< - Deps extends readonly DependencyKind[], - T = unknown, - R = unknown, - E = unknown, - Ts = Record, - Rs = Record, -> = CodeGeneratorCore & - SelectMethods & { - readonly dependencies: Deps; - - aggregate(args: { - input: SelectInput; - baseDir: string; - configPath: string; - }): GeneratorResult | Promise; - }; - -// ======================================== -// Helper types for common generator patterns -// ======================================== - -/** TailorDB-only generator */ -export type TailorDBGenerator> = CodeGenerator< - readonly ["tailordb"], - T, - never, - never, - Ts, - never ->; - -/** Resolver-only generator */ -export type ResolverGenerator> = CodeGenerator< - readonly ["resolver"], - never, - R, - never, - never, - Rs ->; - -/** Executor-only generator */ -export type ExecutorGenerator = CodeGenerator< - readonly ["executor"], - never, - never, - E, - never, - never ->; - -/** TailorDB + Resolver generator */ -export type TailorDBResolverGenerator< - T = unknown, - R = unknown, - Ts = Record, - Rs = Record, -> = CodeGenerator; - -/** Full generator (all dependencies) */ -export type FullCodeGenerator< - T = unknown, - R = unknown, - E = unknown, - Ts = Record, - Rs = Record, -> = CodeGenerator; - -// ======================================== -// Runtime utility -// ======================================== - -interface DependencyCarrier { - dependencies: readonly DependencyKind[]; -} - -/** - * Type guard to check if a generator has a specific dependency. - * @template D - * @param generator - Code generator instance - * @param dependency - Dependency kind to check - * @returns True if the generator has the dependency - */ -export function hasDependency( - generator: DependencyCarrier, - dependency: D, -): boolean { - return generator.dependencies.includes(dependency); -} - -// Type for any generator (used in GenerationManager) -// This is a more permissive type that includes all possible methods -export interface AnyCodeGenerator { - readonly id: string; - readonly description: string; - readonly dependencies: readonly DependencyKind[]; - - processType?(args: { - type: TailorDBType; - namespace: string; - source: TypeSourceInfoEntry; - plugins: readonly PluginAttachment[]; - }): unknown | Promise; - - processTailorDBNamespace?(args: { - namespace: string; - types: Record; - }): unknown | Promise; - - processResolver?(args: { resolver: Resolver; namespace: string }): unknown | Promise; - - processResolverNamespace?(args: { - namespace: string; - resolvers: Record; - }): unknown | Promise; - - processExecutor?(executor: Executor): unknown | Promise; - - aggregate(args: { - input: Record; - baseDir: string; - configPath: string; - }): GeneratorResult | Promise; -} diff --git a/packages/sdk/src/cli/commands/init.ts b/packages/sdk/src/cli/commands/init.ts index b5c8edd68f..ac278d5355 100644 --- a/packages/sdk/src/cli/commands/init.ts +++ b/packages/sdk/src/cli/commands/init.ts @@ -17,18 +17,16 @@ const detectPackageManager = () => { export const initCommand = defineAppCommand({ name: "init", description: "Initialize a new project using create-sdk.", - args: z - .object({ - name: arg(z.string().optional(), { - positional: true, - description: "Project name", - }), - template: arg(z.string().optional(), { - alias: "t", - description: "Template name", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string().optional(), { + positional: true, + description: "Project name", + }), + template: arg(z.string().optional(), { + alias: "t", + description: "Template name", + }), + }), run: async (args) => { const packageJson = await readPackageJson(); const version = diff --git a/packages/sdk/src/cli/commands/login.test.ts b/packages/sdk/src/cli/commands/login.test.ts index f0cf9d2954..2a3e545c69 100644 --- a/packages/sdk/src/cli/commands/login.test.ts +++ b/packages/sdk/src/cli/commands/login.test.ts @@ -6,7 +6,7 @@ import { readPlatformConfig, writePlatformConfig } from "#/cli/shared/context"; import { resetKeyringState } from "#/cli/shared/token-store"; import { loginCommand } from "./login"; -const xdgTempDir = vi.hoisted(() => `/tmp/tailor-login-${Date.now()}-${Math.random()}`); +const xdgTempDir = vi.hoisted(() => `/tmp/sdk-login-test-${Date.now()}-${Math.random()}`); vi.mock("xdg-basedir", () => ({ xdgConfig: xdgTempDir, @@ -118,7 +118,8 @@ describe("login --profile", () => { access_token: "default-token", }); expect(pfConfig.users["https://api.dev.tailor.tech|machine-client"]).toMatchObject({ - access_token: "dev-token", + storage: "keyring", + token_expires_at: "2099-01-01T00:00:00.000Z", }); }); }); diff --git a/packages/sdk/src/cli/commands/login.ts b/packages/sdk/src/cli/commands/login.ts index 8537262638..16fcc938f4 100644 --- a/packages/sdk/src/cli/commands/login.ts +++ b/packages/sdk/src/cli/commands/login.ts @@ -16,6 +16,7 @@ import { defineAppCommand } from "#/cli/shared/command"; import { platformConfigFromProfile, readPlatformConfig, + removeLegacyUserAlias, saveUserTokens, writePlatformConfig, } from "#/cli/shared/context"; @@ -36,8 +37,17 @@ function randomState() { return crypto.randomBytes(32).toString("base64url"); } -function assertProfileLoginUser(args: ProfileLoginOptions, authenticatedUser: string) { - if (args.profile && args.profileUser && authenticatedUser !== args.profileUser) { +function assertProfileLoginUser( + args: ProfileLoginOptions, + authenticatedUser: string, + authenticatedSubject?: string, +) { + if ( + args.profile && + args.profileUser && + authenticatedUser !== args.profileUser && + authenticatedSubject !== args.profileUser + ) { throw new Error( `Profile "${args.profile}" is configured for "${args.profileUser}", but login authenticated "${authenticatedUser}".`, ); @@ -72,12 +82,12 @@ const startAuthServer = async (args: ProfileLoginOptions = {}) => { }, ); const userInfo = await fetchUserInfo(tokens.accessToken, args.platformConfig); - assertProfileLoginUser(args, userInfo.email); + assertProfileLoginUser(args, userInfo.email, userInfo.sub); const pfConfig = await readPlatformConfig(); await saveUserTokens( pfConfig, - userInfo.email, + userInfo.sub, { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ?? undefined, @@ -85,11 +95,12 @@ const startAuthServer = async (args: ProfileLoginOptions = {}) => { new Date( assertDefined(tokens.expiresAt, "token response missing expiresAt"), ).toISOString(), - args.platformConfig, + { platformConfig: args.platformConfig, email: userInfo.email }, ); if (args.updateCurrentUser ?? true) { - pfConfig.current_user = userInfo.email; + pfConfig.current_user = userInfo.sub; } + await removeLegacyUserAlias(pfConfig, userInfo.email, userInfo.sub, args.platformConfig); writePlatformConfig(pfConfig); res.writeHead(200, { "Content-Type": "application/json" }); @@ -160,7 +171,7 @@ async function loginAsMachineUser( args.clientId, { accessToken: tokens.accessToken }, new Date(assertDefined(tokens.expiresAt, "token response missing expiresAt")).toISOString(), - args.platformConfig, + { platformConfig: args.platformConfig }, ); if (args.updateCurrentUser ?? true) { pfConfig.current_user = args.clientId; @@ -173,19 +184,17 @@ export const loginCommand = defineAppCommand({ description: "Login to Tailor Platform.", args: z.xor([ z - .object({ + .strictObject({ profile: arg(z.string().optional(), { alias: "p", description: "Workspace profile whose platform settings should be used for login.", env: "TAILOR_PLATFORM_PROFILE", }), }) - .strict() .describe("User Login"), z - .object({ + .strictObject({ "machine-user": arg(z.literal(true), { - hiddenAlias: "machineuser", description: "Login as a platform machine user.", required: true, }), @@ -204,7 +213,6 @@ export const loginCommand = defineAppCommand({ env: "TAILOR_PLATFORM_PROFILE", }), }) - .strict() .describe("Machine User Login"), ]), run: async (args) => { diff --git a/packages/sdk/src/cli/commands/logout.test.ts b/packages/sdk/src/cli/commands/logout.test.ts index 82df89563e..e5b19ba883 100644 --- a/packages/sdk/src/cli/commands/logout.test.ts +++ b/packages/sdk/src/cli/commands/logout.test.ts @@ -15,6 +15,7 @@ import { logoutCommand } from "./logout"; const xdgTempDir = vi.hoisted(() => `/tmp/tailor-logout-${Date.now()}-${Math.random()}`); const revokeMock = vi.hoisted(() => vi.fn()); +const keyringPasswords = vi.hoisted(() => new Map()); vi.mock("xdg-basedir", () => ({ xdgConfig: xdgTempDir, @@ -22,11 +23,19 @@ vi.mock("xdg-basedir", () => ({ vi.mock("@napi-rs/keyring", () => ({ Entry: class { - setPassword() {} + private key: string; + constructor(service: string, account: string) { + this.key = `${service}:${account}`; + } + setPassword(password: string) { + keyringPasswords.set(this.key, password); + } getPassword(): string | null { - return null; + return keyringPasswords.get(this.key) ?? null; + } + deletePassword() { + keyringPasswords.delete(this.key); } - deletePassword() {} }, })); @@ -52,6 +61,7 @@ describe("logout --profile", () => { beforeEach(async () => { vi.clearAllMocks(); resetKeyringState(); + keyringPasswords.clear(); writePlatformConfig({ version: 2, min_sdk_version: "1.29.0", @@ -84,7 +94,12 @@ describe("logout --profile", () => { refreshToken: "dev-refresh-token", }, futureDate, - { platformUrl: "https://api.dev.tailor.tech", oauth2ClientId: "dev-client" }, + { + platformConfig: { + platformUrl: "https://api.dev.tailor.tech", + oauth2ClientId: "dev-client", + }, + }, ); config.current_user = "u@example.com"; writePlatformConfig(config); diff --git a/packages/sdk/src/cli/commands/logout.ts b/packages/sdk/src/cli/commands/logout.ts index 4be7acce9f..55f12107bb 100644 --- a/packages/sdk/src/cli/commands/logout.ts +++ b/packages/sdk/src/cli/commands/logout.ts @@ -16,15 +16,13 @@ import { logger } from "#/cli/shared/logger"; export const logoutCommand = defineAppCommand({ name: "logout", description: "Logout from Tailor Platform.", - args: z - .object({ - profile: arg(z.string().optional(), { - alias: "p", - description: "Workspace profile whose platform settings should be used for logout.", - env: "TAILOR_PLATFORM_PROFILE", - }), - }) - .strict(), + args: z.strictObject({ + profile: arg(z.string().optional(), { + alias: "p", + description: "Workspace profile whose platform settings should be used for logout.", + env: "TAILOR_PLATFORM_PROFILE", + }), + }), run: async (args) => { const profile = args.profile || process.env.TAILOR_PLATFORM_PROFILE; const pfConfig = await readPlatformConfig(); diff --git a/packages/sdk/src/cli/commands/machineuser/list.ts b/packages/sdk/src/cli/commands/machineuser/list.ts index d1a4373bee..df1ac09d55 100644 --- a/packages/sdk/src/cli/commands/machineuser/list.ts +++ b/packages/sdk/src/cli/commands/machineuser/list.ts @@ -93,12 +93,10 @@ export async function listMachineUsers( export const listCommand = defineAppCommand({ name: "list", description: "List all machine users in the application.", - args: z - .object({ - ...deploymentArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...paginationArgs(), + }), run: async (args) => { // Execute machineuser list logic const machineUsers = await listMachineUsers({ diff --git a/packages/sdk/src/cli/commands/machineuser/token.ts b/packages/sdk/src/cli/commands/machineuser/token.ts index 0fb4d3c8a8..933587f172 100644 --- a/packages/sdk/src/cli/commands/machineuser/token.ts +++ b/packages/sdk/src/cli/commands/machineuser/token.ts @@ -32,7 +32,7 @@ export async function getMachineUserToken( const name = await loadMachineUserName({ machineUser: options.name, profile: options.profile }); if (!name) { throw new Error( - "Machine user is required. Provide the NAME positional argument, set TAILOR_PLATFORM_MACHINE_USER_NAME, or set a profile default with 'tailor-sdk profile update --machine-user '.", + "Machine user is required. Provide the NAME positional argument, set TAILOR_PLATFORM_MACHINE_USER_NAME, or set a profile default with 'tailor profile update --machine-user '.", ); } @@ -84,16 +84,14 @@ export async function getMachineUserToken( export const tokenCommand = defineAppCommand({ name: "token", description: "Get an access token for a machine user.", - args: z - .object({ - ...deploymentArgs, - name: arg(z.string().optional(), { - positional: true, - description: "Machine user name. Falls back to the active profile's default machine user.", - env: "TAILOR_PLATFORM_MACHINE_USER_NAME", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + name: arg(z.string().optional(), { + positional: true, + description: "Machine user name. Falls back to the active profile's default machine user.", + env: "TAILOR_PLATFORM_MACHINE_USER_NAME", + }), + }), run: async (args) => { // Execute machineuser token logic const token = await getMachineUserToken({ diff --git a/packages/sdk/src/cli/commands/oauth2client/get.ts b/packages/sdk/src/cli/commands/oauth2client/get.ts index 013bbb56c7..e53e63aecc 100644 --- a/packages/sdk/src/cli/commands/oauth2client/get.ts +++ b/packages/sdk/src/cli/commands/oauth2client/get.ts @@ -64,15 +64,13 @@ export async function getOAuth2Client( export const getCommand = defineAppCommand({ name: "get", description: "Get OAuth2 client credentials (including client secret).", - args: z - .object({ - ...deploymentArgs, - name: arg(z.string(), { - positional: true, - description: "OAuth2 client name", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + name: arg(z.string(), { + positional: true, + description: "OAuth2 client name", + }), + }), run: async (args) => { const credentials = await getOAuth2Client({ name: args.name, diff --git a/packages/sdk/src/cli/commands/oauth2client/list.ts b/packages/sdk/src/cli/commands/oauth2client/list.ts index 3cab7deb97..a78e65dacf 100644 --- a/packages/sdk/src/cli/commands/oauth2client/list.ts +++ b/packages/sdk/src/cli/commands/oauth2client/list.ts @@ -62,12 +62,10 @@ export async function listOAuth2Clients( export const listCommand = defineAppCommand({ name: "list", description: "List all OAuth2 clients in the application.", - args: z - .object({ - ...deploymentArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...paginationArgs(), + }), run: async (args) => { const oauth2Clients = await listOAuth2Clients({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/open.ts b/packages/sdk/src/cli/commands/open.ts index 5ac933b1f1..bc8c921e51 100644 --- a/packages/sdk/src/cli/commands/open.ts +++ b/packages/sdk/src/cli/commands/open.ts @@ -9,11 +9,9 @@ import { logger } from "#/cli/shared/logger"; export const openCommand = defineAppCommand({ name: "open", description: "Open Tailor Platform Console.", - args: z - .object({ - ...deploymentArgs, - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + }), run: async (args) => { const workspaceId = await loadWorkspaceId({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/organization/folder/create.ts b/packages/sdk/src/cli/commands/organization/folder/create.ts index 1aa96544c6..0240817632 100644 --- a/packages/sdk/src/cli/commands/organization/folder/create.ts +++ b/packages/sdk/src/cli/commands/organization/folder/create.ts @@ -9,6 +9,7 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; import { folderInfo, type FolderInfo } from "../transform"; +// strip unknown keys const createFolderOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), parentFolderId: z.string().optional(), @@ -47,18 +48,16 @@ export async function createFolder(options: CreateFolderOptions): Promise { await assertWritable(); const folder = await createFolder({ diff --git a/packages/sdk/src/cli/commands/organization/folder/delete.ts b/packages/sdk/src/cli/commands/organization/folder/delete.ts index daf3eeddf5..08cd3a63e9 100644 --- a/packages/sdk/src/cli/commands/organization/folder/delete.ts +++ b/packages/sdk/src/cli/commands/organization/folder/delete.ts @@ -8,6 +8,7 @@ import { prompt } from "#/cli/shared/prompt"; import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; +// strip unknown keys const deleteFolderOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), folderId: z.uuid({ message: "folder-id must be a valid UUID" }), @@ -38,13 +39,11 @@ export async function deleteFolder(options: DeleteFolderOptions): Promise export const deleteCommand = defineAppCommand({ name: "delete", description: "Delete a folder from an organization.", - args: z - .object({ - ...organizationArgs, - ...folderArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...organizationArgs, + ...folderArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable(); const accessToken = await loadAccessToken(); diff --git a/packages/sdk/src/cli/commands/organization/folder/get.ts b/packages/sdk/src/cli/commands/organization/folder/get.ts index 86e9b29984..f813606c0a 100644 --- a/packages/sdk/src/cli/commands/organization/folder/get.ts +++ b/packages/sdk/src/cli/commands/organization/folder/get.ts @@ -8,6 +8,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { folderInfo, type FolderInfo } from "../transform"; +// strip unknown keys const getFolderOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), folderId: z.uuid({ message: "folder-id must be a valid UUID" }), @@ -44,12 +45,10 @@ export async function getFolder(options: GetFolderOptions): Promise export const getCommand = defineAppCommand({ name: "get", description: "Show detailed information about a folder.", - args: z - .object({ - ...organizationArgs, - ...folderArgs, - }) - .strict(), + args: z.strictObject({ + ...organizationArgs, + ...folderArgs, + }), run: async (args) => { const folder = await getFolder({ organizationId: args["organization-id"], diff --git a/packages/sdk/src/cli/commands/organization/folder/list.ts b/packages/sdk/src/cli/commands/organization/folder/list.ts index fe35ab3459..59f9496aa7 100644 --- a/packages/sdk/src/cli/commands/organization/folder/list.ts +++ b/packages/sdk/src/cli/commands/organization/folder/list.ts @@ -8,6 +8,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { folderListInfo, type FolderListInfo } from "../transform"; +// strip unknown keys const listFoldersOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), parentFolderId: z.string().optional(), @@ -54,15 +55,13 @@ export async function listFolders(options: ListFoldersOptions): Promise { const folders = await listFolders({ organizationId: args["organization-id"], diff --git a/packages/sdk/src/cli/commands/organization/folder/update.ts b/packages/sdk/src/cli/commands/organization/folder/update.ts index ee61085505..e147fed601 100644 --- a/packages/sdk/src/cli/commands/organization/folder/update.ts +++ b/packages/sdk/src/cli/commands/organization/folder/update.ts @@ -9,6 +9,7 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; import { folderInfo, type FolderInfo } from "../transform"; +// strip unknown keys const updateFolderOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), folderId: z.uuid({ message: "folder-id must be a valid UUID" }), @@ -47,16 +48,14 @@ export async function updateFolder(options: UpdateFolderOptions): Promise { await assertWritable(); const folder = await updateFolder({ diff --git a/packages/sdk/src/cli/commands/organization/get.ts b/packages/sdk/src/cli/commands/organization/get.ts index b57062ea2b..9ec49da599 100644 --- a/packages/sdk/src/cli/commands/organization/get.ts +++ b/packages/sdk/src/cli/commands/organization/get.ts @@ -8,6 +8,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { organizationInfo, type OrganizationInfo } from "./transform"; +// strip unknown keys const getOrganizationOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), }); @@ -42,11 +43,9 @@ export async function getOrganization(options: GetOrganizationOptions): Promise< export const getCommand = defineAppCommand({ name: "get", description: "Show detailed information about an organization.", - args: z - .object({ - ...organizationArgs, - }) - .strict(), + args: z.strictObject({ + ...organizationArgs, + }), run: async (args) => { const organization = await getOrganization({ organizationId: args["organization-id"], diff --git a/packages/sdk/src/cli/commands/organization/list.ts b/packages/sdk/src/cli/commands/organization/list.ts index 5f780f90e6..4ef3e3832a 100644 --- a/packages/sdk/src/cli/commands/organization/list.ts +++ b/packages/sdk/src/cli/commands/organization/list.ts @@ -35,14 +35,12 @@ export async function listOrganizations( export const listCommand = defineAppCommand({ name: "list", description: "List organizations you belong to.", - args: z - .object({ - limit: arg(positiveIntArg.optional(), { - alias: "l", - description: "Maximum number of organizations to list", - }), - }) - .strict(), + args: z.strictObject({ + limit: arg(positiveIntArg.optional(), { + alias: "l", + description: "Maximum number of organizations to list", + }), + }), run: async (args) => { const organizations = await listOrganizations({ limit: args.limit }); logger.out(organizations); diff --git a/packages/sdk/src/cli/commands/organization/tree.ts b/packages/sdk/src/cli/commands/organization/tree.ts index 5c0f931b94..f6d13f6581 100644 --- a/packages/sdk/src/cli/commands/organization/tree.ts +++ b/packages/sdk/src/cli/commands/organization/tree.ts @@ -162,19 +162,17 @@ export async function organizationTree( export const treeCommand = defineAppCommand({ name: "tree", description: "Display organization folder hierarchy as a tree.", - args: z - .object({ - "organization-id": arg(z.string().optional(), { - alias: "o", - description: "Organization ID (show all if omitted)", - env: "TAILOR_PLATFORM_ORGANIZATION_ID", - }), - depth: arg(positiveIntArg.optional(), { - alias: "d", - description: "Maximum folder depth to display", - }), - }) - .strict(), + args: z.strictObject({ + "organization-id": arg(z.string().optional(), { + alias: "o", + description: "Organization ID (show all if omitted)", + env: "TAILOR_PLATFORM_ORGANIZATION_ID", + }), + depth: arg(positiveIntArg.optional(), { + alias: "d", + description: "Maximum folder depth to display", + }), + }), run: async (args) => { const accessToken = await loadAccessToken(); const client = await initOperatorClient(accessToken); diff --git a/packages/sdk/src/cli/commands/organization/update.ts b/packages/sdk/src/cli/commands/organization/update.ts index b679313d9e..7a9a66692a 100644 --- a/packages/sdk/src/cli/commands/organization/update.ts +++ b/packages/sdk/src/cli/commands/organization/update.ts @@ -9,6 +9,7 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; import { organizationInfo, type OrganizationInfo } from "./transform"; +// strip unknown keys const updateOrganizationOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), name: z.string().min(1, "Name must not be empty"), @@ -47,15 +48,13 @@ export async function updateOrganization( export const updateCommand = defineAppCommand({ name: "update", description: "Update an organization's name.", - args: z - .object({ - ...organizationArgs, - name: arg(z.string(), { - alias: "n", - description: "New organization name", - }), - }) - .strict(), + args: z.strictObject({ + ...organizationArgs, + name: arg(z.string(), { + alias: "n", + description: "New organization name", + }), + }), run: async (args) => { await assertWritable(); const organization = await updateOrganization({ diff --git a/packages/sdk/src/cli/commands/plugin/index.ts b/packages/sdk/src/cli/commands/plugin/index.ts new file mode 100644 index 0000000000..c877f86755 --- /dev/null +++ b/packages/sdk/src/cli/commands/plugin/index.ts @@ -0,0 +1,13 @@ +import { defineCommand, runCommand } from "politty"; +import { listCommand } from "./list"; + +export const pluginCommand = defineCommand({ + name: "plugin", + description: "Manage and inspect CLI plugins (beta).", + subCommands: { + list: listCommand, + }, + async run() { + await runCommand(listCommand, []); + }, +}); diff --git a/packages/sdk/src/cli/commands/plugin/list.ts b/packages/sdk/src/cli/commands/plugin/list.ts new file mode 100644 index 0000000000..3fe2133e54 --- /dev/null +++ b/packages/sdk/src/cli/commands/plugin/list.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import { BUILTIN_COMMAND_NAMES } from "#/cli/shared/builtin-commands"; +import { defineAppCommand } from "#/cli/shared/command"; +import { logger } from "#/cli/shared/logger"; +import { readPackageJson } from "#/cli/shared/package-json"; +import { listPlugins } from "#/cli/shared/plugin"; +import ml from "#/utils/multiline"; + +interface PluginListItem { + name: string; + source: "node_modules" | "path"; + path: string; + /** True when a builtin command of the same name shadows this plugin. */ + shadowed: boolean; +} + +export const listCommand = defineAppCommand({ + name: "list", + description: + "List discovered plugins (executables named `-` on PATH or node_modules/.bin).", + args: z.strictObject({}), + run: async () => { + const pkg = await readPackageJson(); + const cliName = Object.keys(pkg.bin ?? {})[0] || "tailor"; + const builtins = new Set(BUILTIN_COMMAND_NAMES); + + const plugins = listPlugins(cliName); + if (plugins.length === 0) { + logger.info(ml` + No plugins found. + Install an executable named "${cliName}-" on your PATH or in node_modules/.bin, + then run it with "${cliName} ". + `); + if (logger.jsonMode) { + logger.out([]); + } + return; + } + + const items: PluginListItem[] = plugins.map((plugin) => ({ + name: plugin.name, + source: plugin.source, + path: plugin.path, + shadowed: builtins.has(plugin.name), + })); + + logger.out(items); + + for (const item of items) { + if (item.shadowed) { + logger.warn( + `Plugin "${cliName}-${item.name}" is shadowed by the builtin "${cliName} ${item.name}" command and will not be dispatched.`, + ); + } + } + }, +}); diff --git a/packages/sdk/src/cli/commands/profile/create.test.ts b/packages/sdk/src/cli/commands/profile/create.test.ts new file mode 100644 index 0000000000..98131e8a52 --- /dev/null +++ b/packages/sdk/src/cli/commands/profile/create.test.ts @@ -0,0 +1,124 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import { runCommand } from "politty"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { readPlatformConfig, writePlatformConfig } from "#/cli/shared/context"; +import { silenceLogger } from "#/cli/shared/test-helpers/silence-logger"; +import { resetKeyringState } from "#/cli/shared/token-store"; +import { createCommand } from "./create"; +import type * as ClientModule from "#/cli/shared/client"; + +const xdgTempDir = vi.hoisted(() => `/tmp/tailor-profile-create-${Date.now()}-${Math.random()}`); +const keyringPasswords = vi.hoisted(() => new Map()); +const validUUID = "12345678-1234-4abc-8def-123456789012"; + +vi.mock("xdg-basedir", () => ({ xdgConfig: xdgTempDir })); + +vi.mock("@napi-rs/keyring", () => ({ + Entry: class { + private key: string; + constructor(service: string, account: string) { + this.key = `${service}:${account}`; + } + setPassword(password: string) { + keyringPasswords.set(this.key, password); + } + getPassword(): string | null { + return keyringPasswords.get(this.key) ?? null; + } + deletePassword() { + keyringPasswords.delete(this.key); + } + }, +})); + +const clientMocks = vi.hoisted(() => ({ + fetchUserInfo: vi.fn(), + refreshToken: vi.fn(), + initOperatorClient: vi.fn(), + fetchAll: vi.fn(), +})); + +vi.mock("#/cli/shared/client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchUserInfo: clientMocks.fetchUserInfo, + initOperatorClient: clientMocks.initOperatorClient, + fetchAll: clientMocks.fetchAll, + initOAuth2Client: () => ({ refreshToken: clientMocks.refreshToken }), + }; +}); + +beforeAll(() => { + fs.mkdirSync(xdgTempDir, { recursive: true }); +}); + +afterAll(() => { + fs.rmSync(xdgTempDir, { recursive: true, force: true }); +}); + +describe("profile create with a migrating legacy email user", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetKeyringState(); + keyringPasswords.clear(); + vi.stubEnv("TAILOR_PLATFORM_PROFILE", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); + if (fs.existsSync(configPath)) fs.rmSync(configPath); + }); + + test("writes the resolved subject as profile.user when the legacy email key migrates", async () => { + using _logger = silenceLogger("out", "success"); + + const pastDate = new Date(Date.now() - 3600 * 1000).toISOString(); + clientMocks.refreshToken.mockResolvedValue({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + expiresAt: Date.now() + 3600 * 1000, + }); + clientMocks.fetchUserInfo.mockResolvedValue({ + sub: "platform-user-sub", + email: "legacy@example.com", + }); + clientMocks.initOperatorClient.mockResolvedValue({ + listWorkspaces: vi.fn(), + }); + clientMocks.fetchAll.mockResolvedValue([{ id: validUUID }]); + + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "legacy@example.com": { + access_token: "expired-access-token", + refresh_token: "refresh", + token_expires_at: pastDate, + storage: "file", + }, + }, + profiles: {}, + current_user: "legacy@example.com", + }); + + await runCommand(createCommand, [ + "myprofile", + "--user", + "legacy@example.com", + "--workspace-id", + validUUID, + ]); + + const config = await readPlatformConfig(); + expect(config.profiles.myprofile?.user).toBe("platform-user-sub"); + expect(config.users["platform-user-sub"]).toMatchObject({ storage: "keyring" }); + expect(keyringPasswords.get("tailor-platform-cli:platform-user-sub")).toBe( + JSON.stringify({ accessToken: "new-access-token", refreshToken: "new-refresh-token" }), + ); + expect(config.users["legacy@example.com"]).toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/cli/commands/profile/create.ts b/packages/sdk/src/cli/commands/profile/create.ts index b84d7bef18..662bf9898f 100644 --- a/packages/sdk/src/cli/commands/profile/create.ts +++ b/packages/sdk/src/cli/commands/profile/create.ts @@ -9,47 +9,45 @@ import type { ProfileInfo } from "./types"; export const createCommand = defineAppCommand({ name: "create", description: "Create a new profile.", - args: z - .object({ - name: arg(z.string(), { - positional: true, - description: "Profile name", - }), - user: arg(z.string(), { - alias: "u", - description: "User email", - }), - "workspace-id": arg(z.string(), { - alias: "w", - description: "Workspace ID", - }), - permission: arg(z.enum(["write", "read"]).default("write"), { - description: - "Profile permission. 'read' blocks all write commands while the profile is active.", - }), - "machine-user": arg(z.string().optional(), { - alias: "m", - description: - "Default machine user name for application-data commands (query, workflow start, function test-run, machineuser token).", - }), - "machine-user-override": arg(z.enum(["allow", "deny"]).optional(), { - description: - "Whether the command line or TAILOR_PLATFORM_MACHINE_USER_NAME may override the profile's machine user. 'deny' requires --machine-user.", - }), - "platform-url": arg(z.url().optional(), { - description: "Platform API base URL for this profile.", - env: "TAILOR_PLATFORM_URL", - }), - "oauth2-client-id": arg(z.string().optional(), { - description: "OAuth2 client ID for logging in to this profile's platform.", - env: "TAILOR_PLATFORM_OAUTH2_CLIENT_ID", - }), - "console-url": arg(z.url().optional(), { - description: "Console base URL for this profile.", - env: "TAILOR_PLATFORM_CONSOLE_URL", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + positional: true, + description: "Profile name", + }), + user: arg(z.string(), { + alias: "u", + description: "User email address or machine user client ID", + }), + "workspace-id": arg(z.string(), { + alias: "w", + description: "Workspace ID", + }), + permission: arg(z.enum(["write", "read"]).default("write"), { + description: + "Profile permission. 'read' blocks all write commands while the profile is active.", + }), + "machine-user": arg(z.string().optional(), { + alias: "m", + description: + "Default machine user name for application-data commands (query, workflow start, function test-run, machineuser token).", + }), + "machine-user-override": arg(z.enum(["allow", "deny"]).optional(), { + description: + "Whether the command line or TAILOR_PLATFORM_MACHINE_USER_NAME may override the profile's machine user. 'deny' requires --machine-user.", + }), + "platform-url": arg(z.url().optional(), { + description: "Platform API base URL for this profile.", + env: "TAILOR_PLATFORM_URL", + }), + "oauth2-client-id": arg(z.string().optional(), { + description: "OAuth2 client ID for logging in to this profile's platform.", + env: "TAILOR_PLATFORM_OAUTH2_CLIENT_ID", + }), + "console-url": arg(z.url().optional(), { + description: "Console base URL for this profile.", + env: "TAILOR_PLATFORM_CONSOLE_URL", + }), + }), run: async (args) => { if (args["machine-user-override"] === "deny" && !args["machine-user"]) { throw new Error("--machine-user-override deny requires --machine-user."); @@ -70,7 +68,11 @@ export const createCommand = defineAppCommand({ }; const platformConfig = Object.keys(platformConfigInput).length > 0 ? platformConfigInput : undefined; - const token = await fetchLatestToken(config, args.user, platformConfig); + const { accessToken: token, user: resolvedUser } = await fetchLatestToken( + config, + args.user, + platformConfig, + ); // Check if workspace exists const client = await initOperatorClient(token, platformConfig); @@ -89,7 +91,7 @@ export const createCommand = defineAppCommand({ // Create new profile config.profiles[args.name] = { - user: args.user, + user: resolvedUser, workspace_id: args["workspace-id"], ...(args.permission === "read" ? { readonly: true } : {}), ...(args["machine-user"] ? { machine_user: args["machine-user"] } : {}), @@ -109,7 +111,7 @@ export const createCommand = defineAppCommand({ // Show profile info const profileInfo: ProfileInfo = { name: args.name, - user: args.user, + user: resolvedUser, workspaceId: args["workspace-id"], permission: args.permission, ...(args["machine-user"] diff --git a/packages/sdk/src/cli/commands/profile/delete.ts b/packages/sdk/src/cli/commands/profile/delete.ts index 252786e9ec..f7d70ea25f 100644 --- a/packages/sdk/src/cli/commands/profile/delete.ts +++ b/packages/sdk/src/cli/commands/profile/delete.ts @@ -7,14 +7,12 @@ import { logger } from "#/cli/shared/logger"; export const deleteCommand = defineAppCommand({ name: "delete", description: "Delete a profile.", - args: z - .object({ - name: arg(z.string(), { - positional: true, - description: "Profile name", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + positional: true, + description: "Profile name", + }), + }), run: async (args) => { const config = await readPlatformConfig(); diff --git a/packages/sdk/src/cli/commands/profile/list.ts b/packages/sdk/src/cli/commands/profile/list.ts index c755ce79a6..f7b84ccf40 100644 --- a/packages/sdk/src/cli/commands/profile/list.ts +++ b/packages/sdk/src/cli/commands/profile/list.ts @@ -9,7 +9,7 @@ import type { ProfileInfo } from "./types"; export const listCommand = defineAppCommand({ name: "list", description: "List all profiles.", - args: z.object({}).strict(), + args: z.strictObject({}), run: async () => { const config = await readPlatformConfig(); const jsonOutput = logger.jsonMode; @@ -18,7 +18,7 @@ export const listCommand = defineAppCommand({ if (profiles.length === 0) { logger.info(ml` No profiles found. - Please create a profile first using 'tailor-sdk profile create' command. + Please create a profile first using 'tailor profile create' command. `); if (jsonOutput) { logger.out([]); diff --git a/packages/sdk/src/cli/commands/profile/update.test.ts b/packages/sdk/src/cli/commands/profile/update.test.ts index 8487111e5b..331530606f 100644 --- a/packages/sdk/src/cli/commands/profile/update.test.ts +++ b/packages/sdk/src/cli/commands/profile/update.test.ts @@ -109,7 +109,10 @@ describe("profile update --permission", () => { test("performs remote validation when --user is also passed (permission does not bypass it)", async () => { using _logger = silenceLogger("out", "success"); - vi.mocked(fetchLatestToken).mockResolvedValue("mock-token"); + vi.mocked(fetchLatestToken).mockResolvedValue({ + accessToken: "mock-token", + user: "new@example.com", + }); vi.mocked(fetchAll).mockResolvedValue([{ id: validUUID }]); vi.mocked(initOperatorClient).mockResolvedValue({ listWorkspaces: vi.fn(), @@ -133,6 +136,41 @@ describe("profile update --permission", () => { expect(config.profiles.rw?.user).toBe("new@example.com"); expect(config.profiles.rw?.readonly).toBe(true); }); + + test("persists the resolved subject when only --workspace-id is updated for a legacy email user", async () => { + using _logger = silenceLogger("out", "success"); + const newUUID = "abcdef12-3456-4abc-8def-abcdef123456"; + vi.mocked(fetchLatestToken).mockResolvedValue({ + accessToken: "mock-token", + user: "platform-user-sub", + }); + vi.mocked(fetchAll).mockResolvedValue([{ id: newUUID }]); + vi.mocked(initOperatorClient).mockResolvedValue({ + listWorkspaces: vi.fn(), + } as unknown as Awaited>); + + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + rw: { user: "legacy@example.com", workspace_id: validUUID }, + }, + current_user: null, + }); + + await runCommand(updateCommand, ["rw", "--workspace-id", newUUID]); + + expect(vi.mocked(fetchLatestToken)).toHaveBeenCalledWith( + expect.anything(), + "legacy@example.com", + undefined, + ); + + const config = await readPlatformConfig(); + expect(config.profiles.rw?.user).toBe("platform-user-sub"); + expect(config.profiles.rw?.workspace_id).toBe(newUUID); + }); }); describe("profile update --machine-user", () => { @@ -174,7 +212,10 @@ describe("profile update --platform", () => { vi.stubEnv("TAILOR_PLATFORM_URL", undefined); vi.stubEnv("TAILOR_PLATFORM_OAUTH2_CLIENT_ID", undefined); vi.stubEnv("TAILOR_PLATFORM_CONSOLE_URL", undefined); - vi.mocked(fetchLatestToken).mockResolvedValue("mock-token"); + vi.mocked(fetchLatestToken).mockResolvedValue({ + accessToken: "mock-token", + user: "u@example.com", + }); vi.mocked(fetchAll).mockResolvedValue([{ id: validUUID }]); vi.mocked(initOperatorClient).mockResolvedValue({ listWorkspaces: vi.fn(), diff --git a/packages/sdk/src/cli/commands/profile/update.ts b/packages/sdk/src/cli/commands/profile/update.ts index 1ff6cb2efb..dc38846107 100644 --- a/packages/sdk/src/cli/commands/profile/update.ts +++ b/packages/sdk/src/cli/commands/profile/update.ts @@ -14,45 +14,43 @@ import type { ProfileInfo } from "./types"; export const updateCommand = defineAppCommand({ name: "update", description: "Update profile properties.", - args: z - .object({ - name: arg(z.string(), { - positional: true, - description: "Profile name", - }), - user: arg(z.string().optional(), { - alias: "u", - description: "New user email", - }), - "workspace-id": arg(z.string().optional(), { - alias: "w", - description: "New workspace ID", - }), - permission: arg(z.enum(["write", "read"]).optional(), { - description: - "Profile permission. 'read' blocks all write commands; 'write' lifts the restriction.", - }), - "machine-user": arg(z.string().optional(), { - alias: "m", - description: - "Default machine user name for application-data commands (query, workflow start, function test-run, machineuser token). Pass an empty string to clear.", - }), - "machine-user-override": arg(z.enum(["allow", "deny"]).optional(), { - description: - "Whether the command line or TAILOR_PLATFORM_MACHINE_USER_NAME may override the profile's machine user. 'deny' requires --machine-user; 'allow' lifts the restriction.", - }), - "platform-url": arg(z.union([z.url(), z.literal("")]).optional(), { - description: "Platform API base URL for this profile. Pass an empty string to clear.", - }), - "oauth2-client-id": arg(z.string().optional(), { - description: - "OAuth2 client ID for logging in to this profile's platform. Pass an empty string to clear.", - }), - "console-url": arg(z.union([z.url(), z.literal("")]).optional(), { - description: "Console base URL for this profile. Pass an empty string to clear.", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + positional: true, + description: "Profile name", + }), + user: arg(z.string().optional(), { + alias: "u", + description: "New user email address or machine user client ID", + }), + "workspace-id": arg(z.string().optional(), { + alias: "w", + description: "New workspace ID", + }), + permission: arg(z.enum(["write", "read"]).optional(), { + description: + "Profile permission. 'read' blocks all write commands; 'write' lifts the restriction.", + }), + "machine-user": arg(z.string().optional(), { + alias: "m", + description: + "Default machine user name for application-data commands (query, workflow start, function test-run, machineuser token). Pass an empty string to clear.", + }), + "machine-user-override": arg(z.enum(["allow", "deny"]).optional(), { + description: + "Whether the command line or TAILOR_PLATFORM_MACHINE_USER_NAME may override the profile's machine user. 'deny' requires --machine-user; 'allow' lifts the restriction.", + }), + "platform-url": arg(z.union([z.url(), z.literal("")]).optional(), { + description: "Platform API base URL for this profile. Pass an empty string to clear.", + }), + "oauth2-client-id": arg(z.string().optional(), { + description: + "OAuth2 client ID for logging in to this profile's platform. Pass an empty string to clear.", + }), + "console-url": arg(z.union([z.url(), z.literal("")]).optional(), { + description: "Console base URL for this profile. Pass an empty string to clear.", + }), + }), run: async (args) => { const config = await readPlatformConfig(); @@ -79,6 +77,7 @@ export const updateCommand = defineAppCommand({ const newUser = args.user || oldUser; const oldWorkspaceId = profile.workspace_id; const newWorkspaceId = args["workspace-id"] || oldWorkspaceId; + let resolvedUser = newUser; // Compute the final machine_user and machine_user_override to validate the combination. const finalMachineUser = @@ -128,10 +127,11 @@ export const updateCommand = defineAppCommand({ args["platform-url"] !== undefined ) { // Check if user exists - const token = await fetchLatestToken(config, newUser, tokenLookupPlatformConfig); + const refreshed = await fetchLatestToken(config, newUser, tokenLookupPlatformConfig); + resolvedUser = refreshed.user; // Check if workspace exists - const client = await initOperatorClient(token, finalPlatformConfig); + const client = await initOperatorClient(refreshed.accessToken, finalPlatformConfig); const workspaces = await fetchAll(async (pageToken, maxPageSize) => { const { workspaces, nextPageToken } = await client.listWorkspaces({ pageToken, @@ -146,7 +146,7 @@ export const updateCommand = defineAppCommand({ } // Update properties - profile.user = newUser; + profile.user = resolvedUser; profile.workspace_id = newWorkspaceId; if (args.permission === "read") { profile.readonly = true; @@ -194,7 +194,7 @@ export const updateCommand = defineAppCommand({ // Show profile info const profileInfo: ProfileInfo = { name: args.name, - user: newUser, + user: resolvedUser, workspaceId: newWorkspaceId, permission: profile.readonly === true ? "read" : "write", ...(profile.machine_user diff --git a/packages/sdk/src/cli/commands/remove.ts b/packages/sdk/src/cli/commands/remove.ts index 50bf9a9c7e..8f97fd7454 100644 --- a/packages/sdk/src/cli/commands/remove.ts +++ b/packages/sdk/src/cli/commands/remove.ts @@ -173,12 +173,10 @@ export async function remove(options?: RemoveOptions): Promise { export const removeCommand = defineAppCommand({ name: "remove", description: "Remove all resources managed by the application from the workspace.", - args: z - .object({ - ...deploymentArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const { client, workspaceId, application, config } = await loadOptions({ diff --git a/packages/sdk/src/cli/commands/secret/create.ts b/packages/sdk/src/cli/commands/secret/create.ts index 4c09f810ee..f72ea18e6a 100644 --- a/packages/sdk/src/cli/commands/secret/create.ts +++ b/packages/sdk/src/cli/commands/secret/create.ts @@ -13,13 +13,11 @@ import { checkVaultManaged, releaseVaultOwnership } from "./check-vault-managed" export const createSecretCommand = defineAppCommand({ name: "create", description: "Create a secret in a vault.", - args: z - .object({ - ...workspaceArgs, - ...secretValueArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...secretValueArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/secret/delete.ts b/packages/sdk/src/cli/commands/secret/delete.ts index b69f3e6cc9..86ccad56b4 100644 --- a/packages/sdk/src/cli/commands/secret/delete.ts +++ b/packages/sdk/src/cli/commands/secret/delete.ts @@ -13,13 +13,11 @@ import { checkVaultManaged, releaseVaultOwnership } from "./check-vault-managed" export const deleteSecretCommand = defineAppCommand({ name: "delete", description: "Delete a secret in a vault.", - args: z - .object({ - ...workspaceArgs, - ...secretIdentifyArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...secretIdentifyArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/secret/list.ts b/packages/sdk/src/cli/commands/secret/list.ts index fa97d8c6c1..9d9c1a4015 100644 --- a/packages/sdk/src/cli/commands/secret/list.ts +++ b/packages/sdk/src/cli/commands/secret/list.ts @@ -67,13 +67,11 @@ async function secretList(options: SecretListOptions): Promise { export const listSecretCommand = defineAppCommand({ name: "list", description: "List all secrets in a vault.", - args: z - .object({ - ...workspaceArgs, - ...vaultArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...vaultArgs, + ...paginationArgs(), + }), run: async (args) => { try { const secrets = await secretList({ diff --git a/packages/sdk/src/cli/commands/secret/update.ts b/packages/sdk/src/cli/commands/secret/update.ts index 94c53441b5..0a58e1ab5e 100644 --- a/packages/sdk/src/cli/commands/secret/update.ts +++ b/packages/sdk/src/cli/commands/secret/update.ts @@ -13,13 +13,11 @@ import { checkVaultManaged, releaseVaultOwnership } from "./check-vault-managed" export const updateSecretCommand = defineAppCommand({ name: "update", description: "Update a secret in a vault.", - args: z - .object({ - ...workspaceArgs, - ...secretValueArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...secretValueArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/secret/vault/create.ts b/packages/sdk/src/cli/commands/secret/vault/create.ts index d5bd0a9b66..4cbba85e5f 100644 --- a/packages/sdk/src/cli/commands/secret/vault/create.ts +++ b/packages/sdk/src/cli/commands/secret/vault/create.ts @@ -11,12 +11,10 @@ import { nameArgs } from "./args"; export const createCommand = defineAppCommand({ name: "create", description: "Create a new Secret Manager vault.", - args: z - .object({ - ...workspaceArgs, - ...nameArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...nameArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/secret/vault/delete.ts b/packages/sdk/src/cli/commands/secret/vault/delete.ts index 06554e52ab..162ec2ef31 100644 --- a/packages/sdk/src/cli/commands/secret/vault/delete.ts +++ b/packages/sdk/src/cli/commands/secret/vault/delete.ts @@ -13,13 +13,11 @@ import { nameArgs } from "./args"; export const deleteCommand = defineAppCommand({ name: "delete", description: "Delete a Secret Manager vault.", - args: z - .object({ - ...workspaceArgs, - ...nameArgs, - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...nameArgs, + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ diff --git a/packages/sdk/src/cli/commands/secret/vault/list.ts b/packages/sdk/src/cli/commands/secret/vault/list.ts index 84432301da..866aad478e 100644 --- a/packages/sdk/src/cli/commands/secret/vault/list.ts +++ b/packages/sdk/src/cli/commands/secret/vault/list.ts @@ -63,12 +63,10 @@ async function vaultList(options?: VaultListOptions): Promise { export const listCommand = defineAppCommand({ name: "list", description: "List all Secret Manager vaults in the workspace.", - args: z - .object({ - ...workspaceArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...paginationArgs(), + }), run: async (args) => { const vaults = await vaultList({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/setup/branch.workflow.yml b/packages/sdk/src/cli/commands/setup/branch.workflow.yml index 6dee1b6ee4..4561b31b1c 100644 --- a/packages/sdk/src/cli/commands/setup/branch.workflow.yml +++ b/packages/sdk/src/cli/commands/setup/branch.workflow.yml @@ -164,8 +164,8 @@ jobs: return normalized; } - const headTarget = lockTarget(".github/tailor-sdk.lock"); - const baseTarget = lockTarget(".tailor-erd-base/.github/tailor-sdk.lock"); + const headTarget = lockTarget(".github/tailor.lock"); + const baseTarget = lockTarget(".tailor-erd-base/.github/tailor.lock"); const baseAppDir = normalizeDir(baseTarget?.inputs?.dir, "base setup lock"); const namespaces = [ ...new Set([ @@ -323,12 +323,12 @@ jobs: run: | set -euo pipefail - run_tailor_sdk() { + run_tailor_cli() { case "__PACKAGE_MANAGER__" in - pnpm) pnpm exec tailor-sdk "$@" ;; - npm) npx tailor-sdk "$@" ;; - yarn) yarn tailor-sdk "$@" ;; - bun) bunx tailor-sdk "$@" ;; + pnpm) pnpm exec tailor "$@" ;; + npm) npx tailor "$@" ;; + yarn) yarn tailor "$@" ;; + bun) bunx tailor "$@" ;; *) echo "::error::Unsupported package-manager '__PACKAGE_MANAGER__' (expected pnpm, npm, yarn, or bun)" exit 1 @@ -349,7 +349,7 @@ jobs: esac } - tailor_sdk_bin="$( + tailor_cli_bin="$( run_head_node - <<'NODE' const path = require("node:path"); const { createRequire } = require("node:module"); @@ -381,12 +381,12 @@ jobs: NODE )" - run_head_tailor_sdk_bin() { + run_head_tailor_cli_bin() { local command_cwd="$1" shift run_head_cli_env() { - env TAILOR_SDK_BIN="$tailor_sdk_bin" TAILOR_SDK_CWD="$command_cwd" "$@" + env TAILOR_CLI_BIN="$tailor_cli_bin" TAILOR_CLI_CWD="$command_cwd" "$@" } ( @@ -404,12 +404,12 @@ jobs: ) } - run_base_tailor_sdk_bin() { + run_base_tailor_cli_bin() { local command_cwd="$1" shift run_head_cli_env() { - env TAILOR_SDK_BIN="$tailor_sdk_bin" TAILOR_SDK_CWD="$command_cwd" "$@" + env TAILOR_CLI_BIN="$tailor_cli_bin" TAILOR_CLI_CWD="$command_cwd" "$@" } ( @@ -431,7 +431,7 @@ jobs: base_output="$RUNNER_TEMP/tailor-erd/base" preview_output="$RUNNER_TEMP/tailor-erd/preview" dts_output="$RUNNER_TEMP/tailor-erd/dts" - head_cli_runner="$RUNNER_TEMP/tailor-erd/run-head-tailor-sdk.mjs" + head_cli_runner="$RUNNER_TEMP/tailor-erd/run-head-cli.mjs" head_log="$RUNNER_TEMP/tailor-erd/head-$NAMESPACE.log" base_log="$RUNNER_TEMP/tailor-erd/base-$NAMESPACE.log" rm -rf "$head_output" "$base_output" "$preview_output" "$dts_output" @@ -439,10 +439,10 @@ jobs: cat > "$head_cli_runner" <<'NODE' import { pathToFileURL } from "node:url"; - const cliBin = process.env.TAILOR_SDK_BIN; - const commandCwd = process.env.TAILOR_SDK_CWD; + const cliBin = process.env.TAILOR_CLI_BIN; + const commandCwd = process.env.TAILOR_CLI_CWD; if (!cliBin || !commandCwd) { - throw new Error("TAILOR_SDK_BIN and TAILOR_SDK_CWD are required."); + throw new Error("TAILOR_CLI_BIN and TAILOR_CLI_CWD are required."); } process.chdir(commandCwd); @@ -463,7 +463,7 @@ jobs: elif ! ( cd "$GITHUB_WORKSPACE/$APP_DIR" || exit 1 TAILOR_PLATFORM_SDK_DTS_PATH="$dts_output/head-$NAMESPACE.d.ts" \ - run_tailor_sdk tailordb erd export --config "$head_config" --namespace "$NAMESPACE" --output "$head_output" + run_tailor_cli tailordb erd export --config "$head_config" --namespace "$NAMESPACE" --output "$head_output" ) >"$head_log" 2>&1; then if grep -q 'not found in local config.db' "$head_log"; then cat "$head_log" @@ -483,7 +483,7 @@ jobs: base_missing="true" elif ! ( TAILOR_PLATFORM_SDK_DTS_PATH="$dts_output/base-$NAMESPACE.d.ts" \ - run_base_tailor_sdk_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config" --namespace "$NAMESPACE" --output "$base_output" + run_base_tailor_cli_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config" --namespace "$NAMESPACE" --output "$base_output" ) >"$base_log" 2>&1; then if grep -q 'not found in local config.db' "$base_log"; then cat "$base_log" @@ -517,7 +517,7 @@ jobs: if [ "$base_missing" = "false" ]; then diff_args+=(--base-html "$base_html") fi - run_head_tailor_sdk_bin "$GITHUB_WORKSPACE" tailordb erd diff "${diff_args[@]}" + run_head_tailor_cli_bin "$GITHUB_WORKSPACE" tailordb erd diff "${diff_args[@]}" - id: tailor-upload-erd-viewer uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/packages/sdk/src/cli/commands/setup/check.ts b/packages/sdk/src/cli/commands/setup/check.ts index cdca521b02..ed3b42c65f 100644 --- a/packages/sdk/src/cli/commands/setup/check.ts +++ b/packages/sdk/src/cli/commands/setup/check.ts @@ -281,8 +281,8 @@ export async function checkGitHub(options: CheckGitHubOptions): Promise { const lock = readLock(outputDir); if (!lock || lock.targets.length === 0) { throw new Error( - "No managed workflows found (.github/tailor-sdk.lock is missing or empty). " + - "Run `tailor-sdk setup branch` (or another setup subcommand) first.", + "No managed workflows found (.github/tailor.lock is missing or empty). " + + "Run `tailor setup branch` (or another setup subcommand) first.", ); } @@ -301,7 +301,7 @@ export async function checkGitHub(options: CheckGitHubOptions): Promise { throw new Error( "TAILOR_PLATFORM_WORKSPACE_ID is not set. " + "Provision the workspace and set the variable:\n" + - " tailor-sdk workspace create # if it does not exist yet; copy the id\n" + + " tailor workspace create # if it does not exist yet; copy the id\n" + " gh variable set TAILOR_PLATFORM_WORKSPACE_ID --env ", ); } @@ -389,6 +389,6 @@ export async function checkGitHub(options: CheckGitHubOptions): Promise { } throw new Error( `Detected ${String(findings.length)} drift finding(s) across ${String(count)} target(s). ` + - "Re-run `tailor-sdk setup` to regenerate, or address each finding above.", + "Re-run `tailor setup` to regenerate, or address each finding above.", ); } diff --git a/packages/sdk/src/cli/commands/setup/generate.test.ts b/packages/sdk/src/cli/commands/setup/generate.test.ts index 313e3bd726..4ebe70a3e5 100644 --- a/packages/sdk/src/cli/commands/setup/generate.test.ts +++ b/packages/sdk/src/cli/commands/setup/generate.test.ts @@ -186,9 +186,9 @@ describe("renderBranchWorkflow", () => { expect(content).toContain( "namespace: ${{ fromJSON(needs.tailor-erd-preview-matrix.outputs.namespaces) }}", ); - expect(content).toContain(".tailor-erd-base/.github/tailor-sdk.lock"); - expect(content).toContain("run_tailor_sdk tailordb erd export"); - expect(content).toContain('run_head_tailor_sdk_bin "$GITHUB_WORKSPACE" tailordb erd diff'); + expect(content).toContain(".tailor-erd-base/.github/tailor.lock"); + expect(content).toContain("run_tailor_cli tailordb erd export"); + expect(content).toContain('run_head_tailor_cli_bin "$GITHUB_WORKSPACE" tailordb erd diff'); expect(content).toContain("const namespacePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;"); expect(content).toContain("Invalid ERD namespace in"); expect(content).toContain("id: tailor-detect-base-package-manager"); @@ -208,12 +208,12 @@ describe("renderBranchWorkflow", () => { 'base_config="$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR/tailor.config.ts"', ); expect(content).toContain("BASE_PACKAGE_MANAGER:"); - expect(content).toContain("tailor_sdk_bin"); + expect(content).toContain("tailor_cli_bin"); expect(content).toContain("run_head_node - <<'NODE'"); expect(content).toContain('path.join(githubWorkspace, appDir, "package.json")'); expect(content).toContain('path.join(githubWorkspace, "package.json")'); expect(content).toContain( - 'run_base_tailor_sdk_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config"', + 'run_base_tailor_cli_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config"', ); expect(content).toContain('yarn) run_head_cli_env yarn node "$head_cli_runner" "$@" ;;'); expect(content).toContain('head_missing="false"'); @@ -312,8 +312,8 @@ describe("renderBranchWorkflow", () => { expect(start).toBeGreaterThanOrEqual(0); expect(end).toBeGreaterThan(start); - expect(buildStep).toContain("run_head_tailor_sdk_bin() {"); - expect(buildStep).toContain("run_base_tailor_sdk_bin() {"); + expect(buildStep).toContain("run_head_tailor_cli_bin() {"); + expect(buildStep).toContain("run_base_tailor_cli_bin() {"); expect(buildStep).toContain('cd "$GITHUB_WORKSPACE"'); expect(buildStep).toContain('local command_cwd="$1"'); expect(buildStep).toContain("process.chdir(commandCwd);"); @@ -325,7 +325,7 @@ describe("renderBranchWorkflow", () => { expect(buildStep).toContain('case "$BASE_PACKAGE_MANAGER" in'); expect(buildStep).toContain('cd "$command_cwd"'); expect(buildStep).toContain( - 'run_base_tailor_sdk_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config"', + 'run_base_tailor_cli_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config"', ); }); @@ -348,7 +348,7 @@ describe("renderBranchWorkflow", () => { 'base_config="$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR/tailor.config.ts"', ); expect(buildStep).toContain( - 'run_base_tailor_sdk_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config"', + 'run_base_tailor_cli_bin "$GITHUB_WORKSPACE/.tailor-erd-base/$BASE_APP_DIR" tailordb erd export --config "$base_config"', ); expect(buildStep).not.toContain(".tailor-erd-base/$APP_DIR/tailor.config.ts"); expect(buildStep).not.toContain('.tailor-erd-base/$APP_DIR" tailordb erd export'); @@ -379,9 +379,9 @@ describe("renderBranchWorkflow", () => { 'cd "$GITHUB_WORKSPACE/$APP_DIR"\n TAILOR_PLATFORM_SDK_DTS_PATH', ); expect(buildStep).not.toContain( - 'cd "$GITHUB_WORKSPACE/$APP_DIR"\n run_tailor_sdk tailordb erd diff', + 'cd "$GITHUB_WORKSPACE/$APP_DIR"\n run_tailor_cli tailordb erd diff', ); - expect(buildStep).toContain('run_head_tailor_sdk_bin "$GITHUB_WORKSPACE" tailordb erd diff'); + expect(buildStep).toContain('run_head_tailor_cli_bin "$GITHUB_WORKSPACE" tailordb erd diff'); }); test("treats missing ERD preview configs as empty diff sides", () => { @@ -418,7 +418,7 @@ describe("renderBranchWorkflow", () => { const { content } = renderBranchWorkflow(branchBase); expect(content).toContain("uses: tailor-platform/actions/generate-check@"); expect(content).not.toContain("git add -A"); - expect(content).not.toContain("tailor-sdk generate"); + expect(content).not.toContain("tailor generate"); }); test("includes paths + working-directory only when dir != '.'", () => { @@ -971,13 +971,13 @@ describe("setupCoordinate", () => { }); test("errors when lock file is missing", async () => { - await expect(setupCoordinate(coordinateOpts())).rejects.toThrow(/tailor-sdk\.lock not found/); + await expect(setupCoordinate(coordinateOpts())).rejects.toThrow(/tailor\.lock not found/); }); test("errors when an action target is not in the lock", async () => { await setupTarget(actionOpts("api")); await expect(setupCoordinate(coordinateOpts({ actions: ["missing-app"] }))).rejects.toThrow( - /not found in .github\/tailor-sdk\.lock/, + /not found in .github\/tailor\.lock/, ); }); diff --git a/packages/sdk/src/cli/commands/setup/generate.ts b/packages/sdk/src/cli/commands/setup/generate.ts index 7441e6d9ea..d04bb587c8 100644 --- a/packages/sdk/src/cli/commands/setup/generate.ts +++ b/packages/sdk/src/cli/commands/setup/generate.ts @@ -580,13 +580,13 @@ function printNextSteps(obj: { environment: string; idInjected: boolean }): void `2. Provision the workspace and set its id as the TAILOR_PLATFORM_WORKSPACE_ID variable ` + `on the "${environment}" environment:`, ); - logger.log(" tailor-sdk workspace create # if it does not exist yet; copy the id"); + logger.log(" tailor workspace create # if it does not exist yet; copy the id"); logger.log(` gh variable set TAILOR_PLATFORM_WORKSPACE_ID --env ${environment}`); logger.newline(); logger.log("3. Commit the generated files:"); logger.log(" - .github/workflows/tailor-*.yml"); - logger.log(" - .github/tailor-sdk.lock"); + logger.log(" - .github/tailor.lock"); if (idInjected) { logger.log(" - tailor.config.ts (app id was added)"); } @@ -694,7 +694,7 @@ export async function setupTarget(options: SetupTargetOptions): Promise { logger.newline(); logger.log(`The composite action has been generated at ${styles.path(resolved.file)}.`); logger.log( - "Use `tailor-sdk setup coordinate` to generate a coordinator workflow that orchestrates this action.", + "Use `tailor setup coordinate` to generate a coordinator workflow that orchestrates this action.", ); } else { printNextSteps({ environment: resolved.environment, idInjected }); @@ -718,7 +718,7 @@ export async function setupCoordinate(options: CoordinateSetupOptions): Promise< if (actions.length === 0) { throw new Error( "At least one --action is required. " + - "Run `tailor-sdk setup action --dir ` for each app first.", + "Run `tailor setup action --dir ` for each app first.", ); } @@ -746,8 +746,8 @@ export async function setupCoordinate(options: CoordinateSetupOptions): Promise< if (!lock) { throw new Error( - ".github/tailor-sdk.lock not found. " + - "Run `tailor-sdk setup action --name ` for each app before running setup coordinate.", + ".github/tailor.lock not found. " + + "Run `tailor setup action --name ` for each app before running setup coordinate.", ); } @@ -764,8 +764,8 @@ export async function setupCoordinate(options: CoordinateSetupOptions): Promise< const entry = lock.targets.find((t) => t.kind === "action" && t.workspaceName === name); if (!entry) { throw new Error( - `Action target "${name}" not found in .github/tailor-sdk.lock. ` + - `Run \`tailor-sdk setup action --name ${name}\` first.`, + `Action target "${name}" not found in .github/tailor.lock. ` + + `Run \`tailor setup action --name ${name}\` first.`, ); } validateDir(entry.inputs.dir); @@ -858,11 +858,11 @@ export async function setupCoordinate(options: CoordinateSetupOptions): Promise< logger.log( `2. Provision each workspace and set TAILOR_PLATFORM_WORKSPACE_ID on the "${environment}" environment:`, ); - logger.log(" tailor-sdk workspace create # one per app; copy the id"); + logger.log(" tailor workspace create # one per app; copy the id"); logger.log(` gh variable set TAILOR_PLATFORM_WORKSPACE_ID --env ${environment}`); logger.newline(); logger.log("3. Commit the generated files:"); logger.log(` - ${file}`); logger.log(` - ${tailorSetupFile} (if newly created)`); - logger.log(" - .github/tailor-sdk.lock"); + logger.log(" - .github/tailor.lock"); } diff --git a/packages/sdk/src/cli/commands/setup/index.ts b/packages/sdk/src/cli/commands/setup/index.ts index e41443cf3f..2c2857c5fd 100644 --- a/packages/sdk/src/cli/commands/setup/index.ts +++ b/packages/sdk/src/cli/commands/setup/index.ts @@ -9,15 +9,13 @@ import { setupCoordinate, setupTarget } from "./generate"; const checkCommand = defineAppCommand({ name: "check", description: "Audit generated workflows for drift against the current config/repo (read-only).", - args: z - .object({ - ci: arg(z.boolean().default(false), { - description: - "Run in CI mode: skip checks that are handled by the runtime " + - "(e.g. TAILOR_PLATFORM_WORKSPACE_ID).", - }), - }) - .strict(), + args: z.strictObject({ + ci: arg(z.boolean().default(false), { + description: + "Run in CI mode: skip checks that are handled by the runtime " + + "(e.g. TAILOR_PLATFORM_WORKSPACE_ID).", + }), + }), run: async (args) => { await checkGitHub({ outputDir: process.cwd(), ci: args.ci }); }, @@ -27,31 +25,28 @@ const coordinateCommand = defineAppCommand({ name: "coordinate", description: "Generate a coordinator workflow that orchestrates multiple --action-generated composite actions.", - args: z - .object({ - name: arg(z.string().min(1), { - alias: "n", - description: "Coordinator name (used in the generated workflow file name and job names)", - }), - action: arg(z.array(z.string().min(1)).min(1), { - description: - "Composite action to include (can be specified multiple times). tailor- prefix optional.", - }), - branch: arg(z.string().min(1).optional(), { - description: - "Branch target: deploy trigger branch (defaults to the detected default branch)", - }), - tag: arg(z.boolean().default(false), { - description: "Generate a tag target coordinator", - }), - environment: arg(z.string().min(1).optional(), { - description: "GitHub Environment for the plan/deploy jobs", - }), - force: arg(z.boolean().default(false), { - description: "Discard hand edits and regenerate", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string().min(1), { + alias: "n", + description: "Coordinator name (used in the generated workflow file name and job names)", + }), + action: arg(z.array(z.string().min(1)).min(1), { + description: + "Composite action to include (can be specified multiple times). tailor- prefix optional.", + }), + branch: arg(z.string().min(1).optional(), { + description: "Branch target: deploy trigger branch (defaults to the detected default branch)", + }), + tag: arg(z.boolean().default(false), { + description: "Generate a tag target coordinator", + }), + environment: arg(z.string().min(1).optional(), { + description: "GitHub Environment for the plan/deploy jobs", + }), + force: arg(z.boolean().default(false), { + description: "Discard hand edits and regenerate", + }), + }), run: async (args) => { const coordinateKind = args.tag ? "tag" : "branch"; await setupCoordinate({ @@ -70,24 +65,22 @@ const actionCommand = defineAppCommand({ name: "action", description: "Generate a per-app composite action for use with setup coordinate (monorepo multi-app deploys).", - args: z - .object({ - name: arg(z.string().min(1).optional(), { - alias: "n", - description: "Name (defaults to the config 'name')", - }), - dir: arg(z.string().min(1).default("."), { - alias: "d", - description: "App directory", - }), - environment: arg(z.string().min(1).optional(), { - description: "GitHub Environment (defaults to the workspace name)", - }), - force: arg(z.boolean().default(false), { - description: "Discard hand edits and regenerate", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string().min(1).optional(), { + alias: "n", + description: "Name (defaults to the config 'name')", + }), + dir: arg(z.string().min(1).default("."), { + alias: "d", + description: "App directory", + }), + environment: arg(z.string().min(1).optional(), { + description: "GitHub Environment (defaults to the workspace name)", + }), + force: arg(z.boolean().default(false), { + description: "Discard hand edits and regenerate", + }), + }), run: async (args) => { await setupTarget({ kind: "action", @@ -103,31 +96,28 @@ const actionCommand = defineAppCommand({ const branchCommand = defineAppCommand({ name: "branch", description: "Generate a branch-target deploy workflow (push to branch triggers deploy).", - args: z - .object({ - name: arg(z.string().min(1).optional(), { - alias: "n", - description: "Name (defaults to the config 'name')", - }), - branch: arg(z.string().min(1).optional(), { - description: "Deploy trigger branch (defaults to the detected default branch)", - }), - environment: arg(z.string().min(1).optional(), { - description: "GitHub Environment for the plan/deploy jobs (defaults to the workspace name)", - }), - "erd-preview": arg(z.boolean().default(false), { - description: - "Add PR ERD viewer artifacts with current/diff previews for TailorDB namespaces", - }), - dir: arg(z.string().min(1).default("."), { - alias: "d", - description: "App directory (for monorepo setups)", - }), - force: arg(z.boolean().default(false), { - description: "Discard hand edits / take over unmanaged files and regenerate", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string().min(1).optional(), { + alias: "n", + description: "Name (defaults to the config 'name')", + }), + branch: arg(z.string().min(1).optional(), { + description: "Deploy trigger branch (defaults to the detected default branch)", + }), + environment: arg(z.string().min(1).optional(), { + description: "GitHub Environment for the plan/deploy jobs (defaults to the workspace name)", + }), + "erd-preview": arg(z.boolean().default(false), { + description: "Add PR ERD viewer artifacts with current/diff previews for TailorDB namespaces", + }), + dir: arg(z.string().min(1).default("."), { + alias: "d", + description: "App directory (for monorepo setups)", + }), + force: arg(z.boolean().default(false), { + description: "Discard hand edits / take over unmanaged files and regenerate", + }), + }), run: async (args) => { await setupTarget({ kind: "branch", @@ -145,30 +135,28 @@ const branchCommand = defineAppCommand({ const tagCommand = defineAppCommand({ name: "tag", description: "Generate a tag-target deploy workflow (tag push triggers deploy).", - args: z - .object({ - name: arg(z.string().min(1).optional(), { - alias: "n", - description: "Name (defaults to the config 'name')", - }), - "tag-pattern": arg(z.string().min(1).default("v*"), { - description: "Tag glob to match (defaults to v*)", - }), - branch: arg(z.string().min(1).optional(), { - description: "Tag-reachability guard branch (no guard when omitted)", - }), - environment: arg(z.string().min(1).optional(), { - description: "GitHub Environment for the plan/deploy jobs (defaults to the workspace name)", - }), - dir: arg(z.string().min(1).default("."), { - alias: "d", - description: "App directory (for monorepo setups)", - }), - force: arg(z.boolean().default(false), { - description: "Discard hand edits / take over unmanaged files and regenerate", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string().min(1).optional(), { + alias: "n", + description: "Name (defaults to the config 'name')", + }), + "tag-pattern": arg(z.string().min(1).default("v*"), { + description: "Tag glob to match (defaults to v*)", + }), + branch: arg(z.string().min(1).optional(), { + description: "Tag-reachability guard branch (no guard when omitted)", + }), + environment: arg(z.string().min(1).optional(), { + description: "GitHub Environment for the plan/deploy jobs (defaults to the workspace name)", + }), + dir: arg(z.string().min(1).default("."), { + alias: "d", + description: "App directory (for monorepo setups)", + }), + force: arg(z.boolean().default(false), { + description: "Discard hand edits / take over unmanaged files and regenerate", + }), + }), run: async (args) => { await setupTarget({ kind: "tag", @@ -186,33 +174,31 @@ const tagCommand = defineAppCommand({ const previewCommand = defineAppCommand({ name: "preview", description: "Generate a preview workflow (PR open/sync triggers deploy to a per-PR workspace).", - args: z - .object({ - name: arg(z.string().min(1).optional(), { - alias: "n", - description: "Name (defaults to the config 'name')", - }), - branch: arg(z.string().min(1).optional(), { - description: "Branch to filter PRs by (defaults to the detected default branch)", - }), - region: arg(z.string().min(1), { - description: "Workspace region for preview workspace creation (e.g. us-west). Required.", - }), - "require-preview-label": arg(z.boolean().default(false), { - description: "Deploy preview only for PRs labeled `tailor:preview` instead of all PRs.", - }), - environment: arg(z.string().min(1).optional(), { - description: "GitHub Environment for the preview jobs (defaults to the workspace name)", - }), - dir: arg(z.string().min(1).default("."), { - alias: "d", - description: "App directory (for monorepo setups)", - }), - force: arg(z.boolean().default(false), { - description: "Discard hand edits / take over unmanaged files and regenerate", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string().min(1).optional(), { + alias: "n", + description: "Name (defaults to the config 'name')", + }), + branch: arg(z.string().min(1).optional(), { + description: "Branch to filter PRs by (defaults to the detected default branch)", + }), + region: arg(z.string().min(1), { + description: "Workspace region for preview workspace creation (e.g. us-west). Required.", + }), + "require-preview-label": arg(z.boolean().default(false), { + description: "Deploy preview only for PRs labeled `tailor:preview` instead of all PRs.", + }), + environment: arg(z.string().min(1).optional(), { + description: "GitHub Environment for the preview jobs (defaults to the workspace name)", + }), + dir: arg(z.string().min(1).default("."), { + alias: "d", + description: "App directory (for monorepo setups)", + }), + force: arg(z.boolean().default(false), { + description: "Discard hand edits / take over unmanaged files and regenerate", + }), + }), run: async (args) => { await setupTarget({ kind: "preview", @@ -230,17 +216,15 @@ const previewCommand = defineAppCommand({ const deleteCommand = defineAppCommand({ name: "delete", - description: "Delete managed workflow/action file(s) and their .github/tailor-sdk.lock entries.", - args: z - .object({ - ...confirmationArgs, - files: arg(z.string().array().min(1), { - positional: true, - description: - "Workflow/action file(s) to delete, as generated under .github/workflows or .github/actions", - }), - }) - .strict(), + description: "Delete managed workflow/action file(s) and their .github/tailor.lock entries.", + args: z.strictObject({ + ...confirmationArgs, + files: arg(z.string().array().min(1), { + positional: true, + description: + "Workflow/action file(s) to delete, as generated under .github/workflows or .github/actions", + }), + }), run: async (args) => { await setupDelete({ files: args.files, yes: args.yes, outputDir: process.cwd() }); }, diff --git a/packages/sdk/src/cli/commands/setup/lock.test.ts b/packages/sdk/src/cli/commands/setup/lock.test.ts index c83ad626ac..35fded1754 100644 --- a/packages/sdk/src/cli/commands/setup/lock.test.ts +++ b/packages/sdk/src/cli/commands/setup/lock.test.ts @@ -56,7 +56,7 @@ describe("readLock / writeLock", () => { test("round-trips through disk with 2-space indent and trailing newline", () => { const lock = makeLock(); writeLock(testDir, lock); - const raw = fs.readFileSync(path.join(testDir, ".github/tailor-sdk.lock"), "utf-8"); + const raw = fs.readFileSync(path.join(testDir, ".github/tailor.lock"), "utf-8"); expect(raw.endsWith("\n")).toBe(true); expect(raw).toContain(' "version": 1'); expect(readLock(testDir)).toEqual(lock); @@ -91,7 +91,7 @@ describe("readLock / writeLock", () => { }, ])("$title", ({ content, error }) => { fs.mkdirSync(path.join(testDir, ".github"), { recursive: true }); - fs.writeFileSync(path.join(testDir, ".github/tailor-sdk.lock"), content()); + fs.writeFileSync(path.join(testDir, ".github/tailor.lock"), content()); expect(() => readLock(testDir)).toThrow(error); }); }); diff --git a/packages/sdk/src/cli/commands/setup/lock.ts b/packages/sdk/src/cli/commands/setup/lock.ts index acead307f2..cfdde8ed05 100644 --- a/packages/sdk/src/cli/commands/setup/lock.ts +++ b/packages/sdk/src/cli/commands/setup/lock.ts @@ -6,7 +6,7 @@ import * as path from "pathe"; export const LOCK_VERSION = 1; /** Lock file path, relative to the repository root. */ -const LOCK_FILENAME = ".github/tailor-sdk.lock"; +const LOCK_FILENAME = ".github/tailor.lock"; export type TargetKind = "branch" | "tag" | "preview" | "action" | "coordinate"; @@ -94,14 +94,14 @@ export function readLock(outputDir: string): LockFile | null { } catch (cause) { throw new Error( `${LOCK_FILENAME} is not valid JSON. The lock file is machine-owned; ` + - "restore it from git (git checkout -- .github/tailor-sdk.lock) and re-run setup.", + "restore it from git (git checkout -- .github/tailor.lock) and re-run setup.", { cause }, ); } if (typeof parsed.version !== "number") { throw new Error( `${LOCK_FILENAME} has no valid 'version' field. The lock file is machine-owned; ` + - "restore it from git (git checkout -- .github/tailor-sdk.lock) and re-run setup.", + "restore it from git (git checkout -- .github/tailor.lock) and re-run setup.", ); } if (parsed.version > LOCK_VERSION) { @@ -113,7 +113,7 @@ export function readLock(outputDir: string): LockFile | null { if (!Array.isArray(parsed.targets)) { throw new Error( `${LOCK_FILENAME} has no valid 'targets' array. The lock file is machine-owned; ` + - "restore it from git (git checkout -- .github/tailor-sdk.lock) and re-run setup.", + "restore it from git (git checkout -- .github/tailor.lock) and re-run setup.", ); } return parsed; diff --git a/packages/sdk/src/cli/commands/setup/preview.workflow.yml b/packages/sdk/src/cli/commands/setup/preview.workflow.yml index 220b1c7498..0f1f548362 100644 --- a/packages/sdk/src/cli/commands/setup/preview.workflow.yml +++ b/packages/sdk/src/cli/commands/setup/preview.workflow.yml @@ -6,7 +6,7 @@ on: branches: ["__BRANCH__"] # __PR_TYPES__ # To deploy only for PRs that carry the `tailor:preview` label instead of all PRs, - # re-run: tailor-sdk setup --require-preview-label + # re-run: tailor setup preview --require-preview-label # __PATHS__ permissions: diff --git a/packages/sdk/src/cli/commands/setup/templates.ts b/packages/sdk/src/cli/commands/setup/templates.ts index 571db6ad82..f1996ba702 100644 --- a/packages/sdk/src/cli/commands/setup/templates.ts +++ b/packages/sdk/src/cli/commands/setup/templates.ts @@ -13,12 +13,12 @@ export const TEMPLATE_VERSION = 6; export type PackageManager = "pnpm" | "yarn" | "npm" | "bun"; -const HEADER = `# Generated by \`tailor-sdk setup\` — managed by the Tailor SDK. +const HEADER = `# Generated by \`tailor setup\` — managed by the Tailor SDK. # # - Jobs and steps whose id starts with \`tailor-\` are managed by the SDK. # Do not edit or rename them. -# - State is tracked in .github/tailor-sdk.lock (machine-owned: commit it, never edit it). -# - Re-running \`tailor-sdk setup\` regenerates this file. If you have +# - State is tracked in .github/tailor.lock (machine-owned: commit it, never edit it). +# - Re-running \`tailor setup\` regenerates this file. If you have # edited it by hand, regeneration stops and asks for --force (which discards # your edits), so prefer keeping customizations in your own jobs/steps and # re-running setup after SDK updates.`; @@ -571,10 +571,10 @@ export function renderCoordinateWorkflow(params: RenderCoordinateParams): Render export function renderTailorSetupAction(params: { packageManager: PackageManager }): string { const { packageManager } = params; return [ - `# This file is user-owned. Generated once by \`tailor-sdk setup coordinate\` and never overwritten.`, + `# This file is user-owned. Generated once by \`tailor setup coordinate\` and never overwritten.`, `# Customize freely: add pre/post steps, change install flags, etc.`, `name: Tailor Setup`, - `description: Install dependencies and run tailor-sdk setup for composite action callers.`, + `description: Install dependencies and run tailor setup for composite action callers.`, `runs:`, ` using: composite`, ` steps:`, diff --git a/packages/sdk/src/cli/commands/setup/workflow-lint.test.ts b/packages/sdk/src/cli/commands/setup/workflow-lint.test.ts index 76c22492ea..91a68cdbb6 100644 --- a/packages/sdk/src/cli/commands/setup/workflow-lint.test.ts +++ b/packages/sdk/src/cli/commands/setup/workflow-lint.test.ts @@ -93,7 +93,7 @@ describe("repository ERD preview workflow", () => { expect(content).toContain("Base ERD namespace '$NAMESPACE' not found"); expect(content).toContain('diff_args=(--namespace "$NAMESPACE"'); expect(content).toContain('diff_args+=(--base-html "$base_html")'); - expect(content).toContain("pnpm exec tailor-sdk tailordb erd diff"); + expect(content).toContain("pnpm exec tailor tailordb erd diff"); }); test("uploads artifacts with names matched by the sticky comment", () => { diff --git a/packages/sdk/src/cli/commands/show.ts b/packages/sdk/src/cli/commands/show.ts index a6d5058758..ba9b09acb9 100644 --- a/packages/sdk/src/cli/commands/show.ts +++ b/packages/sdk/src/cli/commands/show.ts @@ -101,11 +101,9 @@ const showWorkspaceNameTransformer = createWorkspaceNameTransformer( export const showCommand = defineAppCommand({ name: "show", description: "Show information about the deployed application.", - args: z - .object({ - ...deploymentArgs, - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + }), run: async (args) => { // Execute show logic const appInfo = await show({ diff --git a/packages/sdk/src/cli/commands/skills/install.test.ts b/packages/sdk/src/cli/commands/skills/install.test.ts index 3cd3901cac..981f533eb4 100644 --- a/packages/sdk/src/cli/commands/skills/install.test.ts +++ b/packages/sdk/src/cli/commands/skills/install.test.ts @@ -9,7 +9,7 @@ describe("resolveBundledSkillsDir", () => { expect(dir.endsWith("/agent-skills")).toBe(true); expect(existsSync(dir)).toBe(true); expect(statSync(dir).isDirectory()).toBe(true); - expect(existsSync(resolve(dir, "tailor-sdk", "SKILL.md"))).toBe(true); + expect(existsSync(resolve(dir, "tailor", "SKILL.md"))).toBe(true); }); }); diff --git a/packages/sdk/src/cli/commands/skills/install.ts b/packages/sdk/src/cli/commands/skills/install.ts index 46f56a16d3..1d546c80ce 100644 --- a/packages/sdk/src/cli/commands/skills/install.ts +++ b/packages/sdk/src/cli/commands/skills/install.ts @@ -19,19 +19,17 @@ const DEFAULT_AGENT = "claude-code"; export const installCommand = defineAppCommand({ name: "install", - description: "Install the tailor-sdk agent skill from the installed SDK package.", - args: z - .object({ - agent: arg(z.string().default(DEFAULT_AGENT), { - alias: "a", - description: `vercel/skills agent name (e.g. ${DEFAULT_AGENT}, codex). Defaults to ${DEFAULT_AGENT}.`, - }), - yes: arg(z.boolean().default(false), { - alias: "y", - description: "Auto-approve prompts.", - }), - }) - .strict(), + description: "Install the tailor agent skill from the installed SDK package.", + args: z.strictObject({ + agent: arg(z.string().default(DEFAULT_AGENT), { + alias: "a", + description: `vercel/skills agent name (e.g. ${DEFAULT_AGENT}, codex). Defaults to ${DEFAULT_AGENT}.`, + }), + yes: arg(z.boolean().default(false), { + alias: "y", + description: "Auto-approve prompts.", + }), + }), run: async (args) => { const exitCode = await runSkillsInstaller({ source: await resolveBundledSkillsDir(), diff --git a/packages/sdk/src/cli/commands/staticwebsite/deploy.ts b/packages/sdk/src/cli/commands/staticwebsite/deploy.ts index 0514a9aa02..c737d85a6e 100644 --- a/packages/sdk/src/cli/commands/staticwebsite/deploy.ts +++ b/packages/sdk/src/cli/commands/staticwebsite/deploy.ts @@ -229,20 +229,18 @@ export function logSkippedFiles(skippedFiles: string[]) { export const deployCommand = defineAppCommand({ name: "deploy", description: "Deploy a static website from a local build directory.", - args: z - .object({ - ...workspaceArgs, - name: arg(z.string(), { - alias: "n", - description: "Static website name", - }), - dir: arg(z.string(), { - alias: "d", - description: "Path to the static website files", - completion: { type: "directory" }, - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + name: arg(z.string(), { + alias: "n", + description: "Static website name", + }), + dir: arg(z.string(), { + alias: "d", + description: "Path to the static website files", + completion: { type: "directory" }, + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); logger.info(`Deploying static website "${args.name}" from directory: ${args.dir}`); diff --git a/packages/sdk/src/cli/commands/staticwebsite/domain/get.ts b/packages/sdk/src/cli/commands/staticwebsite/domain/get.ts index c03c832935..3ad530bec8 100644 --- a/packages/sdk/src/cli/commands/staticwebsite/domain/get.ts +++ b/packages/sdk/src/cli/commands/staticwebsite/domain/get.ts @@ -11,15 +11,13 @@ import { statusLabels } from "./status"; export const domainGetCommand = defineAppCommand({ name: "get", description: "Get details of a custom domain.", - args: z - .object({ - ...workspaceArgs, - domain: arg(z.string(), { - positional: true, - description: "Custom domain name", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + domain: arg(z.string(), { + positional: true, + description: "Custom domain name", + }), + }), run: async (args) => { const accessToken = await loadAccessToken({ profile: args.profile, diff --git a/packages/sdk/src/cli/commands/staticwebsite/domain/list.ts b/packages/sdk/src/cli/commands/staticwebsite/domain/list.ts index d9a6cfbeda..fe05ba0f5e 100644 --- a/packages/sdk/src/cli/commands/staticwebsite/domain/list.ts +++ b/packages/sdk/src/cli/commands/staticwebsite/domain/list.ts @@ -11,15 +11,13 @@ import { statusLabels } from "./status"; export const domainListCommand = defineAppCommand({ name: "list", description: "List custom domains for a static website.", - args: z - .object({ - ...workspaceArgs, - name: arg(z.string(), { - positional: true, - description: "Static website name", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + name: arg(z.string(), { + positional: true, + description: "Static website name", + }), + }), run: async (args) => { const accessToken = await loadAccessToken({ profile: args.profile, diff --git a/packages/sdk/src/cli/commands/staticwebsite/get.ts b/packages/sdk/src/cli/commands/staticwebsite/get.ts index 537ccc547e..0d451d34cc 100644 --- a/packages/sdk/src/cli/commands/staticwebsite/get.ts +++ b/packages/sdk/src/cli/commands/staticwebsite/get.ts @@ -10,15 +10,13 @@ import { logger } from "#/cli/shared/logger"; export const getCommand = defineAppCommand({ name: "get", description: "Get details of a specific static website.", - args: z - .object({ - ...workspaceArgs, - name: arg(z.string(), { - positional: true, - description: "Static website name", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + name: arg(z.string(), { + positional: true, + description: "Static website name", + }), + }), run: async (args) => { const accessToken = await loadAccessToken({ profile: args.profile, diff --git a/packages/sdk/src/cli/commands/staticwebsite/list.ts b/packages/sdk/src/cli/commands/staticwebsite/list.ts index 0fe4d56f04..b22df94f9c 100644 --- a/packages/sdk/src/cli/commands/staticwebsite/list.ts +++ b/packages/sdk/src/cli/commands/staticwebsite/list.ts @@ -63,12 +63,10 @@ async function listStaticWebsites( export const listCommand = defineAppCommand({ name: "list", description: "List all static websites in a workspace.", - args: z - .object({ - ...workspaceArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...paginationArgs(), + }), run: async (args) => { const jsonOutput = logger.jsonMode; const websites = await listStaticWebsites({ diff --git a/packages/sdk/src/cli/commands/tailordb/erd/deploy.ts b/packages/sdk/src/cli/commands/tailordb/erd/deploy.ts index fc4261e380..9853bb23d9 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/deploy.ts +++ b/packages/sdk/src/cli/commands/tailordb/erd/deploy.ts @@ -11,16 +11,14 @@ import { initErdDeployContext } from "./utils"; export const erdDeployCommand = defineAppCommand({ name: "deploy", description: "Deploy ERD static website for TailorDB namespace(s).", - args: z - .object({ - ...deploymentArgs, - namespace: arg(z.string().optional(), { - alias: "n", - description: - "TailorDB namespace name (optional - deploys all namespaces with erdSite if omitted)", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + namespace: arg(z.string().optional(), { + alias: "n", + description: + "TailorDB namespace name (optional - deploys all namespaces with erdSite if omitted)", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); const { client, workspaceId } = await initErdDeployContext(args); diff --git a/packages/sdk/src/cli/commands/tailordb/erd/diff-command.test.ts b/packages/sdk/src/cli/commands/tailordb/erd/diff-command.test.ts index 1bb708935d..73588360b6 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/diff-command.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/erd/diff-command.test.ts @@ -14,7 +14,7 @@ function schema(overrides: Partial = {}): TailorDbErdSchema { generatedAt: "2026-01-01T00:00:00.000Z", revision: "revision", source: "local", - cleanRoom: { implementation: "tailor-sdk", notes: [] }, + cleanRoom: { implementation: "tailor", notes: [] }, tables: [], relations: [], ...overrides, diff --git a/packages/sdk/src/cli/commands/tailordb/erd/diff-command.ts b/packages/sdk/src/cli/commands/tailordb/erd/diff-command.ts index 67cd704ddb..30e60226df 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/diff-command.ts +++ b/packages/sdk/src/cli/commands/tailordb/erd/diff-command.ts @@ -96,31 +96,29 @@ export function writeErdDiff(options: WriteErdDiffOptions): WriteErdDiffResult { export const erdDiffCommand = defineAppCommand({ name: "diff", description: "Render TailorDB ERD schema diff HTML from exported ERD viewers.", - args: z - .object({ - "base-html": arg(z.string().optional(), { - description: "Base ERD viewer HTML file", - completion: { type: "file", matcher: [".html"] }, - }), - "head-html": arg(z.string().optional(), { - description: "Head ERD viewer HTML file", - completion: { type: "file", matcher: [".html"] }, - }), - namespace: arg(z.string().optional(), { - alias: "n", - description: "TailorDB namespace name (defaults to the provided ERD schema namespace)", - }), - output: arg(z.string().min(1), { - alias: "o", - description: "Output ERD diff HTML file", - completion: { type: "file", matcher: [".html"] }, - }), - "output-json": arg(z.string().optional(), { - description: "Optional output JSON file for the computed diff", - completion: { type: "file", matcher: [".json"] }, - }), - }) - .strict(), + args: z.strictObject({ + "base-html": arg(z.string().optional(), { + description: "Base ERD viewer HTML file", + completion: { type: "file", matcher: [".html"] }, + }), + "head-html": arg(z.string().optional(), { + description: "Head ERD viewer HTML file", + completion: { type: "file", matcher: [".html"] }, + }), + namespace: arg(z.string().optional(), { + alias: "n", + description: "TailorDB namespace name (defaults to the provided ERD schema namespace)", + }), + output: arg(z.string().min(1), { + alias: "o", + description: "Output ERD diff HTML file", + completion: { type: "file", matcher: [".html"] }, + }), + "output-json": arg(z.string().optional(), { + description: "Optional output JSON file for the computed diff", + completion: { type: "file", matcher: [".json"] }, + }), + }), run: (args) => { initErdCommand(); const result = writeErdDiff({ diff --git a/packages/sdk/src/cli/commands/tailordb/erd/diff.test.ts b/packages/sdk/src/cli/commands/tailordb/erd/diff.test.ts index 9990cc373b..5e28e792a0 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/diff.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/erd/diff.test.ts @@ -37,7 +37,7 @@ function schema(overrides: Partial = {}): TailorDbErdSchema { generatedAt: "2026-01-01T00:00:00.000Z", revision: "base-revision", source: "local", - cleanRoom: { implementation: "tailor-sdk", notes: [] }, + cleanRoom: { implementation: "tailor", notes: [] }, tables: [table("User")], relations: [], ...overrides, diff --git a/packages/sdk/src/cli/commands/tailordb/erd/diff.ts b/packages/sdk/src/cli/commands/tailordb/erd/diff.ts index ff11bdf296..b493d217cd 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/diff.ts +++ b/packages/sdk/src/cli/commands/tailordb/erd/diff.ts @@ -323,7 +323,7 @@ export function createEmptyErdSchema(options: CreateEmptyErdSchemaOptions): Tail revision: options.revision, source: "local", cleanRoom: { - implementation: "tailor-sdk", + implementation: "tailor", notes: ["Synthetic empty schema used for ERD diff generation."], }, tables: [], diff --git a/packages/sdk/src/cli/commands/tailordb/erd/export.ts b/packages/sdk/src/cli/commands/tailordb/erd/export.ts index b8372a808b..7278ff84c9 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/export.ts +++ b/packages/sdk/src/cli/commands/tailordb/erd/export.ts @@ -10,7 +10,7 @@ import { initErdCommand } from "./utils"; import { writeViewerDist } from "./viewer"; import type { TailorDBNamespaceData } from "#/plugin/types"; -const DEFAULT_ERD_BASE_DIR = ".tailor-sdk/erd"; +const DEFAULT_ERD_BASE_DIR = ".tailor/erd"; interface ResolveTargetsOptions { context: LocalErdSchemaContext; @@ -177,22 +177,19 @@ export function prepareErdBuildsFromContext( export const erdExportCommand = defineAppCommand({ name: "export", description: "Export TailorDB ERD static viewer from local TailorDB schema.", - args: z - .object({ - ...configArg, - namespace: arg(z.string().optional(), { - alias: "n", - description: - "TailorDB namespace name (optional if only one namespace is defined in config)", - }), - output: arg(z.string().default(DEFAULT_ERD_BASE_DIR), { - alias: "o", - description: - "Output directory path for TailorDB ERD viewer files (writes to `//dist`)", - completion: { type: "directory" }, - }), - }) - .strict(), + args: z.strictObject({ + ...configArg, + namespace: arg(z.string().optional(), { + alias: "n", + description: "TailorDB namespace name (optional if only one namespace is defined in config)", + }), + output: arg(z.string().default(DEFAULT_ERD_BASE_DIR), { + alias: "o", + description: + "Output directory path for TailorDB ERD viewer files (writes to `//dist`)", + completion: { type: "directory" }, + }), + }), run: async (args) => { initErdCommand(); diff --git a/packages/sdk/src/cli/commands/tailordb/erd/schema.ts b/packages/sdk/src/cli/commands/tailordb/erd/schema.ts index 5c91aa04d5..494c6aa114 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/schema.ts +++ b/packages/sdk/src/cli/commands/tailordb/erd/schema.ts @@ -260,7 +260,7 @@ export function buildTailorDbErdSchema(options: BuildTailorDbErdSchemaOptions): namespace: namespaceData.namespace, source: options.source ?? "local", cleanRoom: { - implementation: "tailor-sdk" as const, + implementation: "tailor" as const, notes: CLEAN_ROOM_NOTES, }, tables, diff --git a/packages/sdk/src/cli/commands/tailordb/erd/serve.ts b/packages/sdk/src/cli/commands/tailordb/erd/serve.ts index d184bc12eb..7694b32c3e 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/serve.ts +++ b/packages/sdk/src/cli/commands/tailordb/erd/serve.ts @@ -16,7 +16,7 @@ import { prepareErdBuildsFromContext, type ErdBuildResult } from "./export"; import { loadLocalErdSchema, type LocalErdSchemaContext } from "./local-schema"; import { initErdCommand } from "./utils"; -const DEFAULT_ERD_BASE_DIR = ".tailor-sdk/erd"; +const DEFAULT_ERD_BASE_DIR = ".tailor/erd"; const LOCAL_HOST = "127.0.0.1"; interface StaticServerResult { @@ -56,7 +56,7 @@ interface OpenStaticFileResult { const GLOB_CHARS = /[*?[\]{}()!+@]/; function formatServeCommand(namespace: string): string { - return `tailor-sdk tailordb erd serve --namespace ${namespace}`; + return `tailor tailordb erd serve --namespace ${namespace}`; } function getCacheControl(filePath: string): string { @@ -427,21 +427,19 @@ async function waitForShutdown(server: http.Server, watcher: FSWatcher): Promise export const erdServeCommand = defineAppCommand({ name: "serve", description: "Generate and serve TailorDB ERD locally with watch reload. (beta)", - args: z - .object({ - ...configArg, - namespace: arg(z.string().optional(), { - alias: "n", - description: "TailorDB namespace name (uses first namespace in config if not specified)", - }), - port: arg(z.coerce.number().int().min(0).max(65535).default(0), { - description: "Local server port (0 selects a free port)", - }), - open: arg(z.boolean().default(false), { - description: "Open the ERD viewer in the default browser", - }), - }) - .strict(), + args: z.strictObject({ + ...configArg, + namespace: arg(z.string().optional(), { + alias: "n", + description: "TailorDB namespace name (uses first namespace in config if not specified)", + }), + port: arg(z.coerce.number().int().min(0).max(65535).default(0), { + description: "Local server port (0 selects a free port)", + }), + open: arg(z.boolean().default(false), { + description: "Open the ERD viewer in the default browser", + }), + }), run: async (args) => { initErdCommand(); diff --git a/packages/sdk/src/cli/commands/tailordb/erd/types.ts b/packages/sdk/src/cli/commands/tailordb/erd/types.ts index f7f6bcba3b..e89c0e0ad4 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/types.ts +++ b/packages/sdk/src/cli/commands/tailordb/erd/types.ts @@ -96,7 +96,7 @@ export interface TailorDbErdSchema { revision: string; source: TailorDbErdSource; cleanRoom: { - implementation: "tailor-sdk"; + implementation: "tailor"; notes: string[]; }; tables: TailorDbErdTable[]; diff --git a/packages/sdk/src/cli/commands/tailordb/erd/viewer.test.ts b/packages/sdk/src/cli/commands/tailordb/erd/viewer.test.ts index 9578f37c32..a4720bf904 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/viewer.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/erd/viewer.test.ts @@ -9,7 +9,7 @@ function buildSchema(overrides: Partial = {}): TailorDbErdSch generatedAt: "2026-01-01T00:00:00.000Z", revision: "test-revision", source: "local", - cleanRoom: { implementation: "tailor-sdk", notes: [] }, + cleanRoom: { implementation: "tailor", notes: [] }, tables: [ { name: "User", diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/bundler.test.ts b/packages/sdk/src/cli/commands/tailordb/migrate/bundler.test.ts index c7188a43f6..69f99a0e8c 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/bundler.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/bundler.test.ts @@ -16,13 +16,13 @@ describe("migration-bundler", () => { `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ); fs.mkdirSync(testDir, { recursive: true }); - // Set TAILOR_SDK_OUTPUT_DIR to testDir so bundled output goes into test directory - process.env.TAILOR_SDK_OUTPUT_DIR = testDir; + // Set TAILOR_BUILD_OUTPUT_DIR to testDir so bundled output goes into test directory + process.env.TAILOR_BUILD_OUTPUT_DIR = testDir; }); afterAll(() => { // Clean up environment variable - delete process.env.TAILOR_SDK_OUTPUT_DIR; + delete process.env.TAILOR_BUILD_OUTPUT_DIR; try { fs.rmSync(TEST_BUNDLER_BASE, { recursive: true, force: true }); } catch { diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/bundler.ts b/packages/sdk/src/cli/commands/tailordb/migrate/bundler.ts index 8f388b5fa5..402a4af352 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/bundler.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/bundler.ts @@ -38,7 +38,7 @@ export async function bundleMigrationScript( migrationNumber: number, env: Record = {}, ): Promise { - // Output directory in .tailor-sdk (relative to project root) + // Output directory in .tailor (relative to project root) const outputDir = path.resolve(getDistDir(), "migrations"); fs.mkdirSync(outputDir, { recursive: true }); diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.test.ts b/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.test.ts index f928b9882f..2b0471d5cf 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.test.ts @@ -144,7 +144,7 @@ describe("db-types-generator", () => { externalId: { type: "uuid", required: true }, referenceId: { type: "uuid", required: false }, }, - expectedContains: ["externalId: string;", "referenceId: string | null;"], + expectedContains: ["externalId: UUIDString;", "referenceId: UUIDString | null;"], }, { testName: "generates types with date/datetime fields using Timestamp", @@ -155,8 +155,8 @@ describe("db-types-generator", () => { endTime: { type: "datetime", required: false }, }, expectedContains: [ - "type Timestamp = ColumnType;", - "eventDate: Timestamp;", + "type Timestamp = ColumnType;", + "eventDate: DateString;", "startTime: Timestamp;", "endTime: Timestamp | null;", ], @@ -248,7 +248,7 @@ describe("db-types-generator", () => { const { content } = await generateContent(snapshot); - expect(content).toContain("id: Generated;"); + expect(content).toContain("id: Generated;"); expect(content).toContain( "type Generated = T extends ColumnType", ); @@ -304,6 +304,38 @@ describe("db-types-generator", () => { expect(content).toContain("email: ColumnType;"); }); + test("generates ColumnType for optional to required datetime change", async () => { + const snapshot = createMockSnapshot({ + User: { + fields: { + updatedAt: { type: "datetime", required: true }, + }, + }, + }); + createMigrationDir(testDir, 1); + + const diff = createMockMigrationDiff({ + changes: [ + { + kind: "field_modified", + typeName: "User", + fieldName: "updatedAt", + before: { type: "datetime", required: false }, + after: { type: "datetime", required: true }, + }, + ], + hasBreakingChanges: true, + requiresMigrationScript: true, + }); + + const filePath = await writeDbTypesFile(snapshot, testDir, 1, diff); + const content = fs.readFileSync(filePath, "utf-8"); + + expect(content).toContain( + "updatedAt: ColumnType;", + ); + }); + test("generates ColumnType for added required fields", async () => { const snapshot = createMockSnapshot({ User: { diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.ts b/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.ts index 67cc52eef3..945478d889 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/db-types-generator.ts @@ -15,6 +15,14 @@ import { } from "./snapshot"; import type { MigrationDiff } from "./diff-calculator"; +type UsedUtilityType = + | "Timestamp" + | "Serial" + | "DateString" + | "DateTimeString" + | "DecimalString" + | "TimeString"; + /** * Information about enum value changes */ @@ -132,26 +140,37 @@ function generateDbTypesFromSnapshot(snapshot: SchemaSnapshot, diff?: MigrationD }; // Track which utility types are used - const usedUtilityTypes = new Set<"Timestamp" | "Serial">(); + const usedUtilityTypes = new Set(); // Generate type definitions const typeDefinitions: string[] = []; for (const type of types) { const result = generateTableType(type, breakingChangeFields); - if (result.usedTimestamp) usedUtilityTypes.add("Timestamp"); + for (const utilityType of result.usedUtilityTypes) { + usedUtilityTypes.add(utilityType); + } typeDefinitions.push(result.typeDef); } // Build imports // ColumnType is always needed for Generated and Timestamp utility types - const imports: string[] = ["type ColumnType", "type Transaction as KyselyTransaction"]; + const imports: string[] = [ + "type ColumnType", + "type Transaction as KyselyTransaction", + "type UUIDString", + ]; + if (usedUtilityTypes.has("DateString")) imports.push("type DateString"); + if (usedUtilityTypes.has("DateTimeString")) imports.push("type DateTimeString"); + if (usedUtilityTypes.has("DecimalString")) imports.push("type DecimalString"); + if (usedUtilityTypes.has("TimeString")) imports.push("type TimeString"); // Build utility type declarations const utilityTypeDeclarations: string[] = []; if (usedUtilityTypes.has("Timestamp")) { utilityTypeDeclarations.push( - "type Timestamp = ColumnType;", + "type Timestamp = ColumnType;", ); + if (!usedUtilityTypes.has("DateTimeString")) imports.push("type DateTimeString"); } utilityTypeDeclarations.push( "type Generated = T extends ColumnType\n ? ColumnType\n : ColumnType;", @@ -224,22 +243,22 @@ function generateEmptyDbTypes(namespace: string): string { * Generate table type definition from a snapshot type * @param {TailorDBSnapshotType} type - Snapshot type * @param {BreakingChangeFieldInfo} breakingChangeFields - Breaking change field info - * @returns {{ typeDef: string; usedTimestamp: boolean; usedColumnType: boolean }} Generated type and utility type usage + * @returns Generated type and utility type usage */ function generateTableType( type: TailorDBSnapshotType, breakingChangeFields: BreakingChangeFieldInfo, ): { typeDef: string; - usedTimestamp: boolean; + usedUtilityTypes: Set; usedColumnType: boolean; } { const fieldLines: string[] = []; - let usedTimestamp = false; + const usedUtilityTypes = new Set(); let usedColumnType = false; // Add id field first - fieldLines.push(" id: Generated;"); + fieldLines.push(" id: Generated;"); // Get fields that are changing from optional to required for this type const optionalToRequiredFields = @@ -258,7 +277,9 @@ function generateTableType( const enumValueChange = enumValueChangesForType.get(fieldName); const result = generateFieldType(fieldConfig, isOptionalToRequired, enumValueChange); fieldLines.push(` ${fieldName}: ${result.type};`); - usedTimestamp = usedTimestamp || result.usedTimestamp; + for (const utilityType of result.usedUtilityTypes) { + usedUtilityTypes.add(utilityType); + } usedColumnType = usedColumnType || result.usedColumnType; } @@ -268,36 +289,43 @@ function generateTableType( // Treat as optional→required change (isOptionalToRequired: true) const result = generateFieldType(fieldConfig, true, undefined); fieldLines.push(` ${fieldName}: ${result.type};`); - usedTimestamp = usedTimestamp || result.usedTimestamp; + for (const utilityType of result.usedUtilityTypes) { + usedUtilityTypes.add(utilityType); + } usedColumnType = usedColumnType || result.usedColumnType; } const typeDef = ` ${type.name}: {\n${fieldLines.join("\n")}\n }`; - return { typeDef, usedTimestamp, usedColumnType }; + return { typeDef, usedUtilityTypes, usedColumnType }; } function mapToTsType(fieldType: string): { type: string; - usedTimestamp: boolean; + usedUtilityTypes: Set; } { switch (fieldType) { case "uuid": + return { type: "UUIDString", usedUtilityTypes: new Set() }; case "string": + return { type: "string", usedUtilityTypes: new Set() }; case "decimal": - return { type: "string", usedTimestamp: false }; + return { type: "DecimalString", usedUtilityTypes: new Set(["DecimalString"]) }; case "integer": case "float": case "number": - return { type: "number", usedTimestamp: false }; + return { type: "number", usedUtilityTypes: new Set() }; case "date": + return { type: "DateString", usedUtilityTypes: new Set(["DateString"]) }; case "datetime": - return { type: "Timestamp", usedTimestamp: true }; + return { type: "Timestamp", usedUtilityTypes: new Set(["Timestamp"]) }; + case "time": + return { type: "TimeString", usedUtilityTypes: new Set(["TimeString"]) }; case "bool": case "boolean": - return { type: "boolean", usedTimestamp: false }; + return { type: "boolean", usedUtilityTypes: new Set() }; default: - return { type: "string", usedTimestamp: false }; + return { type: "string", usedUtilityTypes: new Set() }; } } @@ -325,12 +353,44 @@ function generateEnumChangeColumnType( return `ColumnType<${selectType}, ${afterType}, ${afterType}>`; } +function generateOptionalToRequiredDateColumnType( + config: SnapshotFieldConfig, +): { type: string; usedUtilityTypes: Set } | null { + if (config.type !== "date" && config.type !== "datetime") return null; + + if (config.type === "date") { + if (config.array) { + return { + type: "ColumnType", + usedUtilityTypes: new Set(["DateString"]), + }; + } + + return { + type: "ColumnType", + usedUtilityTypes: new Set(["DateString"]), + }; + } + + if (config.array) { + return { + type: "ColumnType<(Date | DateTimeString)[] | null, (Date | DateTimeString)[], (Date | DateTimeString)[]>", + usedUtilityTypes: new Set(["DateTimeString"]), + }; + } + + return { + type: "ColumnType", + usedUtilityTypes: new Set(["DateTimeString"]), + }; +} + /** * Generate field type from snapshot field config * @param {SnapshotFieldConfig} config - Field configuration * @param {boolean} isOptionalToRequired - Whether this field is changing from optional to required * @param {EnumValueChange} [enumValueChange] - Enum value change info if applicable - * @returns {{ type: string; usedTimestamp: boolean; usedColumnType: boolean }} Generated type string and utility type usage + * @returns Generated type string and utility type usage */ function generateFieldType( config: SnapshotFieldConfig, @@ -338,21 +398,21 @@ function generateFieldType( enumValueChange?: EnumValueChange, ): { type: string; - usedTimestamp: boolean; + usedUtilityTypes: Set; usedColumnType: boolean; } { // Handle enum value changes specially if (enumValueChange) { return { type: generateEnumChangeColumnType(enumValueChange, config), - usedTimestamp: false, + usedUtilityTypes: new Set(), usedColumnType: true, }; } // Get base type let baseType: string; - let usedTimestamp = false; + let usedUtilityTypes = new Set(); if (config.type === "enum") { const enumValues = config.allowedValues?.map((v) => v.value) ?? []; @@ -360,7 +420,7 @@ function generateFieldType( } else { const mapped = mapToTsType(config.type); baseType = mapped.type; - usedTimestamp = mapped.usedTimestamp; + usedUtilityTypes = mapped.usedUtilityTypes; } // Apply array modifier @@ -373,12 +433,21 @@ function generateFieldType( // Handle nullable/required modifiers if (isOptionalToRequired) { + const dateColumnType = generateOptionalToRequiredDateColumnType(config); + if (dateColumnType) { + return { + type: dateColumnType.type, + usedUtilityTypes: dateColumnType.usedUtilityTypes, + usedColumnType: true, + }; + } + // For fields changing from optional to required: // SELECT returns T | null (existing data might be null) // INSERT/UPDATE requires T (must provide a value) return { type: `ColumnType<${type} | null, ${type}, ${type}>`, - usedTimestamp, + usedUtilityTypes, usedColumnType: true, }; } @@ -387,7 +456,7 @@ function generateFieldType( type = `${type} | null`; } - return { type, usedTimestamp, usedColumnType: false }; + return { type, usedUtilityTypes, usedColumnType: false }; } /** diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/generate.ts b/packages/sdk/src/cli/commands/tailordb/migrate/generate.ts index 769500a297..789efdf18a 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/generate.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/generate.ts @@ -303,7 +303,7 @@ async function generateDiffFromSnapshot( } logger.newline(); logger.log("A migration script was generated for breaking changes."); - logger.log("Please review and edit the script before running 'tailor-sdk deploy'."); + logger.log("Please review and edit the script before running 'tailor deploy'."); const editor = getConfiguredEditorCommand(); if (!editor) { @@ -330,7 +330,7 @@ async function generateDiffFromSnapshot( `Data loss is possible for this migration but no script was generated. To add a custom migrate.ts, run:`, ); logger.log( - ` ${styles.bold(`tailor-sdk tailordb migration script ${result.migrationNumber.toString().padStart(4, "0")} --namespace ${diff.namespace}`)}`, + ` ${styles.bold(`tailor tailordb migration script ${result.migrationNumber.toString().padStart(4, "0")} --namespace ${diff.namespace}`)}`, ); } } @@ -342,19 +342,17 @@ export const generateCommand = defineAppCommand({ name: "generate", description: "Generate migration files by detecting schema differences between current local types and the previous migration snapshot.", - args: z - .object({ - ...confirmationArgs, - ...configArg, - name: arg(z.string().optional(), { - alias: "n", - description: "Optional description for the migration", - }), - init: arg(z.boolean().default(false), { - description: "Delete existing migrations and start fresh", - }), - }) - .strict(), + args: z.strictObject({ + ...confirmationArgs, + ...configArg, + name: arg(z.string().optional(), { + alias: "n", + description: "Optional description for the migration", + }), + init: arg(z.boolean().default(false), { + description: "Delete existing migrations and start fresh", + }), + }), run: async (args) => { await generate({ configPath: args.config, diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/script.ts b/packages/sdk/src/cli/commands/tailordb/migrate/script.ts index 86095ec963..12b974e8df 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/script.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/script.ts @@ -100,7 +100,7 @@ async function script(options: ScriptOptions): Promise { logger.newline(); logger.log("Edit the script to implement your data migration logic."); - logger.log("It will be executed by 'tailor-sdk deploy' between Pre and Post phases."); + logger.log("It will be executed by 'tailor deploy' between Pre and Post phases."); const editor = getConfiguredEditorCommand(); if (!editor) return; @@ -136,19 +136,17 @@ function resolveTargetNamespace( export const scriptCommand = defineAppCommand({ name: "script", description: "Add a migration script (migrate.ts) template to an existing migration directory.", - args: z - .object({ - ...configArg, - number: arg(z.string(), { - positional: true, - description: "Migration number to add a script to (e.g., 0001 or 1)", - }), - namespace: arg(z.string().optional(), { - alias: "n", - description: "Target TailorDB namespace (required if multiple namespaces exist)", - }), - }) - .strict(), + args: z.strictObject({ + ...configArg, + number: arg(z.string(), { + positional: true, + description: "Migration number to add a script to (e.g., 0001 or 1)", + }), + namespace: arg(z.string().optional(), { + alias: "n", + description: "Target TailorDB namespace (required if multiple namespaces exist)", + }), + }), run: async (args) => { await script({ configPath: args.config, diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/set.ts b/packages/sdk/src/cli/commands/tailordb/migrate/set.ts index 3538881eeb..9596ab8f0c 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/set.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/set.ts @@ -154,20 +154,18 @@ async function set(options: SetOptions): Promise { export const setCommand = defineAppCommand({ name: "set", description: "Set migration checkpoint to a specific number.", - args: z - .object({ - ...deploymentArgs, - ...confirmationArgs, - number: arg(z.string(), { - positional: true, - description: "Migration number to set (e.g., 0001 or 1)", - }), - namespace: arg(z.string().optional(), { - alias: "n", - description: "Target TailorDB namespace (required if multiple namespaces exist)", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...confirmationArgs, + number: arg(z.string(), { + positional: true, + description: "Migration number to set (e.g., 0001 or 1)", + }), + namespace: arg(z.string().optional(), { + alias: "n", + description: "Target TailorDB namespace (required if multiple namespaces exist)", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); await set({ diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.test.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.test.ts index 304ffedfee..79fb7ffdcb 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.test.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.test.ts @@ -294,7 +294,7 @@ describe("snapshot-manifest", () => { expect(manifest.schema?.settings?.disableGqlOperations?.delete).toBe(true); }); - test("handles hooks configuration", () => { + test("aggregates field hooks into a type-level hook script", () => { const snapshotType = createTestSnapshotType("User", { fields: { id: { type: "uuid", required: true }, @@ -311,11 +311,21 @@ describe("snapshot-manifest", () => { const manifest = generateTailorDBTypeManifestFromSnapshot(snapshotType); - expect(manifest.schema?.fields?.updatedAt?.hooks?.create?.expr).toBe("now()"); - expect(manifest.schema?.fields?.updatedAt?.hooks?.update?.expr).toBe("now()"); + expect(manifest.schema?.fields?.updatedAt?.optionalOnCreate).toBe(true); + expect(manifest.schema?.fields?.updatedAt?.hooks).toBeUndefined(); + + // They are aggregated into a single type-level script that binds a shared + // timestamp once and dispatches each field's hook. + const createHook = manifest.schema?.typeHook?.create?.expr ?? ""; + expect(createHook).toContain("const _now = new Date()"); + expect(createHook).toContain( + '"updatedAt": ((_value, _oldValue) => (now()))(_input["updatedAt"], _oldRecord?.["updatedAt"] ?? null)', + ); + expect(manifest.schema?.typeHook?.update?.expr).toContain('_input["updatedAt"]'); + expect(manifest.schema?.typeValidate).toBeUndefined(); }); - test("keeps validate and hooks in nested fields", () => { + test("aggregates nested field hooks and validators into type-level scripts", () => { const snapshotType = createTestSnapshotType("User", { fields: { id: { type: "uuid", required: true }, @@ -366,16 +376,31 @@ describe("snapshot-manifest", () => { const displayNameField = profileField?.fields?.displayName; const emailField = profileField?.fields?.contact?.fields?.email; - expect(displayNameField?.validate).toHaveLength(1); - expect(displayNameField?.validate?.[0]?.errorMessage).toBe("Display name is required"); - expect(displayNameField?.validate?.[0]?.script?.expr).toBe("!((_value ?? '').length > 0)"); - expect(displayNameField?.hooks?.create?.expr).toBe("(_value ?? '').trim()"); - expect(displayNameField?.hooks?.update?.expr).toBe("(_value ?? '').trim()"); - - expect(emailField?.validate).toHaveLength(1); - expect(emailField?.validate?.[0]?.errorMessage).toBe("Email must contain @"); - expect(emailField?.validate?.[0]?.script?.expr).toBe("!((_value ?? '').includes('@'))"); - expect(emailField?.hooks?.create?.expr).toBe("(_value ?? '').toLowerCase()"); + expect(displayNameField?.optionalOnCreate).toBe(true); + expect(displayNameField?.hooks).toBeUndefined(); + expect(displayNameField?.validate ?? []).toHaveLength(0); + expect(emailField?.optionalOnCreate).toBe(true); + expect(emailField?.hooks).toBeUndefined(); + expect(emailField?.validate ?? []).toHaveLength(0); + + // Hooks are aggregated into a type-level script that reconstructs nested + // objects so unhooked siblings are preserved. + const hookExpr = manifest.schema?.typeHook?.create?.expr ?? ""; + expect(hookExpr).toContain('"profile": Object.assign({}, _input["profile"], {'); + expect(hookExpr).toContain("(_value ?? '').trim()"); + expect(hookExpr).toContain('(_input["profile"] || {})["displayName"]'); + expect(hookExpr).toContain( + '"contact": Object.assign({}, (_input["profile"] || {})["contact"], {', + ); + expect(hookExpr).toContain("(_value ?? '').toLowerCase()"); + + // Validators are aggregated into a type-level validate script using ?? chain. + const validateExpr = manifest.schema?.typeValidate?.create?.expr ?? ""; + expect(validateExpr).toContain('__errs["profile.displayName"]'); + expect(validateExpr).toContain("((_value ?? '').length > 0)"); + expect(validateExpr).toContain('if (typeof __r === "string")'); + expect(validateExpr).toContain('__errs["profile.contact.email"]'); + expect(manifest.schema?.typeValidate?.update?.expr).toBe(validateExpr); }); test("handles serial configuration", () => { diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.ts index af6b8b36c7..3b9e297e76 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-manifest.ts @@ -18,7 +18,6 @@ import { type TailorDBGQLPermissionSchema, TailorDBType_Permission_Operator, TailorDBType_Permission_Permit, - TailorDBType_PermitAction, type TailorDBType_FieldConfigSchema, type TailorDBType_FileConfigSchema, type TailorDBType_IndexSchema, @@ -30,6 +29,7 @@ import { type TailorDBTypeSchema, } from "@tailor-platform/tailor-proto/tailordb_resource_pb"; import * as inflection from "inflection"; +import { buildTypeScripts } from "#/parser/service/tailordb/type-script"; import { isSnapshotFieldRefOperand } from "./snapshot"; import type { SchemaSnapshot, @@ -163,6 +163,13 @@ export function generateTailorDBTypeManifestFromSnapshot( ? convertRecordPermissionToProto(snapshotType.permissions.record) : defaultPermission; + // Field hooks/validators are aggregated into type-level scripts so that a + // single shared timestamp is observed across every field in one operation. + const { typeHook, typeValidate } = buildTypeScripts(snapshotType.fields, { + typeHookExpr: snapshotType.typeHookExpr, + typeValidateExpr: snapshotType.typeValidateExpr, + }); + return { name: snapshotType.name, schema: { @@ -175,10 +182,18 @@ export function generateTailorDBTypeManifestFromSnapshot( indexes, files, permission, + ...(typeHook && { typeHook }), + ...(typeValidate && { typeValidate }), }, }; } +function optionalOnCreate( + config: Pick, +): Pick, "optionalOnCreate"> { + return config.hooks?.create || config.default !== undefined ? { optionalOnCreate: true } : {}; +} + /** * Convert a snapshot field config to proto format * @param {SnapshotFieldConfig} config - Snapshot field config @@ -194,7 +209,6 @@ export function convertFieldConfigToProto( ? (config.allowedValues?.map((v: SnapshotEnumValue) => ({ ...v })) ?? []) : [], description: config.description || "", - validate: toProtoSnapshotFieldValidate(config), array: config.array ?? false, index: config.index ?? false, unique: config.unique ?? false, @@ -203,7 +217,7 @@ export function convertFieldConfigToProto( foreignKeyField: config.foreignKeyField, required: config.required, vector: config.vector ?? false, - ...toProtoSnapshotFieldHooks(config), + ...optionalOnCreate(config), ...(config.serial && { serial: { start: BigInt(config.serial.start), @@ -226,40 +240,6 @@ export function convertFieldConfigToProto( return fieldEntry; } -function toProtoSnapshotFieldValidate( - config: SnapshotFieldConfig, -): MessageInitShape["validate"] { - return (config.validate ?? []).map((val) => ({ - action: TailorDBType_PermitAction.DENY, - errorMessage: val.errorMessage || "", - script: { - expr: val.script && val.script.expr ? `!${val.script.expr}` : "", - }, - })); -} - -function toProtoSnapshotFieldHooks( - config: SnapshotFieldConfig, -): Pick, "hooks"> | Record { - if (!config.hooks) { - return {}; - } - return { - hooks: { - create: config.hooks.create - ? { - expr: config.hooks.create.expr || "", - } - : undefined, - update: config.hooks.update - ? { - expr: config.hooks.update.expr || "", - } - : undefined, - }, - }; -} - /** * Process nested fields from snapshot format to proto format * @param {Record} fields - Nested fields @@ -277,14 +257,12 @@ function processNestedFieldsFromSnapshot( type: "nested", allowedValues: fieldConfig.allowedValues?.map((v: SnapshotEnumValue) => ({ ...v })) ?? [], description: fieldConfig.description || "", - validate: toProtoSnapshotFieldValidate(fieldConfig), required: fieldConfig.required, array: fieldConfig.array ?? false, index: false, unique: false, foreignKey: false, vector: false, - ...toProtoSnapshotFieldHooks(fieldConfig), fields: deepNestedFields, ...(fieldConfig.scale !== undefined && { scale: fieldConfig.scale }), }; @@ -296,14 +274,13 @@ function processNestedFieldsFromSnapshot( ? (fieldConfig.allowedValues?.map((v: SnapshotEnumValue) => ({ ...v })) ?? []) : [], description: fieldConfig.description || "", - validate: toProtoSnapshotFieldValidate(fieldConfig), required: fieldConfig.required, array: fieldConfig.array ?? false, index: false, unique: false, foreignKey: false, vector: false, - ...toProtoSnapshotFieldHooks(fieldConfig), + ...optionalOnCreate(fieldConfig), ...(fieldConfig.serial && { serial: { start: BigInt(fieldConfig.serial.start), diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-schema.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-schema.ts index 5820180c52..8e040c0c5b 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-schema.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-schema.ts @@ -124,6 +124,7 @@ export const snapshotFieldConfigSchema: z.ZodType = z.loose validate: z.array(snapshotValidationSchema).optional(), serial: snapshotSerialSchema.optional(), scale: z.number().optional(), + default: z.unknown().optional(), fields: z.lazy(() => snapshotRecordSchema(snapshotFieldConfigSchema)).optional(), }) as z.ZodType; diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-types.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-types.ts index 50a2576a63..109be51d82 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-types.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot-types.ts @@ -65,6 +65,7 @@ export interface SnapshotFieldConfig { validate?: SnapshotValidation[]; serial?: SnapshotSerial; scale?: number; + default?: unknown; /** Nested fields (recursive) */ fields?: Record; } @@ -218,6 +219,8 @@ export interface TailorDBSnapshotType { record?: SnapshotRecordPermission; gql?: SnapshotGqlPermission; }; + typeHookExpr?: { create?: string; update?: string }; + typeValidateExpr?: string; } export type SnapshotSettings = NonNullable; diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.ts b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.ts index 1b2290e890..7b46c94c1b 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/snapshot.ts @@ -357,6 +357,7 @@ function createSnapshotFieldConfig(field: ParsedField): SnapshotFieldConfig { } if (field.config.scale !== undefined) config.scale = field.config.scale; + if (field.config.default !== undefined) config.default = field.config.default; if (field.config.fields && Object.keys(field.config.fields).length > 0) { config.fields = {}; @@ -427,6 +428,7 @@ function createSnapshotFieldConfigFromOperatorConfig( } if (fieldConfig.scale !== undefined) config.scale = fieldConfig.scale; + if (fieldConfig.default !== undefined) config.default = fieldConfig.default; // Recursive for nested fields if (fieldConfig.fields && Object.keys(fieldConfig.fields).length > 0) { @@ -501,6 +503,14 @@ export function createSnapshotType(type: TailorDBType): TailorDBSnapshotType { snapshotType.files = { ...type.files }; } + if (type.typeHookExpr) { + snapshotType.typeHookExpr = type.typeHookExpr; + } + + if (type.typeValidateExpr) { + snapshotType.typeValidateExpr = type.typeValidateExpr; + } + if (Object.keys(type.forwardRelationships).length > 0) { snapshotType.forwardRelationships = {}; for (const [relName, rel] of Object.entries(type.forwardRelationships)) { diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/status.ts b/packages/sdk/src/cli/commands/tailordb/migrate/status.ts index b615f24fa8..7677f18775 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/status.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/status.ts @@ -163,15 +163,13 @@ export const statusCommand = defineAppCommand({ name: "status", description: "Show the current migration status for TailorDB namespaces, including applied and pending migrations.", - args: z - .object({ - ...deploymentArgs, - namespace: arg(z.string().optional(), { - alias: "n", - description: "Target TailorDB namespace (shows all namespaces if not specified)", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + namespace: arg(z.string().optional(), { + alias: "n", + description: "Target TailorDB namespace (shows all namespaces if not specified)", + }), + }), run: async (args) => { await status({ configPath: args.config, diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/sync.ts b/packages/sdk/src/cli/commands/tailordb/migrate/sync.ts index 8a4e895fa0..4a0de9e439 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/sync.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/sync.ts @@ -199,7 +199,7 @@ async function assertMigrationsReproduceLocalTypes( { mode: "plain" }, ); logger.info( - " - Type definitions changed without a new migration — run 'tailor-sdk tailordb migration generate' first.", + " - Type definitions changed without a new migration — run 'tailor tailordb migration generate' first.", { mode: "plain" }, ); logger.newline(); @@ -453,7 +453,7 @@ async function sync(options: SyncOptions): Promise { } catch (error) { handleOptionalToRequiredError(error, [ "The target snapshot marks a field as required, but existing remote records have no value for it.", - "Populate those records first (e.g. with a migration script applied via 'tailor-sdk deploy'), then re-run the sync.", + "Populate those records first (e.g. with a migration script applied via 'tailor deploy'), then re-run the sync.", ]); } await Promise.all( @@ -498,7 +498,7 @@ async function sync(options: SyncOptions): Promise { if (targetVersion < latest) { logger.newline(); logger.info( - `Run 'tailor-sdk deploy' to apply migrations ${formatMigrationNumber( + `Run 'tailor deploy' to apply migrations ${formatMigrationNumber( targetVersion + 1, )}–${formatMigrationNumber(latest)} from the working tree.`, ); @@ -509,21 +509,18 @@ export const syncCommand = defineAppCommand({ name: "sync", description: "Sync remote TailorDB schema to a specific migration snapshot (recovery from --no-schema-check drift).", - args: z - .object({ - ...deploymentArgs, - ...confirmationArgs, - number: arg(z.string(), { - positional: true, - description: - "Migration number to sync to (e.g., 0001 or 1; 0 targets the baseline snapshot)", - }), - namespace: arg(z.string().optional(), { - alias: "n", - description: "Target TailorDB namespace (required if multiple namespaces exist)", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...confirmationArgs, + number: arg(z.string(), { + positional: true, + description: "Migration number to sync to (e.g., 0001 or 1; 0 targets the baseline snapshot)", + }), + namespace: arg(z.string().optional(), { + alias: "n", + description: "Target TailorDB namespace (required if multiple namespaces exist)", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); await sync({ diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/template-generator.ts b/packages/sdk/src/cli/commands/tailordb/migrate/template-generator.ts index fd1d4b6ede..1cfe6e98d0 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/template-generator.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/template-generator.ts @@ -164,7 +164,7 @@ export function generateMigrationScript(diff: MigrationDiff): string { * Migration script for ${diff.namespace} * * This script runs between the Pre-migration and Post-migration phases of - * 'tailor-sdk deploy'. Use it to transform existing data so that the schema + * 'tailor deploy'. Use it to transform existing data so that the schema * change can complete safely (for breaking changes, this is hard-required; * for warning-tier changes it is optional). Edit this file to implement * your data migration logic. diff --git a/packages/sdk/src/cli/commands/tailordb/truncate.ts b/packages/sdk/src/cli/commands/tailordb/truncate.ts index 8633c3a046..cf495d4a40 100644 --- a/packages/sdk/src/cli/commands/tailordb/truncate.ts +++ b/packages/sdk/src/cli/commands/tailordb/truncate.ts @@ -207,24 +207,22 @@ async function $truncate(options: InternalTruncateOptions = {}): Promise { export const truncateCommand = defineAppCommand({ name: "truncate", description: "Truncate (delete all records from) TailorDB tables.", - args: z - .object({ - ...deploymentArgs, - ...confirmationArgs, - types: arg(z.string().array().optional(), { - positional: true, - description: "Type names to truncate", - }), - all: arg(z.boolean().default(false), { - alias: "a", - description: "Truncate all tables in all owned namespaces (excludes external namespaces)", - }), - namespace: arg(z.string().optional(), { - alias: "n", - description: "Truncate all tables in specified namespace", - }), - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...confirmationArgs, + types: arg(z.string().array().optional(), { + positional: true, + description: "Type names to truncate", + }), + all: arg(z.boolean().default(false), { + alias: "a", + description: "Truncate all tables in all owned namespaces (excludes external namespaces)", + }), + namespace: arg(z.string().optional(), { + alias: "n", + description: "Truncate all tables in specified namespace", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); const types = args.types && args.types.length > 0 ? args.types : undefined; diff --git a/packages/sdk/src/cli/commands/upgrade/index.ts b/packages/sdk/src/cli/commands/upgrade/index.ts index 2b4db9e6b4..d222dc20be 100644 --- a/packages/sdk/src/cli/commands/upgrade/index.ts +++ b/packages/sdk/src/cli/commands/upgrade/index.ts @@ -6,21 +6,19 @@ import { defineAppCommand } from "#/cli/shared/command"; export const upgradeCommand = defineAppCommand({ name: "upgrade", description: "Run codemods to upgrade your project to a newer SDK version.", - args: z - .object({ - from: arg(z.string(), { - description: "SDK version before the upgrade (e.g., 1.33.0)", - }), - "dry-run": arg(z.boolean().default(false), { - alias: "d", - description: "Preview changes without modifying files", - }), - path: arg(z.string().default("."), { - description: "Project directory to upgrade", - completion: { type: "directory" }, - }), - }) - .strict(), + args: z.strictObject({ + from: arg(z.string(), { + description: "SDK version before the upgrade (e.g., 1.33.0)", + }), + "dry-run": arg(z.boolean().default(false), { + alias: "d", + description: "Preview changes without modifying files", + }), + path: arg(z.string().default("."), { + description: "Project directory to upgrade", + completion: { type: "directory" }, + }), + }), run: async (args) => { const { initTelemetry } = await import("#/cli/telemetry/index"); await initTelemetry(); diff --git a/packages/sdk/src/cli/commands/user/current.ts b/packages/sdk/src/cli/commands/user/current.ts index 5b52657291..d4bb3e55eb 100644 --- a/packages/sdk/src/cli/commands/user/current.ts +++ b/packages/sdk/src/cli/commands/user/current.ts @@ -11,7 +11,7 @@ import ml from "#/utils/multiline"; export const currentCommand = defineAppCommand({ name: "current", description: "Show current user.", - args: z.object({}).strict(), + args: z.strictObject({}), run: async () => { const config = await readPlatformConfig(); const profile = process.env.TAILOR_PLATFORM_PROFILE; @@ -27,7 +27,7 @@ export const currentCommand = defineAppCommand({ if (!currentUser) { throw new Error(ml` Current user not set. - Please login first using 'tailor-sdk login' command to register a user. + Please login first using 'tailor login' command to register a user. `); } @@ -35,7 +35,7 @@ export const currentCommand = defineAppCommand({ if (!hasUserTokenEntry(config, currentUser, platformConfig)) { throw new Error(ml` Current user '${currentUser}' not found in registered users. - Please login again using 'tailor-sdk login' command to register the user. + Please login again using 'tailor login' command to register the user. `); } diff --git a/packages/sdk/src/cli/commands/user/list.ts b/packages/sdk/src/cli/commands/user/list.ts index 4c3eecf256..85db77699e 100644 --- a/packages/sdk/src/cli/commands/user/list.ts +++ b/packages/sdk/src/cli/commands/user/list.ts @@ -47,7 +47,7 @@ function formatUserListInfo(info: UserListInfo): string { export const listCommand = defineAppCommand({ name: "list", description: "List all users.", - args: z.object({}).strict(), + args: z.strictObject({}), run: async () => { const config = await readPlatformConfig(); const jsonOutput = logger.jsonMode; @@ -56,7 +56,7 @@ export const listCommand = defineAppCommand({ if (users.length === 0) { logger.info(ml` No users found. - Please login first using 'tailor-sdk login' command to register a user. + Please login first using 'tailor login' command to register a user. `); if (jsonOutput) { logger.out([]); diff --git a/packages/sdk/src/cli/commands/user/pat/create.ts b/packages/sdk/src/cli/commands/user/pat/create.ts index bfc08b1e94..a816907832 100644 --- a/packages/sdk/src/cli/commands/user/pat/create.ts +++ b/packages/sdk/src/cli/commands/user/pat/create.ts @@ -8,18 +8,16 @@ import { createPatOperatorClient } from "./user"; export const createCommand = defineAppCommand({ name: "create", description: "Create a new personal access token.", - args: z - .object({ - name: arg(z.string(), { - positional: true, - description: "Token name", - }), - write: arg(z.boolean().default(false), { - alias: "W", - description: "Grant write permission (default: read-only)", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + positional: true, + description: "Token name", + }), + write: arg(z.boolean().default(false), { + alias: "W", + description: "Grant write permission (default: read-only)", + }), + }), run: async (args) => { await assertWritable(); const client = await createPatOperatorClient(); diff --git a/packages/sdk/src/cli/commands/user/pat/delete.ts b/packages/sdk/src/cli/commands/user/pat/delete.ts index 3c3e33ba9c..e11af907b2 100644 --- a/packages/sdk/src/cli/commands/user/pat/delete.ts +++ b/packages/sdk/src/cli/commands/user/pat/delete.ts @@ -8,14 +8,12 @@ import { createPatOperatorClient } from "./user"; export const deleteCommand = defineAppCommand({ name: "delete", description: "Delete a personal access token.", - args: z - .object({ - name: arg(z.string(), { - positional: true, - description: "Token name", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + positional: true, + description: "Token name", + }), + }), run: async (args) => { await assertWritable(); const client = await createPatOperatorClient(); diff --git a/packages/sdk/src/cli/commands/user/pat/list.test.ts b/packages/sdk/src/cli/commands/user/pat/list.test.ts index db0ab630b5..4adb0bce52 100644 --- a/packages/sdk/src/cli/commands/user/pat/list.test.ts +++ b/packages/sdk/src/cli/commands/user/pat/list.test.ts @@ -1,11 +1,7 @@ import { runCommand } from "politty"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { initOperatorClient } from "#/cli/shared/client"; -import { - fetchLatestToken, - loadPlatformClientConfig, - readPlatformConfig, -} from "#/cli/shared/context"; +import { fetchLatestToken, readPlatformConfig } from "#/cli/shared/context"; import { jsonMode } from "#/cli/shared/test-helpers/json-mode"; import { listCommand } from "./list"; @@ -17,17 +13,16 @@ vi.mock("#/cli/shared/client", async (importOriginal) => ({ vi.mock("#/cli/shared/context", async (importOriginal) => ({ ...(await importOriginal()), fetchLatestToken: vi.fn(), - loadPlatformClientConfig: vi.fn(), readPlatformConfig: vi.fn(), })); describe("user pat list", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(loadPlatformClientConfig).mockResolvedValue({ - platformUrl: "https://api.dev.tailor.tech", + vi.mocked(fetchLatestToken).mockResolvedValue({ + accessToken: "scoped-token", + user: "u@example.com", }); - vi.mocked(fetchLatestToken).mockResolvedValue("scoped-token"); vi.mocked(initOperatorClient).mockResolvedValue({ listPersonalAccessTokens: vi.fn().mockResolvedValue({ personalAccessTokens: [], @@ -42,8 +37,8 @@ describe("user pat list", () => { test("uses the active profile platform when loading the current user's token", async () => { const config = { - version: 2, - min_sdk_version: "1.29.0", + version: 3, + min_sdk_version: "2.0.0", users: {}, profiles: { dev: { @@ -53,7 +48,7 @@ describe("user pat list", () => { }, }, current_user: null, - } as Awaited>; + } satisfies Awaited>; vi.stubEnv("TAILOR_PLATFORM_PROFILE", "dev"); vi.mocked(readPlatformConfig).mockResolvedValue(config); using _json = jsonMode(); diff --git a/packages/sdk/src/cli/commands/user/pat/list.ts b/packages/sdk/src/cli/commands/user/pat/list.ts index 66e9788768..68547a185b 100644 --- a/packages/sdk/src/cli/commands/user/pat/list.ts +++ b/packages/sdk/src/cli/commands/user/pat/list.ts @@ -10,7 +10,7 @@ import { createPatOperatorClient } from "./user"; export const listCommand = defineAppCommand({ name: "list", description: "List all personal access tokens.", - args: z.object({ ...paginationArgs() }).strict(), + args: z.strictObject({ ...paginationArgs() }), run: async (args) => { const jsonOutput = logger.jsonMode; const client = await createPatOperatorClient(); @@ -31,7 +31,7 @@ export const listCommand = defineAppCommand({ if (pats.length === 0) { logger.info(ml` No personal access tokens found. - Please create a token using 'tailor-sdk user pat create' command. + Please create a token using 'tailor user pat create' command. `); if (!jsonOutput) { return; diff --git a/packages/sdk/src/cli/commands/user/pat/update.ts b/packages/sdk/src/cli/commands/user/pat/update.ts index 60cd42b87e..81f43d62b2 100644 --- a/packages/sdk/src/cli/commands/user/pat/update.ts +++ b/packages/sdk/src/cli/commands/user/pat/update.ts @@ -8,18 +8,16 @@ import { createPatOperatorClient } from "./user"; export const updateCommand = defineAppCommand({ name: "update", description: "Update a personal access token (delete and recreate).", - args: z - .object({ - name: arg(z.string(), { - positional: true, - description: "Token name", - }), - write: arg(z.boolean().default(false), { - alias: "W", - description: "Grant write permission (if not specified, keeps read-only)", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + positional: true, + description: "Token name", + }), + write: arg(z.boolean().default(false), { + alias: "W", + description: "Grant write permission (if not specified, keeps read-only)", + }), + }), run: async (args) => { await assertWritable(); const client = await createPatOperatorClient(); diff --git a/packages/sdk/src/cli/commands/user/pat/user.ts b/packages/sdk/src/cli/commands/user/pat/user.ts index 1d70a4b564..ba1198e5d2 100644 --- a/packages/sdk/src/cli/commands/user/pat/user.ts +++ b/packages/sdk/src/cli/commands/user/pat/user.ts @@ -26,9 +26,9 @@ export async function createPatOperatorClient() { const user = resolvePatUser(config); if (!user) { - throw new Error("No user logged in.\nPlease login first using 'tailor-sdk login' command."); + throw new Error("No user logged in.\nPlease login first using 'tailor login' command."); } - const token = await fetchLatestToken(config, user, platformConfig); - return await initOperatorClient(token, platformConfig); + const { accessToken } = await fetchLatestToken(config, user, platformConfig); + return await initOperatorClient(accessToken, platformConfig); } diff --git a/packages/sdk/src/cli/commands/user/switch.test.ts b/packages/sdk/src/cli/commands/user/switch.test.ts index d62be18b4a..7ebd57dcfe 100644 --- a/packages/sdk/src/cli/commands/user/switch.test.ts +++ b/packages/sdk/src/cli/commands/user/switch.test.ts @@ -34,29 +34,55 @@ describe("user switch", () => { beforeEach(() => { vi.clearAllMocks(); resetKeyringState(); + vi.stubEnv("TAILOR_PLATFORM_PROFILE", undefined); + vi.stubEnv("TAILOR_PLATFORM_URL", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); + if (fs.existsSync(configPath)) fs.rmSync(configPath); + }); + + test("stores the subject-keyed user when switching by email metadata", async () => { writePlatformConfig({ - version: 2, - min_sdk_version: "1.29.0", + version: 3, + min_sdk_version: "2.0.0", users: { - "https://api.dev.tailor.tech|u@example.com": { + "platform-user-sub": { storage: "file", access_token: "token", + refresh_token: "refresh", token_expires_at: "2999-01-01T00:00:00.000Z", + email: "user@example.com", }, }, profiles: {}, current_user: null, }); - }); - afterEach(() => { - vi.unstubAllEnvs(); - const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); - if (fs.existsSync(configPath)) fs.rmSync(configPath); + const result = await runCommand(switchCommand, ["user@example.com"]); + + expect(result.success).toBe(true); + const config = await readPlatformConfig(); + expect(config.current_user).toBe("platform-user-sub"); }); test("stores the bare user when switching to a TAILOR_PLATFORM_URL-scoped token", async () => { vi.stubEnv("TAILOR_PLATFORM_URL", "https://api.dev.tailor.tech"); + writePlatformConfig({ + version: 3, + min_sdk_version: "2.0.0", + users: { + "https://api.dev.tailor.tech|u@example.com": { + storage: "file", + access_token: "token", + token_expires_at: "2999-01-01T00:00:00.000Z", + }, + }, + profiles: {}, + current_user: null, + }); const result = await runCommand(switchCommand, ["u@example.com"]); @@ -66,19 +92,39 @@ describe("user switch", () => { }); test("updates the active profile user when switching users", async () => { + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "https://api.dev.tailor.tech|u@example.com": { + storage: "file", + access_token: "token", + token_expires_at: "2999-01-01T00:00:00.000Z", + }, + }, + profiles: {}, + current_user: null, + }); vi.stubEnv("TAILOR_PLATFORM_PROFILE", "dev"); - const config = await readPlatformConfig(); - config.users["https://api.dev.tailor.tech|other@example.com"] = { - storage: "file", - access_token: "other-token", - token_expires_at: "2999-01-01T00:00:00.000Z", - }; - config.profiles.dev = { - user: "u@example.com", - workspace_id: "12345678-1234-4abc-8def-123456789012", - platform_url: "https://api.dev.tailor.tech", - }; - writePlatformConfig(config); + writePlatformConfig({ + version: 3, + min_sdk_version: "2.0.0", + users: { + "https://api.dev.tailor.tech|other@example.com": { + storage: "file", + access_token: "other-token", + token_expires_at: "2999-01-01T00:00:00.000Z", + }, + }, + profiles: { + dev: { + user: "u@example.com", + workspace_id: "12345678-1234-4abc-8def-123456789012", + platform_url: "https://api.dev.tailor.tech", + }, + }, + current_user: null, + }); const result = await runCommand(switchCommand, ["other@example.com"]); @@ -89,6 +135,20 @@ describe("user switch", () => { }); test("rejects scoped token keys as current user values", async () => { + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "https://api.dev.tailor.tech|u@example.com": { + storage: "file", + access_token: "token", + token_expires_at: "2999-01-01T00:00:00.000Z", + }, + }, + profiles: {}, + current_user: null, + }); + const result = await runCommand(switchCommand, ["https://api.dev.tailor.tech|u@example.com"]); expect(result.success).toBe(false); diff --git a/packages/sdk/src/cli/commands/user/switch.ts b/packages/sdk/src/cli/commands/user/switch.ts index a9d5838678..6f90672473 100644 --- a/packages/sdk/src/cli/commands/user/switch.ts +++ b/packages/sdk/src/cli/commands/user/switch.ts @@ -2,9 +2,9 @@ import { arg } from "politty"; import { z } from "zod"; import { defineAppCommand } from "#/cli/shared/command"; import { - hasUserTokenEntry, platformConfigFromProfile, readPlatformConfig, + resolveConfigUser, writePlatformConfig, } from "#/cli/shared/context"; import { logger } from "#/cli/shared/logger"; @@ -13,14 +13,12 @@ import ml from "#/utils/multiline"; export const switchCommand = defineAppCommand({ name: "switch", description: "Set current user.", - args: z - .object({ - user: arg(z.string(), { - positional: true, - description: "User email", - }), - }) - .strict(), + args: z.strictObject({ + user: arg(z.string(), { + positional: true, + description: "User email address or machine user client ID", + }), + }), run: async (args) => { const config = await readPlatformConfig(); const activeProfileName = process.env.TAILOR_PLATFORM_PROFILE; @@ -38,21 +36,21 @@ export const switchCommand = defineAppCommand({ ); } - // Check if user exists - if (!hasUserTokenEntry(config, args.user, platformConfig)) { + const user = resolveConfigUser(config, args.user, platformConfig); + if (!user) { throw new Error(ml` User "${args.user}" not found. - Please login first using 'tailor-sdk login' command to register this user. + Please login first using 'tailor login' command to register this user. `); } if (activeProfileEntry) { - activeProfileEntry.user = args.user; + activeProfileEntry.user = user; } else { - config.current_user = args.user; + config.current_user = user; } writePlatformConfig(config); - logger.success(`Current user set to "${args.user}" successfully.`); + logger.success(`Current user set to "${user}" successfully.`); }, }); diff --git a/packages/sdk/src/cli/commands/workflow/executions.ts b/packages/sdk/src/cli/commands/workflow/executions.ts index 7b70669912..b2d64944a3 100644 --- a/packages/sdk/src/cli/commands/workflow/executions.ts +++ b/packages/sdk/src/cli/commands/workflow/executions.ts @@ -354,37 +354,35 @@ export function printExecutionWithLogs(execution: WorkflowExecutionDetailInfo): export const executionsCommand = defineAppCommand({ name: "executions", description: "List or get workflow executions.", - args: z - .object({ - ...workspaceArgs, - ...pagedLogArgs, - "execution-id": arg(z.string().optional(), { - positional: true, - description: "Execution ID (if provided, shows details)", - }), - "workflow-name": arg( - z - .string() - .regex( - /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/, - "Must be 3-63 lowercase alphanumeric characters or hyphens, starting and ending with alphanumeric", - ) - .optional(), - { - alias: "n", - description: "Filter by workflow name (list mode only)", - }, - ), - status: arg(z.string().optional(), { - alias: "s", - description: "Filter by status (list mode only)", - }), - ...waitArgs, - logs: arg(z.boolean().default(false), { - description: "Display job execution logs (detail mode only)", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...pagedLogArgs, + "execution-id": arg(z.string().optional(), { + positional: true, + description: "Execution ID (if provided, shows details)", + }), + "workflow-name": arg( + z + .string() + .regex( + /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/, + "Must be 3-63 lowercase alphanumeric characters or hyphens, starting and ending with alphanumeric", + ) + .optional(), + { + alias: "n", + description: "Filter by workflow name (list mode only)", + }, + ), + status: arg(z.string().optional(), { + alias: "s", + description: "Filter by status (list mode only)", + }), + ...waitArgs, + logs: arg(z.boolean().default(false), { + description: "Display job execution logs (detail mode only)", + }), + }), run: async (args) => { const jsonOutput = logger.jsonMode || args.json; if (args.executionId) { diff --git a/packages/sdk/src/cli/commands/workflow/get.ts b/packages/sdk/src/cli/commands/workflow/get.ts index 0825294524..19d0969b0a 100644 --- a/packages/sdk/src/cli/commands/workflow/get.ts +++ b/packages/sdk/src/cli/commands/workflow/get.ts @@ -88,12 +88,10 @@ export async function getWorkflow( export const getCommand = defineAppCommand({ name: "get", description: "Get workflow details.", - args: z - .object({ - ...workspaceArgs, - ...nameArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...nameArgs, + }), run: async (args) => { const workflow = await getWorkflow({ name: args.name, diff --git a/packages/sdk/src/cli/commands/workflow/list.ts b/packages/sdk/src/cli/commands/workflow/list.ts index 854984e377..50e86ef6bc 100644 --- a/packages/sdk/src/cli/commands/workflow/list.ts +++ b/packages/sdk/src/cli/commands/workflow/list.ts @@ -48,12 +48,10 @@ export async function listWorkflows(options?: ListWorkflowsOptions): Promise { const jsonOutput = logger.jsonMode; const workflows = await listWorkflows({ diff --git a/packages/sdk/src/cli/commands/workflow/resume.ts b/packages/sdk/src/cli/commands/workflow/resume.ts index faa0b5205b..96548a1fc6 100644 --- a/packages/sdk/src/cli/commands/workflow/resume.ts +++ b/packages/sdk/src/cli/commands/workflow/resume.ts @@ -77,16 +77,14 @@ export async function resumeWorkflow( export const resumeCommand = defineAppCommand({ name: "resume", description: "Resume a failed or pending workflow execution.", - args: z - .object({ - ...workspaceArgs, - "execution-id": arg(z.string(), { - positional: true, - description: "Failed execution ID", - }), - ...waitArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + "execution-id": arg(z.string(), { + positional: true, + description: "Failed execution ID", + }), + ...waitArgs, + }), run: async (args) => { const jsonOutput = logger.jsonMode || args.json; const { executionId, wait } = await resumeWorkflow({ diff --git a/packages/sdk/src/cli/commands/workflow/start.api-types.test.ts b/packages/sdk/src/cli/commands/workflow/start.api-types.test.ts index 48073cb153..a7a8f860fc 100644 --- a/packages/sdk/src/cli/commands/workflow/start.api-types.test.ts +++ b/packages/sdk/src/cli/commands/workflow/start.api-types.test.ts @@ -1,25 +1,13 @@ // oxlint-disable vitest/expect-expect -- Type-only assertions are checked by TypeScript. import { describe, test, expectTypeOf } from "vitest"; -import { defineAuth } from "#/configure/services/auth/index"; -import { db } from "#/configure/services/tailordb/index"; import { createWorkflow, createWorkflowJob } from "#/configure/services/workflow/index"; import { type StartWorkflowOptions, type StartWorkflowTypedOptions } from "./start"; -const userType = db.type("User", { - email: db.string().unique(), -}); - -const auth = defineAuth("main-auth", { - userProfile: { - type: userType, - usernameField: "email", - }, - machineUsers: { - admin: {}, - worker: {}, - }, -}); - +// `invoker` is typed as `MachineUserName`, which falls back to `string` until +// `tailor.d.ts` augments `MachineUserNameRegistry`. Narrowing to the registered +// machine user union (and rejection of unknown names) is covered against a real +// generated `tailor.d.ts` in `example/`; here we only assert arg inference and +// that machine user names are accepted as strings. const calculationJob = createWorkflowJob({ name: "calculation", body: (input: { a: number; b: number }) => ({ sum: input.a + input.b }), @@ -102,13 +90,13 @@ describe("startWorkflow API types", () => { test("infers arg type from workflow", () => { acceptsCalculationWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { a: 1, b: 2 }, }); acceptsCalculationWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", // @ts-expect-error - arg shape must match workflow input arg: { x: 1, y: 2 }, }); @@ -121,44 +109,41 @@ describe("startWorkflow API types", () => { // @ts-expect-error - arg is required for workflows with input acceptsCalculationWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", }); }); test("does not allow arg for workflows without input", () => { acceptsNoInputWorkflowOptions({ workflow: noInputWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", }); acceptsNoInputWorkflowOptions({ workflow: noInputWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", // @ts-expect-error - no-input workflow must not receive arg arg: { any: "value" }, }); }); - test("keeps machine user names type-safe via auth.invoker", () => { + test("accepts machine user names as strings", () => { acceptsCalculationWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("worker"), + invoker: "worker", arg: { a: 1, b: 2 }, }); - - // @ts-expect-error - invalid machine user name - auth.invoker("invalid-machine-user"); }); test("keeps default generic usable when StartWorkflowTypedOptions generic is omitted", () => { acceptsDefaultWorkflowOptions({ workflow: noInputWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", }); acceptsDefaultWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { a: 1, b: 2 }, }); }); @@ -166,26 +151,26 @@ describe("startWorkflow API types", () => { test("supports union workflow types without collapsing arg type", () => { acceptsUnionWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { a: 1, b: 2 }, }); acceptsUnionWorkflowOptions({ workflow: noInputWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", }); }); test("supports union workflow input types without collapsing arg type", () => { acceptsUnionInputWorkflowOptions({ workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { a: 1, b: 2 }, }); acceptsUnionInputWorkflowOptions({ workflow: textWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { message: "hello" }, }); @@ -198,13 +183,13 @@ describe("startWorkflow API types", () => { test("supports plain workflow unions without collapsing arg type", () => { acceptsPlainUnionWorkflowOptions({ workflow: plainWorkflowA, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { foo: 1 }, }); acceptsPlainUnionWorkflowOptions({ workflow: plainWorkflowB, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { bar: "x" }, }); @@ -228,7 +213,7 @@ describe("startWorkflow API types", () => { acceptsDeprecatedOptions({ // @ts-expect-error - deprecated options must keep legacy name/machineUser shape workflow: calculationWorkflow, - authInvoker: auth.invoker("admin"), + invoker: "admin", arg: { a: 1, b: 2 }, }); }); diff --git a/packages/sdk/src/cli/commands/workflow/start.test.ts b/packages/sdk/src/cli/commands/workflow/start.test.ts index 9f3d65128d..7eadc38875 100644 --- a/packages/sdk/src/cli/commands/workflow/start.test.ts +++ b/packages/sdk/src/cli/commands/workflow/start.test.ts @@ -85,10 +85,7 @@ describe("startWorkflow runtime overload", () => { body: () => undefined, }, }, - authInvoker: { - namespace: "typed-ns", - machineUserName: "typed-user", - }, + invoker: "typed-user", } as never); expect(loadConfig).toHaveBeenCalledTimes(1); @@ -102,10 +99,92 @@ describe("startWorkflow runtime overload", () => { expect(testStartWorkflowMock).toHaveBeenCalledWith( expect.objectContaining({ workflowId: "id:legacy-workflow", + authInvoker: expect.objectContaining({ + namespace: "auth-ns", + machineUserName: "legacy-user", + }), }), ); }); + test("typed shape resolves auth namespace from config and sends proto credentials", async () => { + await startWorkflow({ + workflow: { + name: "typed-workflow", + mainJob: { + body: () => undefined, + }, + }, + invoker: "typed-user", + }); + + expect(loadConfig).toHaveBeenCalledTimes(1); + expect(getApplicationMock).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + applicationName: "my-app", + }); + expect(testStartWorkflowMock).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: "id:typed-workflow", + authInvoker: expect.objectContaining({ + namespace: "auth-ns", + machineUserName: "typed-user", + }), + }), + ); + }); + + test("typed shape falls back to external auth config name", async () => { + vi.mocked(loadConfig).mockResolvedValueOnce({ + config: { + name: "my-app", + auth: { name: "external-auth", external: true }, + }, + } as Awaited>); + getApplicationMock.mockResolvedValueOnce({ + application: {}, + }); + + await startWorkflow({ + workflow: { + name: "typed-workflow", + mainJob: { + body: () => undefined, + }, + }, + invoker: "typed-user", + }); + + expect(testStartWorkflowMock).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: "id:typed-workflow", + authInvoker: expect.objectContaining({ + namespace: "external-auth", + machineUserName: "typed-user", + }), + }), + ); + }); + + test("throws when neither the deployed app nor the config has an auth namespace", async () => { + getApplicationMock.mockResolvedValueOnce({ + application: {}, + }); + + await expect( + startWorkflow({ + workflow: { + name: "typed-workflow", + mainJob: { + body: () => undefined, + }, + }, + invoker: "typed-user", + }), + ).rejects.toThrow("my-app does not have an auth configuration"); + expect(testStartWorkflowMock).not.toHaveBeenCalled(); + }); + test("start command with jsonMode emits only parseable JSON to stdout", async () => { using stdout = captureStdout(); using stderr = captureStderr(); diff --git a/packages/sdk/src/cli/commands/workflow/start.ts b/packages/sdk/src/cli/commands/workflow/start.ts index 796e33a2b2..87c72f0a48 100644 --- a/packages/sdk/src/cli/commands/workflow/start.ts +++ b/packages/sdk/src/cli/commands/workflow/start.ts @@ -18,6 +18,10 @@ import { waitForWorkflowExecution, type WorkflowWaitResult, } from "./waiter"; +// Import from the public entry (not `@/types/auth`) so the `./cli` d.ts references +// `@tailor-platform/sdk` externally instead of inlining the registry — a single +// generated `declare module "@tailor-platform/sdk"` then narrows both entries. +import type { MachineUserName } from "@tailor-platform/sdk"; import type { Jsonifiable } from "type-fest"; type WorkflowLike = { @@ -27,7 +31,7 @@ type WorkflowLike = { }; }; -type AuthInvoker = { +type WorkflowInvoker = { namespace: string; machineUserName: M; }; @@ -63,9 +67,10 @@ export interface StartWorkflowOptions { type StartWorkflowTypedBaseOptions = { workflow: W; - authInvoker: AuthInvoker; + invoker: MachineUserName; workspaceId?: string; profile?: string; + configPath?: string; interval?: number; }; @@ -89,7 +94,7 @@ interface StartWorkflowCoreOptions { client: Awaited>; workspaceId: string; workflowName: string; - authInvoker: AuthInvoker; + invoker: WorkflowInvoker; arg?: unknown; interval?: number; } @@ -101,7 +106,7 @@ async function startWorkflowCore( try { const workflow = await resolveWorkflow(client, workspaceId, workflowName); - const authInvoker = create(AuthInvokerSchema, options.authInvoker); + const invoker = create(AuthInvokerSchema, options.invoker); const arg = options.arg === undefined ? undefined @@ -112,7 +117,7 @@ async function startWorkflowCore( const { executionId } = await client.testStartWorkflow({ workspaceId, workflowId: workflow.id, - authInvoker, + authInvoker: invoker, arg, }); @@ -138,6 +143,23 @@ async function startWorkflowCore( } } +async function resolveApplicationAuthNamespace(options: { + client: Awaited>; + workspaceId: string; + configPath?: string; +}): Promise { + const { config } = await loadConfig(options.configPath); + const { application } = await options.client.getApplication({ + workspaceId: options.workspaceId, + applicationName: config.name, + }); + const authNamespace = application?.authNamespace || config.auth?.name; + if (!authNamespace) { + throw new Error(`Application ${config.name} does not have an auth configuration.`); + } + return authNamespace; +} + async function startWorkflowByName( options: StartWorkflowOptions, ): Promise { @@ -147,7 +169,7 @@ async function startWorkflowByName( }); if (!machineUser) { throw new Error( - "Machine user is required. Specify --machine-user, set TAILOR_PLATFORM_MACHINE_USER_NAME, or set a profile default with 'tailor-sdk profile update --machine-user '.", + "Machine user is required. Specify --machine-user, set TAILOR_PLATFORM_MACHINE_USER_NAME, or set a profile default with 'tailor profile update --machine-user '.", ); } @@ -160,21 +182,18 @@ async function startWorkflowByName( profile: options.profile, }); - const { config } = await loadConfig(options.configPath); - const { application } = await client.getApplication({ + const authNamespace = await resolveApplicationAuthNamespace({ + client, workspaceId, - applicationName: config.name, + configPath: options.configPath, }); - if (!application?.authNamespace) { - throw new Error(`Application ${config.name} does not have an auth configuration.`); - } return await startWorkflowCore({ client, workspaceId, workflowName: options.name, - authInvoker: { - namespace: application.authNamespace, + invoker: { + namespace: authNamespace, machineUserName: machineUser, }, arg: options.arg, @@ -209,12 +228,20 @@ export async function startWorkflow( workspaceId: options.workspaceId, profile: options.profile, }); + const authNamespace = await resolveApplicationAuthNamespace({ + client, + workspaceId, + configPath: options.configPath, + }); return await startWorkflowCore({ client, workspaceId, workflowName: options.workflow.name, - authInvoker: options.authInvoker, + invoker: { + namespace: authNamespace, + machineUserName: options.invoker, + }, arg: options.arg, interval: options.interval, }); @@ -223,23 +250,20 @@ export async function startWorkflow( export const startCommand = defineAppCommand({ name: "start", description: "Start a workflow execution.", - args: z - .object({ - ...deploymentArgs, - ...nameArgs, - "machine-user": arg(z.string().optional(), { - alias: "m", - hiddenAlias: "machineuser", - description: "Machine user name. Falls back to the active profile's default machine user.", - env: "TAILOR_PLATFORM_MACHINE_USER_NAME", - }), - arg: arg(z.string().optional(), { - alias: "a", - description: "Workflow argument (JSON string)", - }), - ...waitArgs, - }) - .strict(), + args: z.strictObject({ + ...deploymentArgs, + ...nameArgs, + "machine-user": arg(z.string().optional(), { + alias: "m", + description: "Machine user name. Falls back to the active profile's default machine user.", + env: "TAILOR_PLATFORM_MACHINE_USER_NAME", + }), + arg: arg(z.string().optional(), { + alias: "a", + description: "Workflow argument (JSON string)", + }), + ...waitArgs, + }), run: async (args) => { const { executionId, wait } = await startWorkflowByName({ name: args.name, diff --git a/packages/sdk/src/cli/commands/workflow/wait.ts b/packages/sdk/src/cli/commands/workflow/wait.ts index c2f19d2244..5f244927d4 100644 --- a/packages/sdk/src/cli/commands/workflow/wait.ts +++ b/packages/sdk/src/cli/commands/workflow/wait.ts @@ -71,16 +71,14 @@ export const waitCommand = defineAppCommand({ desc: "Wait for success, failure, or suspension", }, ], - args: z - .object({ - ...workspaceArgs, - "execution-id": arg(z.string(), { - positional: true, - description: "Execution ID", - }), - ...workflowWaitControlArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + "execution-id": arg(z.string(), { + positional: true, + description: "Execution ID", + }), + ...workflowWaitControlArgs, + }), run: async (args) => { const jsonOutput = logger.jsonMode || args.json; const result = await waitWorkflowExecution({ diff --git a/packages/sdk/src/cli/commands/workspace/app/health.ts b/packages/sdk/src/cli/commands/workspace/app/health.ts index 807190d6db..c84b75b86d 100644 --- a/packages/sdk/src/cli/commands/workspace/app/health.ts +++ b/packages/sdk/src/cli/commands/workspace/app/health.ts @@ -9,6 +9,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { appHealthInfo, type AppHealthInfo } from "./transform"; +// strip unknown keys const healthOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -56,15 +57,13 @@ export async function getAppHealth(options: HealthOptions): Promise { const health = await getAppHealth({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/workspace/app/list.ts b/packages/sdk/src/cli/commands/workspace/app/list.ts index 45f0ef57af..f747ac5ddc 100644 --- a/packages/sdk/src/cli/commands/workspace/app/list.ts +++ b/packages/sdk/src/cli/commands/workspace/app/list.ts @@ -8,6 +8,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { appInfo, type AppInfo } from "./transform"; +// strip unknown keys const listAppsOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -66,12 +67,10 @@ export async function listApps(options: ListAppsOptions): Promise { export const listCommand = defineAppCommand({ name: "list", description: "List applications in a workspace", - args: z - .object({ - ...workspaceArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...paginationArgs(), + }), run: async (args) => { const jsonOutput = logger.jsonMode; const apps = await listApps({ diff --git a/packages/sdk/src/cli/commands/workspace/create.ts b/packages/sdk/src/cli/commands/workspace/create.ts index 9920943afa..a0da9dd5b0 100644 --- a/packages/sdk/src/cli/commands/workspace/create.ts +++ b/packages/sdk/src/cli/commands/workspace/create.ts @@ -11,10 +11,10 @@ import { } from "#/cli/shared/client"; import { defineAppCommand } from "#/cli/shared/command"; import { - hasUserTokenEntry, loadAccessToken, platformConfigFromProfile, readPlatformConfig, + resolveConfigUser, writePlatformConfig, } from "#/cli/shared/context"; import { logger } from "#/cli/shared/logger"; @@ -33,6 +33,7 @@ import type { ProfileInfo } from "../profile"; * - name: 3-63 chars, lowercase alphanumeric and hyphens, cannot start/end with hyphen * - organizationId, folderId: optional UUIDs */ +// strip unknown keys const createWorkspaceOptionsSchema = z.object({ name: z .string() @@ -108,43 +109,42 @@ export async function createWorkspace(options: CreateWorkspaceOptions): Promise< export const createCommand = defineAppCommand({ name: "create", description: "Create a new Tailor Platform workspace.", - args: z - .object({ - name: arg(z.string(), { - alias: "n", - description: "Workspace name", - }), - region: arg(z.string(), { - alias: "r", - description: "Workspace region (us-west, asia-northeast)", - }), - "delete-protection": arg(z.boolean().default(false), { - alias: "d", - description: "Enable delete protection", - }), - "organization-id": arg(z.string().optional(), { - alias: "o", - description: "Organization ID to workspace associate with", - env: "TAILOR_PLATFORM_ORGANIZATION_ID", - }), - "folder-id": arg(z.string().optional(), { - alias: "f", - description: "Folder ID to workspace associate with", - env: "TAILOR_PLATFORM_FOLDER_ID", - }), - "profile-name": arg(z.string().optional(), { - alias: "p", - description: "Profile name to create", - }), - "profile-user": arg(z.string().optional(), { - description: "User email for the profile (defaults to current user)", - }), - permission: arg(z.enum(["write", "read"]).default("write"), { - description: - "Profile permission (requires --profile-name). 'read' blocks all write commands while the profile is active.", - }), - }) - .strict(), + args: z.strictObject({ + name: arg(z.string(), { + alias: "n", + description: "Workspace name", + }), + region: arg(z.string(), { + alias: "r", + description: "Workspace region (us-west, asia-northeast)", + }), + "delete-protection": arg(z.boolean().default(false), { + alias: "d", + description: "Enable delete protection", + }), + "organization-id": arg(z.string().optional(), { + alias: "o", + description: "Organization ID to workspace associate with", + env: "TAILOR_PLATFORM_ORGANIZATION_ID", + }), + "folder-id": arg(z.string().optional(), { + alias: "f", + description: "Folder ID to workspace associate with", + env: "TAILOR_PLATFORM_FOLDER_ID", + }), + "profile-name": arg(z.string().optional(), { + alias: "p", + description: "Profile name to create", + }), + "profile-user": arg(z.string().optional(), { + description: + "User email address or machine user client ID for the profile (defaults to current user)", + }), + permission: arg(z.enum(["write", "read"]).default("write"), { + description: + "Profile permission (requires --profile-name). 'read' blocks all write commands while the profile is active.", + }), + }), run: async (args) => { // This command does not expose `--profile`, so the guard resolves the // active profile from `TAILOR_PLATFORM_PROFILE` only. @@ -175,14 +175,15 @@ export const createCommand = defineAppCommand({ ); } - if (!hasUserTokenEntry(config, profileUser, platformConfig)) { + const resolvedProfileUser = resolveConfigUser(config, profileUser, platformConfig); + if (!resolvedProfileUser) { throw new Error( - `User "${profileUser}" not found.\nPlease verify your user name and login using 'tailor-sdk login' command.`, + `User "${profileUser}" not found.\nPlease verify your user name and login using 'tailor login' command.`, ); } profileSetup = { name: profileName, - user: profileUser, + user: resolvedProfileUser, platformSettings: profilePlatformSettings(platformConfig), }; } diff --git a/packages/sdk/src/cli/commands/workspace/delete.ts b/packages/sdk/src/cli/commands/workspace/delete.ts index 22f09b8201..24048fc43d 100644 --- a/packages/sdk/src/cli/commands/workspace/delete.ts +++ b/packages/sdk/src/cli/commands/workspace/delete.ts @@ -10,6 +10,7 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; import { resolveWorkspaceFolderName, workspaceDisplayName } from "./transform"; +// strip unknown keys const deleteWorkspaceOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }), }); @@ -50,15 +51,13 @@ export async function deleteWorkspace(options: DeleteWorkspaceOptions): Promise< export const deleteCommand = defineAppCommand({ name: "delete", description: "Delete a Tailor Platform workspace.", - args: z - .object({ - "workspace-id": arg(z.string(), { - alias: "w", - description: "Workspace ID", - }), - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + "workspace-id": arg(z.string(), { + alias: "w", + description: "Workspace ID", + }), + ...confirmationArgs, + }), run: async (args) => { await assertWritable(); // Load and validate options diff --git a/packages/sdk/src/cli/commands/workspace/get.ts b/packages/sdk/src/cli/commands/workspace/get.ts index 31ce0c6f0e..7224f1a9f9 100644 --- a/packages/sdk/src/cli/commands/workspace/get.ts +++ b/packages/sdk/src/cli/commands/workspace/get.ts @@ -12,6 +12,7 @@ import { type WorkspaceDetails, } from "./transform"; +// strip unknown keys const getWorkspaceOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -60,11 +61,9 @@ export async function getWorkspace(options: GetWorkspaceOptions): Promise { const workspace = await getWorkspace({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/workspace/list.ts b/packages/sdk/src/cli/commands/workspace/list.ts index 91d27277df..6c5d9992ff 100644 --- a/packages/sdk/src/cli/commands/workspace/list.ts +++ b/packages/sdk/src/cli/commands/workspace/list.ts @@ -43,11 +43,9 @@ export async function listWorkspaces(options?: ListWorkspacesOptions): Promise { const workspaces = await listWorkspaces({ order: args.order, diff --git a/packages/sdk/src/cli/commands/workspace/restore.ts b/packages/sdk/src/cli/commands/workspace/restore.ts index 7efff4a270..3214337900 100644 --- a/packages/sdk/src/cli/commands/workspace/restore.ts +++ b/packages/sdk/src/cli/commands/workspace/restore.ts @@ -9,6 +9,7 @@ import { prompt } from "#/cli/shared/prompt"; import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; +// strip unknown keys const restoreWorkspaceOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }), }); @@ -46,15 +47,13 @@ export async function restoreWorkspace(options: RestoreWorkspaceOptions): Promis export const restoreCommand = defineAppCommand({ name: "restore", description: "Restore a deleted workspace", - args: z - .object({ - "workspace-id": arg(z.string(), { - alias: "w", - description: "Workspace ID", - }), - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + "workspace-id": arg(z.string(), { + alias: "w", + description: "Workspace ID", + }), + ...confirmationArgs, + }), run: async (args) => { await assertWritable(); const { client, workspaceId } = await loadOptions({ diff --git a/packages/sdk/src/cli/commands/workspace/user/invite.ts b/packages/sdk/src/cli/commands/workspace/user/invite.ts index e13e3cf08a..c6c0bdc268 100644 --- a/packages/sdk/src/cli/commands/workspace/user/invite.ts +++ b/packages/sdk/src/cli/commands/workspace/user/invite.ts @@ -9,6 +9,7 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; import { stringToRole, validRoles } from "./transform"; +// strip unknown keys const inviteUserOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -57,18 +58,16 @@ export async function inviteUser(options: InviteUserOptions): Promise { export const inviteCommand = defineAppCommand({ name: "invite", description: "Invite a user to a workspace", - args: z - .object({ - ...workspaceArgs, - email: arg(z.email(), { - description: "Email address of the user to invite", - }), - role: arg(z.enum(validRoles), { - description: `Role to assign (${validRoles.join(", ")})`, - alias: "r", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + email: arg(z.email(), { + description: "Email address of the user to invite", + }), + role: arg(z.enum(validRoles), { + description: `Role to assign (${validRoles.join(", ")})`, + alias: "r", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); await inviteUser({ diff --git a/packages/sdk/src/cli/commands/workspace/user/list.ts b/packages/sdk/src/cli/commands/workspace/user/list.ts index ea28f2d3c9..559e1b2b23 100644 --- a/packages/sdk/src/cli/commands/workspace/user/list.ts +++ b/packages/sdk/src/cli/commands/workspace/user/list.ts @@ -7,6 +7,7 @@ import { logger } from "#/cli/shared/logger"; import { assertDefined } from "#/utils/assert"; import { userInfo, type UserInfo } from "./transform"; +// strip unknown keys const listUsersOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -65,12 +66,10 @@ export async function listUsers(options: ListUsersOptions): Promise export const listCommand = defineAppCommand({ name: "list", description: "List users in a workspace", - args: z - .object({ - ...workspaceArgs, - ...paginationArgs(), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + ...paginationArgs(), + }), run: async (args) => { const users = await listUsers({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/workspace/user/remove.ts b/packages/sdk/src/cli/commands/workspace/user/remove.ts index 3de9e9a19d..a5f260f7da 100644 --- a/packages/sdk/src/cli/commands/workspace/user/remove.ts +++ b/packages/sdk/src/cli/commands/workspace/user/remove.ts @@ -9,6 +9,7 @@ import { prompt } from "#/cli/shared/prompt"; import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; +// strip unknown keys const removeUserOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -54,15 +55,13 @@ export async function removeUser(options: RemoveUserOptions): Promise { export const removeCommand = defineAppCommand({ name: "remove", description: "Remove a user from a workspace", - args: z - .object({ - ...workspaceArgs, - email: arg(z.email(), { - description: "Email address of the user to remove", - }), - ...confirmationArgs, - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + email: arg(z.email(), { + description: "Email address of the user to remove", + }), + ...confirmationArgs, + }), run: async (args) => { await assertWritable({ profile: args.profile }); if (!args.yes) { diff --git a/packages/sdk/src/cli/commands/workspace/user/update.ts b/packages/sdk/src/cli/commands/workspace/user/update.ts index f3d98a12dc..d185e4524a 100644 --- a/packages/sdk/src/cli/commands/workspace/user/update.ts +++ b/packages/sdk/src/cli/commands/workspace/user/update.ts @@ -9,6 +9,7 @@ import { assertWritable } from "#/cli/shared/readonly-guard"; import { assertDefined } from "#/utils/assert"; import { stringToRole, validRoles } from "./transform"; +// strip unknown keys const updateUserOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), profile: z.string().optional(), @@ -57,18 +58,16 @@ export async function updateUser(options: UpdateUserOptions): Promise { export const updateCommand = defineAppCommand({ name: "update", description: "Update a user's role in a workspace", - args: z - .object({ - ...workspaceArgs, - email: arg(z.email(), { - description: "Email address of the user to update", - }), - role: arg(z.enum(validRoles), { - description: `New role to assign (${validRoles.join(", ")})`, - alias: "r", - }), - }) - .strict(), + args: z.strictObject({ + ...workspaceArgs, + email: arg(z.email(), { + description: "Email address of the user to update", + }), + role: arg(z.enum(validRoles), { + description: `New role to assign (${validRoles.join(", ")})`, + alias: "r", + }), + }), run: async (args) => { await assertWritable({ profile: args.profile }); await updateUser({ diff --git a/packages/sdk/src/cli/completion.test.ts b/packages/sdk/src/cli/completion.test.ts index 1b9cff99a3..fc0082b99b 100644 --- a/packages/sdk/src/cli/completion.test.ts +++ b/packages/sdk/src/cli/completion.test.ts @@ -120,7 +120,7 @@ describe("shell completion", () => { candidates: readonly { value: string; description?: string }[]; }[]; } { - const data = extractCompletionData(mainCommand, "tailor-sdk"); + const data = extractCompletionData(mainCommand, "tailor"); const apiCmd = data.command.subcommands.find((s) => s.name === "api"); if (!apiCmd) throw new Error("api subcommand missing"); const fieldOpt = apiCmd.options.find((o) => o.name === "field"); @@ -186,9 +186,9 @@ describe("shell completion", () => { // repeated. Confirm both are wired up in the zsh script. const { script } = generateCompletion(mainCommand, { shell: "zsh", - programName: "tailor-sdk", + programName: "tailor", }); - expect(script).toMatch(/__tailor_sdk_expand_[a-z_]+__field=/); + expect(script).toMatch(/__tailor_expand_[a-z_]+__field=/); expect(script).toContain("GetFunctionExecution"); expect(script).toContain("_used_field_keys"); }); diff --git a/packages/sdk/src/cli/crashreport/index.ts b/packages/sdk/src/cli/crashreport/index.ts index 274b24f0fc..aee9be83b6 100644 --- a/packages/sdk/src/cli/crashreport/index.ts +++ b/packages/sdk/src/cli/crashreport/index.ts @@ -35,7 +35,7 @@ export async function reportCrash(error: unknown, errorType: ErrorType): Promise ` ${filePath}`, "", "To submit this report:", - ` tailor-sdk crashreport send --file "${filePath}"`, + ` tailor crashreport send --file "${filePath}"`, ].join("\n"), ); } diff --git a/packages/sdk/src/cli/crashreport/report.ts b/packages/sdk/src/cli/crashreport/report.ts index ca879c4f65..f70b2f00dc 100644 --- a/packages/sdk/src/cli/crashreport/report.ts +++ b/packages/sdk/src/cli/crashreport/report.ts @@ -84,26 +84,44 @@ export function buildCrashReport(options: BuildCrashReportOptions): CrashReport errorMessage: sanitizeMessage(rawMessage), stackTrace: sanitizeStackTrace(rawStack), errorType, - userId: currentUser, - userEmail: currentUser, + userId: currentUser?.id ?? null, + userEmail: currentUser?.email ?? null, }; } +type CurrentUser = { + id: string; + email: string | null; +}; + /** * Read current_user from Tailor Platform config without side effects. * Unlike readPlatformConfig(), this never triggers migration or logs warnings. - * @returns The current user email, or null if unavailable + * @returns The current user ID and email, or null if unavailable */ -function readCurrentUser(): string | null { +function readCurrentUser(): CurrentUser | null { try { if (!xdgConfig) return null; const configPath = path.join(xdgConfig, "tailor-platform", "config.yaml"); if (!fs.existsSync(configPath)) return null; - const raw = parseYAML(fs.readFileSync(configPath, "utf-8")) as { current_user?: string | null }; + const raw = parseYAML(fs.readFileSync(configPath, "utf-8")) as { + current_user?: string | null; + users?: Record; + }; // parseYAML returns null for empty documents // oxlint-disable-next-line typescript/no-unnecessary-condition - return raw?.current_user ?? null; + const currentUser = raw?.current_user ?? null; + if (!currentUser) return null; + const email = raw.users?.[currentUser]?.email; + return { + id: currentUser, + email: typeof email === "string" ? email : legacyEmail(currentUser), + }; } catch { return null; } } + +function legacyEmail(user: string): string | null { + return user.includes("@") ? user : null; +} diff --git a/packages/sdk/src/cli/crashreport/sanitize.test.ts b/packages/sdk/src/cli/crashreport/sanitize.test.ts index 05b4aa7c17..f308c8a322 100644 --- a/packages/sdk/src/cli/crashreport/sanitize.test.ts +++ b/packages/sdk/src/cli/crashreport/sanitize.test.ts @@ -128,42 +128,42 @@ describe("sanitizeMessage", () => { describe("sanitizeArgv", () => { test("keeps command and subcommand names", () => { - const argv = ["node", "tailor-sdk", "apply"]; - expect(sanitizeArgv(argv)).toEqual(["node", "tailor-sdk", "apply"]); + const argv = ["node", "tailor", "apply"]; + expect(sanitizeArgv(argv)).toEqual(["node", "tailor", "apply"]); }); test.each([ { name: "redacts value after any long flag (space format)", - argv: ["node", "tailor-sdk", "show", "--workspace-id", "some-uuid"], - expected: ["node", "tailor-sdk", "show", "--workspace-id", ""], + argv: ["node", "tailor", "show", "--workspace-id", "some-uuid"], + expected: ["node", "tailor", "show", "--workspace-id", ""], }, { name: "redacts value after any short flag (space format)", - argv: ["node", "tailor-sdk", "show", "-w", "some-uuid"], - expected: ["node", "tailor-sdk", "show", "-w", ""], + argv: ["node", "tailor", "show", "-w", "some-uuid"], + expected: ["node", "tailor", "show", "-w", ""], }, { name: "redacts value after any flag regardless of flag name", - argv: ["node", "tailor-sdk", "apply", "--region", "asia-northeast"], - expected: ["node", "tailor-sdk", "apply", "--region", ""], + argv: ["node", "tailor", "apply", "--region", "asia-northeast"], + expected: ["node", "tailor", "apply", "--region", ""], }, { name: "treats consecutive flags correctly (no value between them)", - argv: ["node", "tailor-sdk", "apply", "--verbose", "--yes"], - expected: ["node", "tailor-sdk", "apply", "--verbose", "--yes"], + argv: ["node", "tailor", "apply", "--verbose", "--yes"], + expected: ["node", "tailor", "apply", "--verbose", "--yes"], }, { name: "redacts value after boolean flag followed by valued flag", - argv: ["node", "tailor-sdk", "apply", "--verbose", "--workspace-id", "secret"], - expected: ["node", "tailor-sdk", "apply", "--verbose", "--workspace-id", ""], + argv: ["node", "tailor", "apply", "--verbose", "--workspace-id", "secret"], + expected: ["node", "tailor", "apply", "--verbose", "--workspace-id", ""], }, ])("$name", ({ argv, expected }) => { expect(sanitizeArgv(argv)).toEqual(expected); }); test("redacts --flag=value (equals format)", () => { - const argv = ["node", "tailor-sdk", "show", "--workspace-id=some-uuid"]; + const argv = ["node", "tailor", "show", "--workspace-id=some-uuid"]; const result = sanitizeArgv(argv); expect(result).toContain("--workspace-id="); expect(result).not.toContain("some-uuid"); @@ -172,19 +172,19 @@ describe("sanitizeArgv", () => { test.each([ { name: "redacts absolute path positional arguments", - argv: ["node", "tailor-sdk", "/home/user/project/tailor.config.ts"], + argv: ["node", "tailor", "/home/user/project/tailor.config.ts"], contains: "", excludes: "/home/user/", }, { name: "redacts Windows-style absolute path positional arguments", - argv: ["node", "tailor-sdk", "C:\\Users\\admin\\project\\tailor.config.ts"], + argv: ["node", "tailor", "C:\\Users\\admin\\project\\tailor.config.ts"], contains: "", excludes: "C:\\Users\\admin", }, { name: "redacts email address positional arguments", - argv: ["node", "tailor-sdk", "user", "switch", "user@example.com"], + argv: ["node", "tailor", "user", "switch", "user@example.com"], contains: "", excludes: "user@example.com", }, diff --git a/packages/sdk/src/cli/crashreport/sender.test.ts b/packages/sdk/src/cli/crashreport/sender.test.ts index 0de3ba59b3..8709af8661 100644 --- a/packages/sdk/src/cli/crashreport/sender.test.ts +++ b/packages/sdk/src/cli/crashreport/sender.test.ts @@ -12,7 +12,7 @@ function makeCrashReport(): CrashReport { osRelease: "25.3.0", arch: "arm64", command: "apply", - argv: ["node", "tailor-sdk", "apply"], + argv: ["node", "tailor", "apply"], errorName: "TypeError", errorMessage: "Cannot read properties of undefined", stackTrace: "TypeError: Cannot read properties of undefined", @@ -51,7 +51,7 @@ describe("sendCrashReport", () => { mockFetchResolvedValue({ data: { submitCrashReport: { success: true } } }); const report = makeCrashReport(); - await sendCrashReport(report, "tailor-sdk/1.0.0"); + await sendCrashReport(report, "tailor/1.0.0"); const call = vi.mocked(globalThis.fetch).mock.calls[0]!; const body = JSON.parse(call[1]!.body as string); @@ -67,7 +67,7 @@ describe("sendCrashReport", () => { mockFetchResolvedValue({ data: { submitCrashReport: { success: true } } }); const report = makeCrashReport(); - await sendCrashReport(report, "tailor-sdk/1.0.0"); + await sendCrashReport(report, "tailor/1.0.0"); const call = vi.mocked(globalThis.fetch).mock.calls[0]!; const { variables } = JSON.parse(call[1]!.body as string); @@ -98,7 +98,7 @@ describe("sendCrashReport", () => { ])("%s", async (_name, response, expected) => { mockFetchResolvedValue(response); - const result = await sendCrashReport(makeCrashReport(), "tailor-sdk/1.0.0"); + const result = await sendCrashReport(makeCrashReport(), "tailor/1.0.0"); expect(result).toBe(expected); }); @@ -110,7 +110,7 @@ describe("sendCrashReport", () => { json: () => Promise.resolve({}), }); - const result = await sendCrashReport(makeCrashReport(), "tailor-sdk/1.0.0"); + const result = await sendCrashReport(makeCrashReport(), "tailor/1.0.0"); expect(result).toBe(false); }); @@ -118,7 +118,7 @@ describe("sendCrashReport", () => { test("returns false on network error", async () => { globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network error")); - const result = await sendCrashReport(makeCrashReport(), "tailor-sdk/1.0.0"); + const result = await sendCrashReport(makeCrashReport(), "tailor/1.0.0"); expect(result).toBe(false); }); @@ -127,7 +127,7 @@ describe("sendCrashReport", () => { mockFetchResolvedValue({ data: { submitCrashReport: { success: true } } }); process.env.TAILOR_CRASH_REPORT_ENDPOINT = "https://custom.example.com/query"; - await sendCrashReport(makeCrashReport(), "tailor-sdk/1.0.0"); + await sendCrashReport(makeCrashReport(), "tailor/1.0.0"); expect(globalThis.fetch).toHaveBeenCalledWith( "https://custom.example.com/query", @@ -138,14 +138,14 @@ describe("sendCrashReport", () => { test("sends Content-Type application/json and User-Agent headers", async () => { mockFetchResolvedValue({ data: { submitCrashReport: { success: true } } }); - await sendCrashReport(makeCrashReport(), "tailor-sdk/1.0.0"); + await sendCrashReport(makeCrashReport(), "tailor/1.0.0"); expect(globalThis.fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ "Content-Type": "application/json", - "User-Agent": "tailor-sdk/1.0.0", + "User-Agent": "tailor/1.0.0", }), }), ); diff --git a/packages/sdk/src/cli/crashreport/writer.test.ts b/packages/sdk/src/cli/crashreport/writer.test.ts index ae5915565a..e4a0783646 100644 --- a/packages/sdk/src/cli/crashreport/writer.test.ts +++ b/packages/sdk/src/cli/crashreport/writer.test.ts @@ -15,7 +15,7 @@ function makeCrashReport(overrides?: Partial): CrashReport { osRelease: "25.3.0", arch: "arm64", command: "apply", - argv: ["node", "tailor-sdk", "apply"], + argv: ["node", "tailor", "apply"], errorName: "TypeError", errorMessage: "Cannot read properties of undefined", stackTrace: @@ -45,12 +45,10 @@ describe("formatCrashReport", () => { test("serializes argv as JSON array", () => { const report = makeCrashReport({ - argv: ["node", "tailor-sdk", "apply", "--body", '{"a": "b c"}'], + argv: ["node", "tailor", "apply", "--body", '{"a": "b c"}'], }); const text = formatCrashReport(report); - expect(text).toContain( - 'Arguments: ["node","tailor-sdk","apply","--body","{\\"a\\": \\"b c\\"}"]', - ); + expect(text).toContain('Arguments: ["node","tailor","apply","--body","{\\"a\\": \\"b c\\"}"]'); }); test("handles empty stack trace", () => { diff --git a/packages/sdk/src/cli/docs.test.ts b/packages/sdk/src/cli/docs.test.ts index 11310dbe6f..a12e6ebcea 100644 --- a/packages/sdk/src/cli/docs.test.ts +++ b/packages/sdk/src/cli/docs.test.ts @@ -25,7 +25,7 @@ const templateFiles: [output: string, commands: string[]][] = [ ["application", ["init", "generate", "deploy", "remove", "show", "open", "api"]], ["tailordb", ["tailordb"]], ["query", ["query"]], - ["user", ["login", "logout", "user"]], + ["user", ["login", "logout", "auth", "user"]], ["organization", ["organization"]], ["workspace", ["workspace", "profile"]], ["auth", ["authconnection", "machineuser", "oauth2client"]], @@ -38,6 +38,7 @@ const templateFiles: [output: string, commands: string[]][] = [ ["setup", ["setup"]], ["upgrade", ["upgrade"]], ["skills", ["skills"]], + ["plugin", ["plugin"]], ["completion", ["completion"]], ]; @@ -65,6 +66,7 @@ describe("CLI Documentation", () => { command: mainCommand, templates, targetCommands, + // strip unknown keys globalArgs: z.object(commonArgs), formatter: mdFormatter, }); diff --git a/packages/sdk/src/cli/index.ts b/packages/sdk/src/cli/index.ts index 17282b302f..36c2261675 100644 --- a/packages/sdk/src/cli/index.ts +++ b/packages/sdk/src/cli/index.ts @@ -4,6 +4,7 @@ import { defineCommand, runMain } from "politty"; import { withCompletionCommand } from "politty/completion"; import { z } from "zod"; import { apiCommand } from "./commands/api"; +import { authCommand } from "./commands/auth"; import { authconnectionCommand } from "./commands/authconnection"; import { crashReportCommand } from "./commands/crashreport"; import { deployCommand } from "./commands/deploy"; @@ -17,6 +18,7 @@ import { machineuserCommand } from "./commands/machineuser"; import { oauth2clientCommand } from "./commands/oauth2client"; import { openCommand } from "./commands/open"; import { organizationCommand } from "./commands/organization"; +import { pluginCommand } from "./commands/plugin"; import { profileCommand } from "./commands/profile"; import { removeCommand } from "./commands/remove"; import { secretCommand } from "./commands/secret"; @@ -35,16 +37,10 @@ import { commonArgs, isVerbose } from "./shared/args"; import { isCLIError } from "./shared/errors"; import { logger } from "./shared/logger"; import { readPackageJson } from "./shared/package-json"; -import { isNativeTypeScriptRuntime } from "./shared/runtime"; +import { dispatchPlugin } from "./shared/plugin"; +import { registerTsHook } from "./shared/register-ts-hook"; -// Register tsx for TypeScript loading on Node.js. -// Bun and Deno handle TypeScript natively, so registration is skipped. -// tsx's own register() picks `module.registerHooks` on Node ≥ 24.11.1 / 25.1 / 26 -// (avoiding the DEP0205 deprecation) and falls back to `module.register` on older runtimes. -if (!isNativeTypeScriptRuntime()) { - const { register } = await import("tsx/esm/api"); - register(); -} +await registerTsHook(new URL("./ts-hook.mjs", import.meta.url)); // Runs before globalArgs effects load --env-file, so env file overrides for // TAILOR_CRASH_REPORTS_* are not available for early startup failures. @@ -53,15 +49,18 @@ if (!isNativeTypeScriptRuntime()) { initCrashReporting(); const packageJson = await readPackageJson(); -const cliName = Object.keys(packageJson.bin ?? {})[0] || "tailor-sdk"; +const cliName = Object.keys(packageJson.bin ?? {})[0] || "tailor"; export const mainCommand = withCompletionCommand( defineCommand({ name: cliName, description: packageJson.description || "Tailor CLI for managing Tailor Platform SDK applications", + notes: `CLI plugins (beta): an unknown subcommand is dispatched to an external plugin executable named \`${cliName}-\` (found on your PATH or in node_modules/.bin), similar to \`gh\` extensions. +Run \`${cliName} plugin list\` to see which plugins are installed and where they resolve from.`, subCommands: { api: apiCommand, + auth: authCommand, authconnection: authconnectionCommand, crashreport: crashReportCommand, deploy: deployCommand, @@ -75,6 +74,7 @@ export const mainCommand = withCompletionCommand( oauth2client: oauth2clientCommand, open: openCommand, organization: organizationCommand, + plugin: pluginCommand, profile: profileCommand, query: queryCommand, remove: removeCommand, @@ -94,8 +94,19 @@ export const mainCommand = withCompletionCommand( runMain(mainCommand, { version: packageJson.version, + // strip unknown keys globalArgs: z.object(commonArgs), displayErrors: false, + // CLI plugin dispatch: an unknown subcommand at any level execs the external + // `tailor--` binary, forwarding args and injecting context. + onUnknownSubcommand: ({ commandPath, name, args }) => + dispatchPlugin({ + commandPath, + name, + args, + cliName, + profile: process.env.TAILOR_PLATFORM_PROFILE, + }), cleanup: async ({ error }) => { if (error) { if (isCLIError(error)) { diff --git a/packages/sdk/src/cli/lib.ts b/packages/sdk/src/cli/lib.ts index d89be3b811..35ffb38f7e 100644 --- a/packages/sdk/src/cli/lib.ts +++ b/packages/sdk/src/cli/lib.ts @@ -1,14 +1,7 @@ // CLI API exports for programmatic usage -import { isNativeTypeScriptRuntime } from "./shared/runtime"; +import { registerTsHook } from "./shared/register-ts-hook"; -// Register tsx to handle TypeScript files when using CLI API programmatically. -// Bun and Deno handle TypeScript natively, so registration is skipped. -// tsx's own register() picks `module.registerHooks` on Node ≥ 24.11.1 / 25.1 / 26 -// (avoiding the DEP0205 deprecation) and falls back to `module.register` on older runtimes. -if (!isNativeTypeScriptRuntime()) { - const { register } = await import("tsx/esm/api"); - register(); -} +await registerTsHook(new URL("./ts-hook.mjs", import.meta.url)); export { deploy, deploy as apply } from "./commands/deploy/deploy"; export type { DeployOptions, DeployOptions as ApplyOptions } from "./commands/deploy/deploy"; @@ -17,24 +10,8 @@ export { generate } from "./commands/generate/service"; export type { GenerateOptions } from "./commands/generate/options"; export { loadConfig, type LoadedConfig } from "./shared/config-loader"; export { generateUserTypes } from "./shared/type-generator"; -export type { - CodeGenerator, - TailorDBGenerator, - ResolverGenerator, - ExecutorGenerator, - TailorDBResolverGenerator, - FullCodeGenerator, - TailorDBInput, - ResolverInput, - ExecutorInput, - FullInput, - AggregateArgs, - GeneratorResult, - DependencyKind, - PluginAttachment, - TypeSourceInfoEntry, -} from "./commands/generate/types"; -export type { TailorDBType } from "#/parser/service/tailordb/types"; +export type { GeneratorResult, PluginAttachment } from "#/plugin/types"; +export type { TailorDBType, TypeSourceInfoEntry } from "#/parser/service/tailordb/types"; export type { Resolver } from "#/types/resolver.generated"; export type { Executor } from "#/types/executor.generated"; @@ -91,6 +68,7 @@ export { type GetWorkflowOptions, type GetWorkflowTypedOptions, } from "./commands/workflow/get"; +export type { MachineUserName } from "@tailor-platform/sdk"; export { startWorkflow, type StartWorkflowOptions, diff --git a/packages/sdk/src/cli/options.test.ts b/packages/sdk/src/cli/options.test.ts index c0aef192e1..dce1736fec 100644 --- a/packages/sdk/src/cli/options.test.ts +++ b/packages/sdk/src/cli/options.test.ts @@ -1,5 +1,6 @@ import { extractFields, isLazyCommand } from "politty"; import { describe, expect, test, vi } from "vitest"; +import { BUILTIN_COMMAND_NAMES } from "./shared/builtin-commands"; import { mainCommand } from "./index"; import type { AnyCommand, ExtractedFields, SubCommandValue } from "politty"; @@ -114,4 +115,14 @@ describe("CLI options", () => { expect(checked).toBeGreaterThan(0); }); + + test("keeps BUILTIN_COMMAND_NAMES in sync with the registered subcommands", () => { + // `plugin list` uses BUILTIN_COMMAND_NAMES (a leaf module, to avoid an + // import cycle) to flag shadowed plugins. Exclude the wrapper-added + // `completion` command and any internal `__`-prefixed commands. + const registered = Object.keys(mainCommand.subCommands ?? {}).filter( + (name) => !name.startsWith("__") && name !== "completion", + ); + expect(new Set(registered)).toEqual(new Set(BUILTIN_COMMAND_NAMES)); + }); }); diff --git a/packages/sdk/src/cli/query/errors.ts b/packages/sdk/src/cli/query/errors.ts index 9152f2838d..916c9dedcb 100644 --- a/packages/sdk/src/cli/query/errors.ts +++ b/packages/sdk/src/cli/query/errors.ts @@ -27,7 +27,7 @@ export function mapQueryExecutionError(args: MapQueryExecutionErrorArgs): Error return CLIError({ code: "not_found", message: `Machine user '${args.machineUser ?? "unknown"}' was not found.`, - suggestion: "Run `tailor-sdk machineuser list` and use an existing name.", + suggestion: "Run `tailor machineuser list` and use an existing name.", }); } diff --git a/packages/sdk/src/cli/query/index.test.ts b/packages/sdk/src/cli/query/index.test.ts index 11e21572c1..85a2dbc9aa 100644 --- a/packages/sdk/src/cli/query/index.test.ts +++ b/packages/sdk/src/cli/query/index.test.ts @@ -281,7 +281,7 @@ describe("query", () => { expect(executeScript).toHaveBeenCalled(); const call = vi.mocked(executeScript).mock.calls[0]![0]!; - const arg = JSON.parse(call.arg ?? "{}"); + const arg = call.arg as unknown as { queries: string[] }; expect(arg.queries).toEqual(["SELECT 1; ", "SELECT 2"]); }); @@ -297,7 +297,7 @@ describe("query", () => { }); const call = vi.mocked(executeScript).mock.calls[0]![0]!; - const arg = JSON.parse(call.arg ?? "{}"); + const arg = call.arg as unknown as { queries: string[] }; expect(arg.queries).toHaveLength(1); }); @@ -327,11 +327,11 @@ describe("query", () => { expect(executeScript).toHaveBeenCalledWith( expect.objectContaining({ name: "query-gql.js", - arg: JSON.stringify({ + arg: { endpoint: "https://app.example.com/query", accessToken: "mu-token", query: "{ viewer { id } }", - }), + }, }), ); diff --git a/packages/sdk/src/cli/query/index.ts b/packages/sdk/src/cli/query/index.ts index 4b9e367d9f..bb5a788f2c 100644 --- a/packages/sdk/src/cli/query/index.ts +++ b/packages/sdk/src/cli/query/index.ts @@ -41,6 +41,7 @@ import type { Application } from "@tailor-platform/tailor-proto/application_reso export type { QueryEngine } from "./types"; const queryEngineSchema = z.enum(queryEngines); +// strip unknown keys const queryBaseOptionsSchema = z.object({ workspaceId: z.string().optional(), profile: z.string().optional(), @@ -150,7 +151,7 @@ async function loadOptions(options: QueryBaseOptions) { }); if (!machineUser) { throw new Error( - "Machine user is required. Specify --machine-user, set TAILOR_PLATFORM_MACHINE_USER_NAME, or set a profile default with 'tailor-sdk profile update --machine-user '.", + "Machine user is required. Specify --machine-user, set TAILOR_PLATFORM_MACHINE_USER_NAME, or set a profile default with 'tailor profile update --machine-user '.", ); } @@ -210,10 +211,10 @@ async function sqlQuery( workspaceId: args.workspaceId, name: `query-sql-${args.namespace}.js`, code: args.bundledCode, - arg: JSON.stringify({ + arg: { namespace: args.namespace, queries, - }), + }, invoker, }); @@ -251,11 +252,11 @@ async function gqlQuery( workspaceId: args.workspaceId, name: `query-gql.js`, code: args.bundledCode, - arg: JSON.stringify({ + arg: { endpoint: `${application.url}/query`, accessToken, query: args.query, - }), + }, invoker, }); @@ -746,7 +747,7 @@ export const queryCommand = defineAppCommand({ name: "query", description: "Run SQL/GraphQL query.", args: z - .object({ + .strictObject({ ...deploymentArgs, engine: arg(queryEngineSchema, { description: "Query engine (sql or gql)", @@ -764,7 +765,6 @@ export const queryCommand = defineAppCommand({ }), "machine-user": arg(z.string().optional(), { alias: "m", - hiddenAlias: "machineuser", description: "Machine user name for query execution. Falls back to the active profile's default machine user.", env: "TAILOR_PLATFORM_MACHINE_USER_NAME", @@ -798,8 +798,7 @@ export const queryCommand = defineAppCommand({ message: "Pass only one of --edit, -q/--query, or -f/--file.", }); } - }) - .strict(), + }), run: async (args) => { const mode = await resolveQueryCommandInput({ query: args.query, @@ -823,9 +822,7 @@ export const queryCommand = defineAppCommand({ if (mode.mode === "repl") { const newlineOnEnter = - args["newline-on-enter"] ?? - parseBoolean(process.env.TAILOR_PLATFORM_QUERY_NEWLINE_ON_ENTER) ?? - true; + args["newline-on-enter"] ?? parseBoolean(process.env.TAILOR_QUERY_NEWLINE_ON_ENTER) ?? true; await runRepl({ ...sharedOptions, json: args.json, diff --git a/packages/sdk/src/cli/query/type-field-order.test.ts b/packages/sdk/src/cli/query/type-field-order.test.ts new file mode 100644 index 0000000000..1367c31b3c --- /dev/null +++ b/packages/sdk/src/cli/query/type-field-order.test.ts @@ -0,0 +1,51 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import { afterEach, describe, expect, test } from "vitest"; +import { loadTypeFieldOrder } from "./type-field-order"; +import type { LoadedConfig } from "../shared/config-loader"; + +describe("loadTypeFieldOrder", () => { + let tmpDir: string | undefined; + + afterEach(() => { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + function writeTypeFile(name: string, source: string): string { + if (!tmpDir) { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(import.meta.dirname, ".type-order-"))); + } + const file = path.join(tmpDir, name); + fs.writeFileSync(file, source); + return file; + } + + test("loads field order from db.type builder outputs", async () => { + const typeFile = writeTypeFile( + "user.ts", + ` +import { db } from "@tailor-platform/sdk"; +export const user = db.type("User", { + firstName: db.string(), + lastName: db.string(), +}); +`, + ); + const config = { + path: "tailor.config.ts", + name: "sample-app", + db: { + main: { + files: [typeFile], + }, + }, + } as LoadedConfig; + + await expect(loadTypeFieldOrder(config, "main")).resolves.toEqual( + new Map([["User", ["id", "firstName", "lastName"]]]), + ); + }); +}); diff --git a/packages/sdk/src/cli/query/type-field-order.ts b/packages/sdk/src/cli/query/type-field-order.ts index f7209bcf9c..c3b16c49ed 100644 --- a/packages/sdk/src/cli/query/type-field-order.ts +++ b/packages/sdk/src/cli/query/type-field-order.ts @@ -1,5 +1,6 @@ import { pathToFileURL } from "node:url"; import { loadFilesWithIgnores } from "#/cli/services/file-loader"; +import { stripTailorDBTypeBuilderHelpers } from "#/parser/service/tailordb/builder-helpers"; import { TailorDBTypeSchema } from "#/parser/service/tailordb/index"; import type { LoadedConfig } from "#/cli/shared/config-loader"; @@ -30,7 +31,9 @@ export async function loadTypeFieldOrder( const module = await import(pathToFileURL(typeFile).href); for (const exportedValue of Object.values(module)) { - const result = TailorDBTypeSchema.safeParse(exportedValue); + const result = TailorDBTypeSchema.safeParse( + stripTailorDBTypeBuilderHelpers(exportedValue), + ); if (!result.success) { continue; } diff --git a/packages/sdk/src/cli/services/application.test.ts b/packages/sdk/src/cli/services/application.test.ts index ddb2df8882..59e235ad44 100644 --- a/packages/sdk/src/cli/services/application.test.ts +++ b/packages/sdk/src/cli/services/application.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "vitest"; import { defineConfig } from "#/configure/config/index"; import { defineAuth } from "#/configure/services/auth/index"; +import { defineIdp } from "#/configure/services/idp/index"; +import { defineStaticWebSite } from "#/configure/services/staticwebsite/index"; import { db } from "#/configure/services/tailordb/schema"; import { defineApplication } from "./application"; @@ -39,4 +41,42 @@ describe("defineAuth parse wiring", () => { expect(application.authService!.userProfile?.namespace).toBe("external-ns"); }); + + test("accepts defineIdp helper objects when parsing IdP services", () => { + const idp = defineIdp("my-idp", { + clients: ["default-client"], + }); + + const config = { + ...defineConfig({ + name: "testApp", + idp: [idp], + }), + path: "tailor.config.ts", + }; + + const application = defineApplication({ config }); + + expect(application.idpServices).toHaveLength(1); + expect(application.idpServices[0]?.name).toBe("my-idp"); + }); + + test("accepts defineStaticWebSite helper objects when parsing static websites", () => { + const website = defineStaticWebSite("my-site", { + description: "my website", + }); + + const config = { + ...defineConfig({ + name: "testApp", + staticWebsites: [website], + }), + path: "tailor.config.ts", + }; + + const application = defineApplication({ config }); + + expect(application.staticWebsiteServices).toHaveLength(1); + expect(application.staticWebsiteServices[0]?.name).toBe("my-site"); + }); }); diff --git a/packages/sdk/src/cli/services/application.ts b/packages/sdk/src/cli/services/application.ts index 9d32d4e51d..9e1ba5f5f6 100644 --- a/packages/sdk/src/cli/services/application.ts +++ b/packages/sdk/src/cli/services/application.ts @@ -19,6 +19,7 @@ import { createTailorDBService, type TailorDBService } from "#/cli/services/tail import { assertUniqueLocalTailorDBTypeNames } from "#/cli/services/tailordb/type-name-validation"; import { bundleWorkflowJobs, type BundleWorkflowJobsResult } from "#/cli/services/workflow/bundler"; import { createWorkflowService, type WorkflowService } from "#/cli/services/workflow/service"; +import { getApplicationAuthNamespace } from "#/cli/shared/auth-namespace"; import { resolveBundleLogLevel } from "#/cli/shared/bundle-log-level"; import { type LoadedConfig } from "#/cli/shared/config-loader"; import { getDistDir } from "#/cli/shared/dist-dir"; @@ -34,7 +35,7 @@ import { type WorkflowServiceConfig, } from "#/configure/config/types"; import { type AuthConfig } from "#/configure/services/auth/types"; -import { type IdPConfig } from "#/configure/services/idp/types"; +import { type IdPConfig, type IdPOwnConfig } from "#/configure/services/idp/types"; import { AIGatewaySchema } from "#/parser/service/aigateway/index"; import { AuthConfigSchema } from "#/parser/service/auth/index"; import { IdPSchema } from "#/parser/service/idp/index"; @@ -155,6 +156,13 @@ type DefineIdpResult = { subgraphs: Array<{ Type: string; Name: string }>; }; +function stripIdpProviderHelper(idpConfig: IdPOwnConfig): IdPOwnConfig { + const configWithProvider = idpConfig as IdPOwnConfig & { provider?: unknown }; + if (typeof configWithProvider.provider !== "function") return idpConfig; + const { provider: _provider, ...config } = configWithProvider; + return config as IdPOwnConfig; +} + function defineIdp(config: readonly IdPConfig[] | undefined): DefineIdpResult { const idpServices: IdP[] = []; const subgraphs: Array<{ Type: string; Name: string }> = []; @@ -171,7 +179,7 @@ function defineIdp(config: readonly IdPConfig[] | undefined): DefineIdpResult { } idpNames.add(name); if (!("external" in idpConfig)) { - const idp = IdPSchema.parse(idpConfig); + const idp = IdPSchema.parse(stripIdpProviderHelper(idpConfig)); idpServices.push(idp); } subgraphs.push({ Type: "idp", Name: name }); @@ -235,6 +243,13 @@ function defineHttpAdapterService( return createHttpAdapterService({ config }); } +function stripStaticWebsiteUrlHelper(config: StaticWebsiteInput): StaticWebsiteInput { + const configWithUrl = config as StaticWebsiteInput & { url?: unknown }; + if (configWithUrl.url !== `${config.name}:url`) return config; + const { url: _url, ...websiteConfig } = configWithUrl; + return websiteConfig; +} + function defineStaticWebsites( websites: readonly StaticWebsiteInput[] | undefined, ): StaticWebsite[] { @@ -242,7 +257,7 @@ function defineStaticWebsites( const websiteNames = new Set(); (websites ?? []).forEach((config) => { - const website = StaticWebsiteSchema.parse(config); + const website = StaticWebsiteSchema.parse(stripStaticWebsiteUrlHelper(config)); if (websiteNames.has(website.name)) { throw new Error(`Static website with name "${website.name}" already defined.`); } @@ -514,7 +529,7 @@ export async function loadApplication( // 7. Build trigger context for workflow/job trigger transformation const triggerContext = await buildTriggerContext( config.workflow, - authResult.authService?.config.name, + getApplicationAuthNamespace({ authService: authResult.authService, config }), ); // 8. Resolve bundle settings diff --git a/packages/sdk/src/cli/services/auth/bundler.test.ts b/packages/sdk/src/cli/services/auth/bundler.test.ts index b732040c40..02f8f0b159 100644 --- a/packages/sdk/src/cli/services/auth/bundler.test.ts +++ b/packages/sdk/src/cli/services/auth/bundler.test.ts @@ -64,12 +64,12 @@ export default { expect(code).toContain("env"); }); - test("inlines LOG_LEVEL references from config during bundling", async () => { + test("inlines TAILOR_APP_LOG_LEVEL references from config during bundling", async () => { const configFile = writeConfig(` const handler = async () => ({ ok: true }); export default { - logLevel: process.env.LOG_LEVEL ?? "DEBUG", + logLevel: process.env.TAILOR_APP_LOG_LEVEL ?? "DEBUG", auth: { hooks: { beforeLogin: { handler } } }, }; `); @@ -83,6 +83,6 @@ export default { const code = bundled.get("auth-hook--my-auth--before-login"); expect(code).toBeDefined(); - expect(code).not.toContain("process.env.LOG_LEVEL"); + expect(code).not.toContain("process.env.TAILOR_APP_LOG_LEVEL"); }); }); diff --git a/packages/sdk/src/cli/services/auth/bundler.ts b/packages/sdk/src/cli/services/auth/bundler.ts index e2f4c8addb..c636dedf2a 100644 --- a/packages/sdk/src/cli/services/auth/bundler.ts +++ b/packages/sdk/src/cli/services/auth/bundler.ts @@ -137,7 +137,7 @@ export async function bundleAuthHooks( plugins, transform: { define: { - "process.env.LOG_LEVEL": JSON.stringify(bundleLogLevel), + "process.env.TAILOR_APP_LOG_LEVEL": JSON.stringify(bundleLogLevel), }, }, treeshake: composeFunctionTreeshakeOptions([ diff --git a/packages/sdk/src/cli/services/executor/loader.test.ts b/packages/sdk/src/cli/services/executor/loader.test.ts new file mode 100644 index 0000000000..689e068eee --- /dev/null +++ b/packages/sdk/src/cli/services/executor/loader.test.ts @@ -0,0 +1,32 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import { describe, expect, test } from "vitest"; +import { tempCwd } from "#/cli/shared/test-helpers/temp-cwd"; +import { loadExecutor } from "./loader"; + +describe("loadExecutor", () => { + test("accepts executor trigger helper args", async () => { + using tmp = tempCwd("sdk-executor-loader-"); + const executorFile = path.join(tmp.dir, "executor.ts"); + fs.writeFileSync( + executorFile, + ` +import { createExecutor, scheduleTrigger } from "@tailor-platform/sdk"; + +export default createExecutor({ + name: "daily", + trigger: scheduleTrigger({ cron: "0 12 * * *" }), + operation: { + kind: "function", + body: async () => {}, + }, +}); +`, + ); + + const executor = await loadExecutor(executorFile); + + expect(executor).not.toBeNull(); + expect(executor?.trigger).not.toHaveProperty("__args"); + }); +}); diff --git a/packages/sdk/src/cli/services/executor/loader.ts b/packages/sdk/src/cli/services/executor/loader.ts index 2f2a8f30dd..a47351cfe3 100644 --- a/packages/sdk/src/cli/services/executor/loader.ts +++ b/packages/sdk/src/cli/services/executor/loader.ts @@ -1,7 +1,22 @@ import { pathToFileURL } from "node:url"; import { ExecutorSchema } from "#/parser/service/executor/index"; +import { isSdkBranded } from "#/utils/brand"; import type { Executor } from "#/types/executor.generated"; +export function stripExecutorTriggerArgs(executor: unknown): unknown { + if (!isSdkBranded(executor, "executor")) { + return executor; + } + + const trigger = (executor as { trigger?: unknown }).trigger; + if (trigger === null || typeof trigger !== "object" || !("__args" in trigger)) { + return executor; + } + + const { __args: _args, ...triggerConfig } = trigger as Record; + return { ...(executor as Record), trigger: triggerConfig }; +} + /** * Load and validate an executor definition from a file. * @param executorFilePath - Path to the executor file @@ -11,7 +26,7 @@ export async function loadExecutor(executorFilePath: string): Promise => { try { const executorModule = await import(pathToFileURL(executorFile).href); - const result = ExecutorSchema.safeParse(executorModule.default); + const result = ExecutorSchema.safeParse(stripExecutorTriggerArgs(executorModule.default)); if (result.success) { const relativePath = path.relative(process.cwd(), executorFile); logger.log( @@ -109,7 +110,7 @@ export function createExecutorService(params: CreateExecutorServiceParams): Exec const executor = await loadExecutorForFile(filePath); if (executor) { // Track as plugin executor (plugin ID is extracted from file path) - // File path format: .tailor-sdk/plugin/{executor-name}.ts + // File path format: .tailor/plugin/{executor-name}.ts pluginExecutors.push({ executor, pluginId: "plugin-generated", diff --git a/packages/sdk/src/cli/services/resolver/bundler.ts b/packages/sdk/src/cli/services/resolver/bundler.ts index 0eab379b87..4d269e9dd0 100644 --- a/packages/sdk/src/cli/services/resolver/bundler.ts +++ b/packages/sdk/src/cli/services/resolver/bundler.ts @@ -159,7 +159,7 @@ async function bundleSingleResolver( const result = t.object(_internalResolver.input).parse({ value: context.input, data: context.input, - user: context.user, + invoker, }); if (result.issues) { diff --git a/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.ts b/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.ts index a290991250..fd92484f1e 100644 --- a/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.ts +++ b/packages/sdk/src/cli/services/tailordb/hooks-validate-bundler.ts @@ -4,7 +4,7 @@ import { join, resolve } from "pathe"; import * as rolldown from "rolldown"; import { getDistDir } from "#/cli/shared/dist-dir"; import { platformBundleDefinePlugin } from "#/cli/shared/platform-bundle-plugin"; -import { stringifyFunction, tailorUserMap } from "#/parser/service/tailordb/field"; +import { stringifyFunction, tailorPrincipalMap } from "#/parser/service/tailordb/field"; import { setPrecompiledScriptExpr } from "#/parser/service/tailordb/hooks-validate-precompiled-expr"; import { assertDefined } from "#/utils/assert"; import { assertParsableExpression } from "#/utils/script-expr"; @@ -24,7 +24,7 @@ type ScriptFunction = (...args: unknown[]) => unknown; type ScriptTarget = { fn: ScriptFunction; - kind: "hooks" | "validate"; + kind: "hooks" | "validate" | "typeHook" | "typeValidate"; }; /** Binding found in the source file: either an import or a top-level declaration */ @@ -105,13 +105,8 @@ function collectScriptTargets(type: TailorDBTypeSchemaOutput): ScriptTarget[] { } for (const validateInput of metadata.validate ?? []) { - if (typeof validateInput === "function") { - const validateFn = toScriptFunction(validateInput); - if (validateFn) targets.push({ fn: validateFn, kind: "validate" }); - } else { - const validateFn = toScriptFunction(validateInput[0]); - if (validateFn) targets.push({ fn: validateFn, kind: "validate" }); - } + const validateFn = toScriptFunction(validateInput); + if (validateFn) targets.push({ fn: validateFn, kind: "validate" }); } if (field.type === "nested" && field.fields) { @@ -125,6 +120,20 @@ function collectScriptTargets(type: TailorDBTypeSchemaOutput): ScriptTarget[] { collectFieldTargets(field); } + if (type.metadata.typeHook) { + for (const op of ["create", "update"] as const) { + const fn = toScriptFunction(type.metadata.typeHook[op]); + if (fn) { + targets.push({ fn, kind: "typeHook" }); + } + } + } + + const typeValidateFn = toScriptFunction(type.metadata.typeValidate); + if (typeValidateFn) { + targets.push({ fn: typeValidateFn, kind: "typeValidate" }); + } + return targets; } @@ -386,13 +395,13 @@ export function resolveNeededBindings( }; } -function buildPrecompiledExpr(bundleCode: string): string { +function buildPrecompiledExpr(bundleCode: string, argsObject: string): string { return ( "(() => {\n" + " const module = { exports: {} };\n" + " const exports = module.exports;\n" + `${bundleCode}\n` + - ` return module.exports.main({ value: _value, data: _data, user: ${tailorUserMap} });\n` + + ` return module.exports.main(${argsObject});\n` + "})()" ); } @@ -403,6 +412,7 @@ function buildPrecompiledExpr(bundleCode: string): string { * @param declarations - Declaration statement texts. * @param fnSource - The function source code. * @param sourceFilePath - Path to the source file for resolving relative imports. + * @param multiArg - Whether the function accepts multiple arguments (spread via `...args`). * @returns Entry file content string. */ export function buildMinimalEntryFromResolved( @@ -410,6 +420,7 @@ export function buildMinimalEntryFromResolved( declarations: string[], fnSource: string, sourceFilePath: string, + multiArg = false, ): string { const sourceDir = resolve(sourceFilePath, "..").replace(/\\/g, "/"); @@ -424,14 +435,16 @@ export function buildMinimalEntryFromResolved( const lines = [ ...resolvedImports, ...declarations, - `export function main(input) { return (${fnSource})(input); }`, + multiArg + ? `export function main(...args) { return (${fnSource})(...args); }` + : `export function main(input) { return (${fnSource})(input); }`, ]; return lines.join("\n"); } async function bundleScriptTarget(args: { fn: ScriptFunction; - kind: "hooks" | "validate"; + kind: "hooks" | "validate" | "typeHook" | "typeValidate"; sourceFilePath: string; sourceBindings: Map; tempDir: string; @@ -441,10 +454,15 @@ async function bundleScriptTarget(args: { const { fn, kind, sourceFilePath, sourceBindings, tempDir, targetIndex, tsconfig } = args; const context = `${kind} in ${sourceFilePath}`; const fnSource = stringifyFunction(fn); - const inlineExpr = assertParsableExpression( - `(${fnSource})({ value: _value, data: _data, user: ${tailorUserMap} })`, - context, - ); + const argsObject = + kind === "hooks" + ? `{ value: _value, oldValue: _oldValue, invoker: ${tailorPrincipalMap}, now: _now }` + : kind === "validate" + ? `{ value: _value }` + : kind === "typeHook" + ? `{ input: _input, oldRecord: _oldRecord, invoker: ${tailorPrincipalMap}, now: _now }` + : `{ newRecord: _newRecord, oldRecord: _oldRecord, invoker: ${tailorPrincipalMap} }, __issues`; + const inlineExpr = assertParsableExpression(`(${fnSource})(${argsObject})`, context); // Check if the function has free variables that need bundling const freeVars = findUndefinedReferences(`const __fn = ${fnSource};`); @@ -467,6 +485,7 @@ async function bundleScriptTarget(args: { declarations, fnSource, sourceFilePath, + kind === "typeValidate", ); const entryPath = join(tempDir, `tailordb-script-${targetIndex}.entry.ts`); @@ -492,7 +511,7 @@ async function bundleScriptTarget(args: { } as rolldown.BuildOptions); const bundledCode = buildResult.output[0].code; - return assertParsableExpression(buildPrecompiledExpr(bundledCode), context); + return assertParsableExpression(buildPrecompiledExpr(bundledCode, argsObject), context); } /** diff --git a/packages/sdk/src/cli/services/tailordb/service.ts b/packages/sdk/src/cli/services/tailordb/service.ts index 69bc0dc2aa..fd7271de84 100644 --- a/packages/sdk/src/cli/services/tailordb/service.ts +++ b/packages/sdk/src/cli/services/tailordb/service.ts @@ -3,6 +3,7 @@ import * as path from "pathe"; import { resolveTSConfig } from "pkg-types"; import { loadFilesWithIgnores } from "#/cli/services/file-loader"; import { logger, styles } from "#/cli/shared/logger"; +import { stripTailorDBTypeBuilderHelpers } from "#/parser/service/tailordb/builder-helpers"; import { parseTypes, TailorDBTypeSchema } from "#/parser/service/tailordb/index"; import { findOmittedPermitRules } from "#/parser/service/tailordb/permission"; import { assertDefined } from "#/utils/assert"; @@ -176,7 +177,7 @@ export function createTailorDBService(params: CreateTailorDBServiceParams): Tail for (const exportName of Object.keys(module)) { const exportedValue = module[exportName]; - const result = TailorDBTypeSchema.safeParse(exportedValue); + const result = TailorDBTypeSchema.safeParse(stripTailorDBTypeBuilderHelpers(exportedValue)); if (!result.success) { if (isSdkBranded(exportedValue, "tailordb-type")) { throw result.error; diff --git a/packages/sdk/src/cli/services/workflow/ast-transformer.test.ts b/packages/sdk/src/cli/services/workflow/ast-transformer.test.ts index e2f15fc482..fbf90a1837 100644 --- a/packages/sdk/src/cli/services/workflow/ast-transformer.test.ts +++ b/packages/sdk/src/cli/services/workflow/ast-transformer.test.ts @@ -417,7 +417,7 @@ const mainJob = createWorkflowJob({ ); expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("fetch-data", { id: input.id }))()', + 'tailor.workflow.triggerJobFunction("fetch-data", { id: input.id })', ); // fetchData declaration is removed (const fetchData = ...) expect(result).not.toContain("const fetchData"); @@ -462,9 +462,7 @@ const mainJob = createWorkflowJob({ // mainJob body is preserved expect(result).toContain('result: "main"'); // trigger is transformed (job name appears in triggerJobFunction call) - expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("heavy-job", undefined))()', - ); + expect(result).toContain('tailor.workflow.triggerJobFunction("heavy-job", undefined)'); }); test("removes declarations of multiple other jobs", () => { @@ -511,12 +509,8 @@ const mainJob = createWorkflowJob({ // heavy code is removed (part of job1/job2 body) expect(result).not.toContain("heavy code"); // triggers are transformed (job names appear in triggerJobFunction calls) - expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("job-one", undefined))()', - ); - expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("job-two", undefined))()', - ); + expect(result).toContain('tailor.workflow.triggerJobFunction("job-one", undefined)'); + expect(result).toContain('tailor.workflow.triggerJobFunction("job-two", undefined)'); }); test("does not transform trigger calls inside fallback-removed job bodies", () => { @@ -743,7 +737,7 @@ describe("AST Transformer - transformFunctionTriggers", () => { const source = ` const workflowRunId = await orderWorkflow.trigger( { orderId: "123", customerId: "456" }, - { authInvoker: auth.invoker("admin") } + { invoker: "admin" } ); `; const workflowNameMap = new Map([["orderWorkflow", "order-processing"]]); @@ -753,13 +747,13 @@ const workflowRunId = await orderWorkflow.trigger( expect(result).toContain('tailor.workflow.triggerWorkflow("order-processing"'); expect(result).toContain('{ orderId: "123", customerId: "456" }'); - expect(result).toContain('{ authInvoker: auth.invoker("admin") }'); + expect(result).toContain('{ invoker: "admin" }'); }); - test("transforms workflow.trigger() with shorthand authInvoker", () => { + test("transforms workflow.trigger() with shorthand invoker", () => { const source = ` -const authInvoker = auth.invoker("admin"); -const result = await myWorkflow.trigger({ id: 1 }, { authInvoker }); +const invoker = "admin"; +const result = await myWorkflow.trigger({ id: 1 }, { invoker }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); @@ -767,7 +761,7 @@ const result = await myWorkflow.trigger({ id: 1 }, { authInvoker }); const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); expect(result).toContain('tailor.workflow.triggerWorkflow("my-workflow"'); - expect(result).toContain("{ authInvoker }"); + expect(result).toContain("{ invoker }"); }); test("transforms workflow.trigger() without options and omits the helper", () => { @@ -790,9 +784,9 @@ const result = await myWorkflow.trigger({ id: 1 }); expect(result).not.toContain("__tailor_normalizeTriggerOptions"); }); - test("wraps a string-literal authInvoker with the runtime normalizer when authNamespace is provided", () => { + test("wraps a string-literal invoker with the runtime normalizer when authNamespace is provided", () => { const source = ` -const result = await myWorkflow.trigger({ id: 1 }, { authInvoker: "kiosk" }); +const result = await myWorkflow.trigger({ id: 1 }, { invoker: "kiosk" }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); @@ -807,17 +801,17 @@ const result = await myWorkflow.trigger({ id: 1 }, { authInvoker: "kiosk" }); ); expect(result).toContain('tailor.workflow.triggerWorkflow("my-workflow"'); - expect(result).toContain('__tailor_normalizeTriggerOptions({ authInvoker: "kiosk" })'); + expect(result).toContain('__tailor_normalizeTriggerOptions({ invoker: "kiosk" })'); // Helper injected at the top of the file with the namespace baked in expect(result).toContain( - 'const __tailor_normalizeTriggerOptions = (o) => o && typeof o.authInvoker === "string" ? { ...o, authInvoker: { namespace: "my-auth", machineUserName: o.authInvoker } } : o;', + 'const __tailor_normalizeTriggerOptions = (o) => { if (!o) return o; const { invoker, ...rest } = o; return typeof invoker === "string" ? { ...rest, authInvoker: { namespace: "my-auth", machineUserName: invoker } } : typeof invoker === "object" ? { ...rest, authInvoker: invoker } : o; };', ); }); - test("wraps a variable-reference authInvoker with the runtime normalizer", () => { + test("wraps a variable-reference invoker with the runtime normalizer", () => { const source = ` -const invoker = "kiosk"; -const result = await myWorkflow.trigger({ id: 1 }, { authInvoker: invoker }); +const machineUser = "kiosk"; +const result = await myWorkflow.trigger({ id: 1 }, { invoker: machineUser }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); @@ -831,13 +825,13 @@ const result = await myWorkflow.trigger({ id: 1 }, { authInvoker: invoker }); "my-auth", ); - expect(result).toContain("__tailor_normalizeTriggerOptions({ authInvoker: invoker })"); + expect(result).toContain("__tailor_normalizeTriggerOptions({ invoker: machineUser })"); }); - test("wraps a shorthand authInvoker with the runtime normalizer", () => { + test("wraps a shorthand invoker with the runtime normalizer", () => { const source = ` -const authInvoker = "kiosk"; -const result = await myWorkflow.trigger({ id: 1 }, { authInvoker }); +const invoker = "kiosk"; +const result = await myWorkflow.trigger({ id: 1 }, { invoker }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); @@ -851,12 +845,12 @@ const result = await myWorkflow.trigger({ id: 1 }, { authInvoker }); "my-auth", ); - expect(result).toContain("__tailor_normalizeTriggerOptions({ authInvoker })"); + expect(result).toContain("__tailor_normalizeTriggerOptions({ invoker })"); }); test("wraps a variable options argument with the runtime normalizer", () => { const source = ` -const opts = { authInvoker: "kiosk" }; +const opts = { invoker: "kiosk" }; const result = await myWorkflow.trigger({ id: 1 }, opts); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); @@ -918,8 +912,8 @@ const result = await myWorkflow.trigger({ id: 1 }, {}); test("injects the normalizer helper only once per file even for multiple trigger calls", () => { const source = ` -await myWorkflow.trigger({ id: 1 }, { authInvoker: "kiosk" }); -await myWorkflow.trigger({ id: 2 }, { authInvoker: "batch" }); +await myWorkflow.trigger({ id: 1 }, { invoker: "kiosk" }); +await myWorkflow.trigger({ id: 2 }, { invoker: "batch" }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); @@ -939,7 +933,7 @@ await myWorkflow.trigger({ id: 2 }, { authInvoker: "batch" }); test("keeps options unchanged and omits the helper when authNamespace is not provided", () => { const source = ` -const result = await myWorkflow.trigger({ id: 1 }, { authInvoker: "kiosk" }); +const result = await myWorkflow.trigger({ id: 1 }, { invoker: "kiosk" }); `; const workflowNameMap = new Map([["myWorkflow", "my-workflow"]]); const jobNameMap = new Map(); @@ -947,12 +941,11 @@ const result = await myWorkflow.trigger({ id: 1 }, { authInvoker: "kiosk" }); const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); expect(result).toContain( - 'tailor.workflow.triggerWorkflow("my-workflow", { id: 1 }, { authInvoker: "kiosk" })', + 'tailor.workflow.triggerWorkflow("my-workflow", { id: 1 }, { invoker: "kiosk" })', ); expect(result).not.toContain("__tailor_normalizeTriggerOptions"); }); }); - describe("job trigger transformation", () => { test("transforms job.trigger() calls to tailor.workflow.triggerJobFunction()", () => { const source = ` @@ -1003,7 +996,7 @@ const event = button.trigger("click"); test("only transforms trigger calls for known workflows and jobs", () => { const source = ` // Known workflow - should be transformed -const wfResult = await orderWorkflow.trigger({ id: 1 }, { authInvoker: auth.invoker("admin") }); +const wfResult = await orderWorkflow.trigger({ id: 1 }, { invoker: "admin" }); // Known job - should be transformed const jobResult = await fetchData.trigger({ id: 2 }); @@ -1048,7 +1041,7 @@ async function processOrder(orderId: string) { // Then trigger a workflow for processing const workflowRunId = await orderWorkflow.trigger( { orderId, data }, - { authInvoker: auth.invoker("system") } + { invoker: "system" } ); return { data, workflowRunId }; @@ -1064,8 +1057,8 @@ async function processOrder(orderId: string) { }); }); - describe("async IIFE wrapping for job triggers", () => { - test("wraps job.trigger() in an async IIFE and preserves await", () => { + describe("direct job trigger transformation", () => { + test("replaces job.trigger() with triggerJobFunction() and preserves await", () => { const source = ` const customer = await fetchCustomer.trigger({ customerId: "123" }); console.log(customer); @@ -1076,11 +1069,11 @@ console.log(customer); const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); expect(result).toContain( - 'const customer = await (async () => tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" }))()', + 'const customer = await tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" })', ); }); - test("wraps multiple job.trigger() calls in an async IIFE", () => { + test("replaces multiple job.trigger() calls directly", () => { const source = ` const customer = await fetchCustomer.trigger({ customerId: "123" }); const notification = await sendNotification.trigger({ message: "Hello" }); @@ -1093,15 +1086,13 @@ const notification = await sendNotification.trigger({ message: "Hello" }); const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); - expect(result).toContain('(async () => tailor.workflow.triggerJobFunction("fetch-customer"'); - expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("send-notification"', - ); + expect(result).toContain('tailor.workflow.triggerJobFunction("fetch-customer"'); + expect(result).toContain('tailor.workflow.triggerJobFunction("send-notification"'); }); test("does not wrap workflow.trigger() calls (already async)", () => { const source = ` -const executionId = await orderWorkflow.trigger({ orderId: "123" }, { authInvoker }); +const executionId = await orderWorkflow.trigger({ orderId: "123" }, { invoker }); `; const workflowNameMap = new Map([["orderWorkflow", "order-processing"]]); const jobNameMap = new Map(); @@ -1112,7 +1103,7 @@ const executionId = await orderWorkflow.trigger({ orderId: "123" }, { authInvoke expect(result).not.toContain("(async () => tailor.workflow.triggerWorkflow"); }); - test("wraps job.trigger() without await so it still returns a Promise", () => { + test("replaces job.trigger() without await with the raw platform result", () => { const source = ` const customerPromise = fetchCustomer.trigger({ customerId: "123" }); `; @@ -1122,12 +1113,12 @@ const customerPromise = fetchCustomer.trigger({ customerId: "123" }); const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); expect(result).toContain( - 'const customerPromise = (async () => tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" }))()', + 'const customerPromise = tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" })', ); expect(result).not.toMatch(/\bawait\b/); }); - test("wraps job.trigger() inside Promise.all array elements", () => { + test("replaces job.trigger() inside Promise.all array elements", () => { const source = ` const [customer, notification] = await Promise.all([ fetchCustomer.trigger({ customerId: "123" }), @@ -1143,15 +1134,15 @@ const [customer, notification] = await Promise.all([ const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" }))()', + 'tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" })', ); expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("send-notification", { message: "Hello" }))()', + 'tailor.workflow.triggerJobFunction("send-notification", { message: "Hello" })', ); expect(result).toContain("await Promise.all(["); }); - test("wraps job.trigger() before .then() chains", () => { + test("replaces job.trigger() before .then() chains without preserving Promise wrapping", () => { const source = ` fetchCustomer.trigger({ customerId: "123" }).then((customer) => { console.log(customer); @@ -1163,7 +1154,7 @@ fetchCustomer.trigger({ customerId: "123" }).then((customer) => { const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" }))().then(', + 'tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" }).then(', ); }); @@ -1185,7 +1176,7 @@ await myWorkflow.trigger(fetchCustomer.trigger({ customerId: "123" })); ); }); - test("wraps job.trigger() nested inside an unknown .trigger() argument", () => { + test("replaces job.trigger() nested inside an unknown .trigger() argument", () => { const source = ` unknown.trigger(fetchCustomer.trigger({ customerId: "123" })); `; @@ -1195,7 +1186,7 @@ unknown.trigger(fetchCustomer.trigger({ customerId: "123" })); const result = transformFunctionTriggers(source, workflowNameMap, jobNameMap); expect(result).toContain( - '(async () => tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" }))()', + 'tailor.workflow.triggerJobFunction("fetch-customer", { customerId: "123" })', ); expect(result).toContain("unknown.trigger("); }); @@ -1224,7 +1215,7 @@ export const job = createWorkflowJob({ body: async () => { const result = await simpleWorkflow.trigger( { input: 0 }, - { authInvoker: "admin" } + { invoker: "admin" } ); return result; }, @@ -1243,8 +1234,8 @@ import simpleWorkflow from "./simple"; export const job = createWorkflowJob({ name: "my-job", body: async () => { - await simpleWorkflow.trigger({ input: 1 }, { authInvoker: "admin" }); - await simpleWorkflow.trigger({ input: 2 }, { authInvoker: "admin" }); + await simpleWorkflow.trigger({ input: 1 }, { invoker: "admin" }); + await simpleWorkflow.trigger({ input: 2 }, { invoker: "admin" }); }, }); `; @@ -1262,7 +1253,7 @@ console.log(simpleWorkflow); export const job = createWorkflowJob({ name: "my-job", body: async () => { - await simpleWorkflow.trigger({ input: 0 }, { authInvoker: "admin" }); + await simpleWorkflow.trigger({ input: 0 }, { invoker: "admin" }); }, }); `; @@ -1296,8 +1287,8 @@ import workflowB from "./workflow-b"; export const job = createWorkflowJob({ name: "my-job", body: async () => { - await workflowA.trigger({ input: 1 }, { authInvoker: "admin" }); - await workflowB.trigger({ input: 2 }, { authInvoker: "admin" }); + await workflowA.trigger({ input: 1 }, { invoker: "admin" }); + await workflowB.trigger({ input: 2 }, { invoker: "admin" }); }, }); `; @@ -1321,7 +1312,7 @@ export const job = createWorkflowJob({ name: "my-job", body: async () => { someHelper(); - await simpleWorkflow.trigger({ input: 0 }, { authInvoker: "admin" }); + await simpleWorkflow.trigger({ input: 0 }, { invoker: "admin" }); }, }); `; @@ -1340,7 +1331,7 @@ import simpleWorkflow from "./simple"; export const job = createWorkflowJob({ name: "my-job", body: async () => { - await simpleWorkflow.trigger({ input: 0 }, { authInvoker: "admin" }); + await simpleWorkflow.trigger({ input: 0 }, { invoker: "admin" }); function helper(simpleWorkflow) { console.log(simpleWorkflow); } @@ -1360,7 +1351,7 @@ import simpleWorkflow from "./simple"; export const job = createWorkflowJob({ name: "my-job", body: async () => { - await simpleWorkflow.trigger({ input: 0 }, { authInvoker: "admin" }); + await simpleWorkflow.trigger({ input: 0 }, { invoker: "admin" }); const config = { simpleWorkflow: "some-value" }; return config; }, @@ -1408,7 +1399,7 @@ export const job = createWorkflowJob({ body: async () => { const result = await simpleWorkflow.trigger( { input: 0 }, - { authInvoker: "admin" } + { invoker: "admin" } ); return result; }, diff --git a/packages/sdk/src/cli/services/workflow/bundler.test.ts b/packages/sdk/src/cli/services/workflow/bundler.test.ts index 9cd32aeafc..31184d8373 100644 --- a/packages/sdk/src/cli/services/workflow/bundler.test.ts +++ b/packages/sdk/src/cli/services/workflow/bundler.test.ts @@ -31,7 +31,7 @@ describe("bundleWorkflowJobs", () => { }); const buildBundleFixture = (options: BuildBundleFixtureOptions) => { - const { ext, importPath, triggerArgs = `{ input: 0 }, { authInvoker: "admin" }` } = options; + const { ext, importPath, triggerArgs = `{ input: 0 }, { invoker: "admin" }` } = options; // Use realpathSync to avoid macOS symlink mismatch (/var -> /private/var) tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "bundler-test-"))); @@ -123,7 +123,8 @@ export default createWorkflow({ // tree-shake every test-only symbol; otherwise an unsubstituted process.env.* // reaches the Platform Web runtime (no `process`) and crashes. for (const code of result.bundledCode.values()) { - expect(code).not.toContain("process.env.TAILOR_PLATFORM_BUNDLE"); + expect(code).not.toContain("process.env.__TAILOR_PLATFORM_BUNDLE"); + expect(code).not.toContain("async_hooks"); expect(code).not.toContain("job-registry"); expect(code).not.toContain("registerJob"); expect(code).not.toContain("platformSerialize"); diff --git a/packages/sdk/src/cli/services/workflow/service.test.ts b/packages/sdk/src/cli/services/workflow/service.test.ts new file mode 100644 index 0000000000..2e07611f69 --- /dev/null +++ b/packages/sdk/src/cli/services/workflow/service.test.ts @@ -0,0 +1,34 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import { describe, expect, test } from "vitest"; +import { tempCwd } from "#/cli/shared/test-helpers/temp-cwd"; +import { createWorkflowService } from "./service"; + +describe("createWorkflowService", () => { + test("does not strip runtime trigger from an unbranded default export", async () => { + using tmp = tempCwd("sdk-workflow-service-"); + const workflowFile = path.join(tmp.dir, "workflow.mjs"); + fs.writeFileSync( + workflowFile, + ` +export const mainJob = { + name: "main-job", + trigger: () => {}, + body: () => {}, +}; + +export default { + name: "looks-like-workflow", + mainJob, + trigger: () => {}, +}; +`, + ); + + const service = createWorkflowService({ config: { files: ["workflow.mjs"] } }); + + await service.loadWorkflows(); + + expect(service.workflows).toEqual({}); + }); +}); diff --git a/packages/sdk/src/cli/services/workflow/service.ts b/packages/sdk/src/cli/services/workflow/service.ts index 95371e7b79..be7bf11c03 100644 --- a/packages/sdk/src/cli/services/workflow/service.ts +++ b/packages/sdk/src/cli/services/workflow/service.ts @@ -13,6 +13,19 @@ export interface CollectedJob { sourceFile: string; } +function stripRuntimeTrigger(workflow: unknown): unknown { + if ( + !isSdkBranded(workflow, "workflow") || + workflow === null || + typeof workflow !== "object" || + !("trigger" in workflow) + ) { + return workflow; + } + const { trigger: _trigger, ...rest } = workflow as Record; + return rest; +} + interface WorkflowLoadResult { workflows: Record; workflowSources: Array<{ workflow: Workflow; sourceFile: string }>; @@ -177,7 +190,7 @@ async function loadFileContent(filePath: string): Promise<{ for (const [exportName, exportValue] of Object.entries(module)) { // Check if it's a workflow (default export) if (exportName === "default") { - const workflowResult = WorkflowSchema.safeParse(exportValue); + const workflowResult = WorkflowSchema.safeParse(stripRuntimeTrigger(exportValue)); if (workflowResult.success) { workflow = workflowResult.data; } else if (isSdkBranded(exportValue, ["workflow", "workflow-job"])) { diff --git a/packages/sdk/src/cli/services/workflow/source-transformer.ts b/packages/sdk/src/cli/services/workflow/source-transformer.ts index 13bbb051f3..be432223c0 100644 --- a/packages/sdk/src/cli/services/workflow/source-transformer.ts +++ b/packages/sdk/src/cli/services/workflow/source-transformer.ts @@ -222,7 +222,7 @@ export function transformWorkflowSource( const jobName = jobNameMap.get(call.identifierName); if (jobName) { - const transformedCall = `(async () => tailor.workflow.triggerJobFunction("${jobName}", ${call.argsText || "undefined"}))()`; + const transformedCall = `tailor.workflow.triggerJobFunction("${jobName}", ${call.argsText || "undefined"})`; replacements.push({ start: call.callRange.start, end: call.callRange.end, diff --git a/packages/sdk/src/cli/services/workflow/trigger-transformer.ts b/packages/sdk/src/cli/services/workflow/trigger-transformer.ts index 0eadb064f1..30e0742523 100644 --- a/packages/sdk/src/cli/services/workflow/trigger-transformer.ts +++ b/packages/sdk/src/cli/services/workflow/trigger-transformer.ts @@ -29,15 +29,16 @@ const NORMALIZER_IDENTIFIER = "__tailor_normalizeTriggerOptions"; /** * Build the source text of the injected normalizer helper. * - * Expands a plain-string `authInvoker` (machine user name) in the trigger - * options to the object form `{ namespace, machineUserName }`; any other - * options value passes through unchanged. The auth namespace is baked in at - * bundle time. + * Renames an `invoker` in the trigger options to the `authInvoker` form the + * platform RPC expects: a plain string (machine user name) becomes + * `{ namespace, machineUserName }`, while an object form passes through + * as-is. Any other options value is unchanged. The auth namespace is baked + * in at bundle time. * @param authNamespace - Auth service namespace to embed * @returns Source line defining the helper */ function buildNormalizerHelperSource(authNamespace: string): string { - return `const ${NORMALIZER_IDENTIFIER} = (o) => o && typeof o.authInvoker === "string" ? { ...o, authInvoker: { namespace: ${JSON.stringify(authNamespace)}, machineUserName: o.authInvoker } } : o;\n`; + return `const ${NORMALIZER_IDENTIFIER} = (o) => { if (!o) return o; const { invoker, ...rest } = o; return typeof invoker === "string" ? { ...rest, authInvoker: { namespace: ${JSON.stringify(authNamespace)}, machineUserName: invoker } } : typeof invoker === "object" ? { ...rest, authInvoker: invoker } : o; };\n`; } /** @@ -275,7 +276,7 @@ function detectExtendedTriggerCalls( * @param jobNameMap - Map from variable name to job name * @param workflowFileMap - Map from file path (without extension) to workflow name for default exports * @param currentFilePath - Path of the current file being transformed (for resolving relative imports) - * @param authNamespace - Auth service namespace used to expand string-literal `authInvoker` to object form + * @param authNamespace - Auth service namespace used to expand string-literal `invoker` to object form * @returns Transformed source code with trigger calls rewritten */ export function transformFunctionTriggers( @@ -350,7 +351,7 @@ export function transformFunctionTriggers( } const replacements: Replacement[] = []; - // Whether any workflow trigger authInvoker was wrapped with the runtime + // Whether any workflow trigger invoker was wrapped with the runtime // normalizer. Used to decide whether to inject the helper at the top. let needsNormalizerHelper = false; @@ -362,13 +363,12 @@ export function transformFunctionTriggers( // Workflow trigger - get workflow name from map const workflowName = localWorkflowNameMap.get(call.identifierName); if (workflowName) { - // Wrap the options with the runtime normalizer so a string-form - // authInvoker in any options shape (object literal, variable - // reference, spread) becomes the object form the platform RPC - // expects. The normalizer is injected once at the top of the file. - // When no auth service is configured we can't expand a string, so - // we pass through unchanged (platform will reject a string with a - // clear error). + // Wrap the options with the runtime normalizer so an `invoker` in any + // options shape (object literal, variable reference, spread) becomes + // the `authInvoker` object form the platform RPC expects. The + // normalizer is injected once at the top of the file. When no auth + // service is configured we can't expand a string, so we pass through + // unchanged (platform will reject a string with a clear error). let optionsPart = ""; if (call.optionsText !== undefined) { if (authNamespace) { @@ -393,7 +393,7 @@ export function transformFunctionTriggers( } else { const jobName = jobNameMap.get(call.identifierName); if (jobName) { - const transformedCall = `(async () => tailor.workflow.triggerJobFunction("${jobName}", ${call.argsText || "undefined"}))()`; + const transformedCall = `tailor.workflow.triggerJobFunction("${jobName}", ${call.argsText || "undefined"})`; replacements.push({ start: call.callRange.start, diff --git a/packages/sdk/src/cli/shared/args.ts b/packages/sdk/src/cli/shared/args.ts index 312928888d..7373885ca2 100644 --- a/packages/sdk/src/cli/shared/args.ts +++ b/packages/sdk/src/cli/shared/args.ts @@ -201,8 +201,8 @@ export const workspaceArgs = { export const configArg = { config: arg(z.string().default("tailor.config.ts"), { alias: "c", - description: "Path to SDK config file", - env: "TAILOR_PLATFORM_SDK_CONFIG_PATH", + description: "Path to Tailor config file", + env: "TAILOR_CONFIG_PATH", completion: { type: "file", extensions: ["ts"] }, }), } satisfies ArgsShape; diff --git a/packages/sdk/src/cli/shared/auth-namespace.test.ts b/packages/sdk/src/cli/shared/auth-namespace.test.ts new file mode 100644 index 0000000000..b7f7edc072 --- /dev/null +++ b/packages/sdk/src/cli/shared/auth-namespace.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; +import { getApplicationAuthNamespace } from "./auth-namespace"; + +describe("getApplicationAuthNamespace", () => { + test("uses the local auth service name first", () => { + expect( + getApplicationAuthNamespace({ + authService: { config: { name: "local-auth" } }, + config: { auth: { name: "external-auth", external: true } }, + }), + ).toBe("local-auth"); + }); + + test("uses external auth config name when no local auth service exists", () => { + expect( + getApplicationAuthNamespace({ + config: { auth: { name: "external-auth", external: true } }, + }), + ).toBe("external-auth"); + }); + + test("returns undefined when no auth is configured", () => { + expect(getApplicationAuthNamespace({ config: {} })).toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/cli/shared/auth-namespace.ts b/packages/sdk/src/cli/shared/auth-namespace.ts new file mode 100644 index 0000000000..fb60324172 --- /dev/null +++ b/packages/sdk/src/cli/shared/auth-namespace.ts @@ -0,0 +1,17 @@ +import type { AppConfig } from "#/configure/config/types"; + +type AuthNamespaceApplication = { + authService?: { config: { name: string } }; + config?: Pick; +}; + +/** + * Resolve the auth namespace configured for an application. + * @param application - Loaded application with local or external Auth config + * @returns Auth namespace, or undefined when no Auth config is present + */ +export function getApplicationAuthNamespace( + application: AuthNamespaceApplication, +): string | undefined { + return application.authService?.config.name ?? application.config?.auth?.name; +} diff --git a/packages/sdk/src/cli/shared/builtin-commands.ts b/packages/sdk/src/cli/shared/builtin-commands.ts new file mode 100644 index 0000000000..9b4c219ee4 --- /dev/null +++ b/packages/sdk/src/cli/shared/builtin-commands.ts @@ -0,0 +1,39 @@ +/** + * Top-level builtin command names. A plugin named the same as one of these is + * shadowed by the builtin and can never be dispatched. + * + * This list is the single source of truth used by `plugin list` to flag + * shadowed plugins without importing the (cyclic) command tree. It is kept in + * sync with `main-command.ts` by a drift test in `options.test.ts`. + */ +export const BUILTIN_COMMAND_NAMES = [ + "api", + "auth", + "authconnection", + "crashreport", + "deploy", + "executor", + "function", + "generate", + "init", + "login", + "logout", + "machineuser", + "oauth2client", + "open", + "organization", + "plugin", + "profile", + "query", + "remove", + "secret", + "setup", + "show", + "skills", + "staticwebsite", + "tailordb", + "upgrade", + "user", + "workflow", + "workspace", +] as const; diff --git a/packages/sdk/src/cli/shared/client.test.ts b/packages/sdk/src/cli/shared/client.test.ts index 47c477dc13..d8674db419 100644 --- a/packages/sdk/src/cli/shared/client.test.ts +++ b/packages/sdk/src/cli/shared/client.test.ts @@ -34,6 +34,20 @@ vi.mock("#/cli/crashreport/index", () => ({ reportCrash: vi.fn(), })); +describe("client environment configuration", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + test("uses TAILOR_PLATFORM_URL for the platform base URL", async () => { + vi.resetModules(); + vi.stubEnv("TAILOR_PLATFORM_URL", "https://api.staging.tailor.test"); + const client = await import("./client"); + expect(client.getPlatformBaseUrl()).toBe("https://api.staging.tailor.test"); + }); +}); + describe("createTransport", () => { afterEach(() => { vi.clearAllMocks(); diff --git a/packages/sdk/src/cli/shared/client.ts b/packages/sdk/src/cli/shared/client.ts index e4a84011af..8b4643438e 100644 --- a/packages/sdk/src/cli/shared/client.ts +++ b/packages/sdk/src/cli/shared/client.ts @@ -592,7 +592,9 @@ export async function fetchUserInfo(accessToken: string, config?: PlatformClient } const rawJson = await resp.json(); + // strip unknown keys const schema = z.object({ + sub: z.string(), email: z.string(), }); return schema.parse(rawJson); @@ -712,6 +714,7 @@ export async function fetchMachineUserToken(url: string, clientId: string, clien } const rawJson = await resp.json(); + // strip unknown keys const schema = z.object({ token_type: z.string(), access_token: z.string(), diff --git a/packages/sdk/src/cli/shared/config-loader.ts b/packages/sdk/src/cli/shared/config-loader.ts index bf70dd5687..1f71486cbf 100644 --- a/packages/sdk/src/cli/shared/config-loader.ts +++ b/packages/sdk/src/cli/shared/config-loader.ts @@ -2,14 +2,11 @@ import * as fs from "node:fs"; import { pathToFileURL } from "node:url"; import * as path from "pathe"; import { AppConfigSchema } from "#/parser/app-config/schema"; -import { CodeGeneratorSchema, BaseGeneratorConfigSchema } from "#/parser/generator-config/schema"; import { PluginConfigSchema } from "#/parser/plugin-config/index"; -import { builtinPlugins } from "#/plugin/builtin/registry"; import { loadConfigPath } from "./context"; import { installCliTailordbStub } from "./mock"; import type { AppConfig } from "#/configure/config/types"; import type { Plugin } from "#/plugin/types"; -import type { z } from "zod"; /** * Loaded configuration with resolved path @@ -21,21 +18,16 @@ export interface LoadConfigOptions { importNonce?: string; } -// Generator schema for custom CodeGenerator objects (builtin generators are handled as plugins) -const GeneratorConfigSchema = CodeGeneratorSchema.brand("CodeGenerator"); - -export type Generator = z.output; - /** - * Load Tailor configuration file and associated generators and plugins. + * Load Tailor configuration file and associated plugins. * @param configPath - Optional explicit config path * @param options - Optional module import behavior. - * @returns Loaded config, generators, plugins, and config path + * @returns Loaded config, plugins, and config path */ export async function loadConfig( configPath?: string, options: LoadConfigOptions = {}, -): Promise<{ config: LoadedConfig; generators: Generator[]; plugins: Plugin[] }> { +): Promise<{ config: LoadedConfig; plugins: Plugin[] }> { installCliTailordbStub(); const foundPath = loadConfigPath(configPath); if (!foundPath) { @@ -66,50 +58,11 @@ export async function loadConfig( throw new Error(`Invalid Tailor config in ${resolvedPath}:\n${issues}`); } - // Collect all generator exports (generators, generators2, etc.) - const allGenerators: Generator[] = []; // Collect all plugin exports (plugins, plugins2, etc.) const allPlugins: Plugin[] = []; for (const value of Object.values(configModule)) { if (Array.isArray(value)) { - // Try to parse as generators (converting builtin tuples to plugins) - const generatorParsed = value.reduce( - (acc, item) => { - if (!acc.success) return acc; - - // Check if this is a builtin generator tuple that should be converted to a plugin - const baseResult = BaseGeneratorConfigSchema.safeParse(item); - if (baseResult.success && Array.isArray(baseResult.data)) { - const [id, options] = baseResult.data as [string, Record]; - const pluginFactory = builtinPlugins.get(id); - if (pluginFactory) { - acc.convertedPlugins.push(pluginFactory(options)); - return acc; - } - } - - // Try to parse as a custom CodeGenerator object - const result = GeneratorConfigSchema.safeParse(item); - if (result.success) { - acc.items.push(result.data); - } else { - acc.success = false; - } - return acc; - }, - { success: true, items: [] as Generator[], convertedPlugins: [] as Plugin[] }, - ); - if ( - generatorParsed.success && - (generatorParsed.items.length > 0 || generatorParsed.convertedPlugins.length > 0) - ) { - allGenerators.push(...generatorParsed.items); - allPlugins.push(...generatorParsed.convertedPlugins); - continue; - } - - // Try to parse as plugins const pluginParsed = value.reduce( (acc, item) => { if (!acc.success) return acc; @@ -132,7 +85,6 @@ export async function loadConfig( return { config: { ...configModule.default, path: resolvedPath } as LoadedConfig, - generators: allGenerators, plugins: allPlugins, }; } diff --git a/packages/sdk/src/cli/shared/context.test.ts b/packages/sdk/src/cli/shared/context.test.ts index 6411ad5cb7..5fae4a8901 100644 --- a/packages/sdk/src/cli/shared/context.test.ts +++ b/packages/sdk/src/cli/shared/context.test.ts @@ -4,6 +4,7 @@ import { parseYAML } from "confbox"; import * as path from "pathe"; import { describe, expect, test, vi, beforeEach, afterEach, afterAll, beforeAll } from "vitest"; import { + fetchLatestToken, loadConsoleBaseUrl, loadAccessToken, loadConfigPath, @@ -16,11 +17,12 @@ import { } from "./context"; import { isCLIError } from "./errors"; import { logger } from "./logger"; -import { resetKeyringState } from "./token-store"; +import { isKeyringAvailable, resetKeyringState } from "./token-store"; import type * as ClientModule from "./client"; const xdgTempDir = vi.hoisted(() => `/tmp/tailor-xdg-${Date.now()}-${Math.random()}`); -const refreshTokenMock = vi.hoisted(() => vi.fn()); +const keyringPasswords = vi.hoisted(() => new Map()); +const keyringSetPasswordFailure = vi.hoisted(() => ({ error: undefined as Error | undefined })); vi.mock("xdg-basedir", () => ({ xdgConfig: xdgTempDir, @@ -28,21 +30,36 @@ vi.mock("xdg-basedir", () => ({ vi.mock("@napi-rs/keyring", () => ({ Entry: class { - setPassword() {} + private key: string; + constructor(service: string, account: string) { + this.key = `${service}:${account}`; + } + setPassword(password: string) { + if (keyringSetPasswordFailure.error) throw keyringSetPasswordFailure.error; + keyringPasswords.set(this.key, password); + } getPassword(): string | null { - return null; + return keyringPasswords.get(this.key) ?? null; + } + deletePassword() { + keyringPasswords.delete(this.key); } - deletePassword() {} }, })); +const clientMocks = vi.hoisted(() => ({ + fetchUserInfo: vi.fn(), + refreshToken: vi.fn(), +})); + vi.mock("./client", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - initOAuth2Client: vi.fn(() => ({ - refreshToken: refreshTokenMock, - })), + fetchUserInfo: clientMocks.fetchUserInfo, + initOAuth2Client: () => ({ + refreshToken: clientMocks.refreshToken, + }), }; }); @@ -55,49 +72,12 @@ function writeFuturePlatformConfig() { ); } -type PfProfile = { - user: string; - workspace_id: string; - readonly?: boolean; - machine_user?: string; - machine_user_override?: "allow" | "deny"; -}; - -type PfUserV2 = - | { storage: "keyring"; token_expires_at: string } - | { storage: "file"; access_token: string; refresh_token?: string; token_expires_at: string }; - -type PfConfig = { - version: 2; - min_sdk_version: `${number}.${number}.${number}`; - users: Record; - profiles: Record; - current_user: string | null; -}; - -function v2Config(overrides: Partial = {}): PfConfig { - return { - version: 2, - min_sdk_version: "1.29.0", - users: {}, - profiles: {}, - current_user: null, - ...overrides, - }; -} - -function fileUser(accessToken: string, tokenExpiresAt: string): PfUserV2 { - return { - access_token: accessToken, - refresh_token: "refresh", - token_expires_at: tokenExpiresAt, - storage: "file", - }; -} - -function profile(user: string, overrides: Partial = {}): PfProfile { - return { user, workspace_id: "12345678-1234-4abc-8def-123456789012", ...overrides }; -} +beforeEach(() => { + clientMocks.fetchUserInfo.mockReset(); + clientMocks.refreshToken.mockReset(); + keyringPasswords.clear(); + keyringSetPasswordFailure.error = undefined; +}); describe("loadConfigPath", () => { const originalEnv = process.env; @@ -106,7 +86,7 @@ describe("loadConfigPath", () => { beforeEach(() => { vi.resetModules(); process.env = { ...originalEnv }; - delete process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH; + delete process.env.TAILOR_CONFIG_PATH; tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "tailor-test-")); vi.spyOn(process, "cwd").mockReturnValue(tempDir); }); @@ -123,7 +103,7 @@ describe("loadConfigPath", () => { }); test("returns env config path when set", () => { - process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH = "/env/path/config.ts"; + process.env.TAILOR_CONFIG_PATH = "/env/path/config.ts"; const result = loadConfigPath(); expect(result).toBe("/env/path/config.ts"); }); @@ -136,11 +116,8 @@ describe("loadConfigPath", () => { expect(result).toBe(configPath); }); - test.each([ - ["parent", ["nested"]], - ["grandparent", ["nested", "deep"]], - ])("finds config in %s directory", (_label, segments) => { - const nestedDir = path.join(tempDir, ...segments); + test("finds config in parent directory", () => { + const nestedDir = path.join(tempDir, "nested"); fs.mkdirSync(nestedDir, { recursive: true }); const configPath = path.join(tempDir, "tailor.config.ts"); fs.writeFileSync(configPath, "export default {}"); @@ -150,6 +127,17 @@ describe("loadConfigPath", () => { expect(result).toBe(configPath); }); + test("finds config in grandparent directory", () => { + const deepNestedDir = path.join(tempDir, "nested", "deep"); + fs.mkdirSync(deepNestedDir, { recursive: true }); + const configPath = path.join(tempDir, "tailor.config.ts"); + fs.writeFileSync(configPath, "export default {}"); + + vi.spyOn(process, "cwd").mockReturnValue(deepNestedDir); + const result = loadConfigPath(); + expect(result).toBe(configPath); + }); + test("prefers config in closer directory", () => { const nestedDir = path.join(tempDir, "nested"); fs.mkdirSync(nestedDir, { recursive: true }); @@ -181,12 +169,18 @@ describe("loadWorkspaceId", () => { beforeEach(() => { vi.resetModules(); - refreshTokenMock.mockReset(); + clientMocks.refreshToken.mockReset(); resetKeyringState(); process.env = { ...originalEnv }; delete process.env.TAILOR_PLATFORM_WORKSPACE_ID; delete process.env.TAILOR_PLATFORM_PROFILE; - writePlatformConfig(v2Config()); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); }); afterEach(() => { @@ -237,9 +231,15 @@ describe("loadWorkspaceId", () => { test("env takes precedence over profile", async () => { process.env.TAILOR_PLATFORM_WORKSPACE_ID = validUUID; - writePlatformConfig( - v2Config({ profiles: { myprofile: profile("test", { workspace_id: otherUUID }) } }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + myprofile: { user: "test", workspace_id: otherUUID }, + }, + current_user: null, + }); const result = await loadWorkspaceId({ profile: "myprofile" }); expect(result).toBe(validUUID); }); @@ -257,23 +257,38 @@ describe("loadWorkspaceId", () => { describe("opts.profile", () => { test("returns workspaceId from profile when opts.profile provided", async () => { - writePlatformConfig( - v2Config({ profiles: { myprofile: profile("testuser", { workspace_id: validUUID }) } }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { myprofile: { user: "testuser", workspace_id: validUUID } }, + current_user: null, + }); const result = await loadWorkspaceId({ profile: "myprofile" }); expect(result).toBe(validUUID); }); test("throws error when profile not found", async () => { + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); await expect(loadWorkspaceId({ profile: "nonexistent" })).rejects.toThrow( 'Profile "nonexistent" not found', ); }); test("throws error when profile workspace_id is invalid UUID", async () => { - writePlatformConfig( - v2Config({ profiles: { badprofile: profile("testuser", { workspace_id: invalidUUID }) } }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { badprofile: { user: "testuser", workspace_id: invalidUUID } }, + current_user: null, + }); await expect(loadWorkspaceId({ profile: "badprofile" })).rejects.toThrow( 'Invalid value from profile "badprofile": must be a valid UUID', ); @@ -283,23 +298,29 @@ describe("loadWorkspaceId", () => { describe("env.TAILOR_PLATFORM_PROFILE", () => { test("returns workspaceId from env profile when set", async () => { process.env.TAILOR_PLATFORM_PROFILE = "envprofile"; - writePlatformConfig( - v2Config({ profiles: { envprofile: profile("testuser", { workspace_id: validUUID }) } }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { envprofile: { user: "testuser", workspace_id: validUUID } }, + current_user: null, + }); const result = await loadWorkspaceId(); expect(result).toBe(validUUID); }); test("opts.profile takes precedence over env profile", async () => { process.env.TAILOR_PLATFORM_PROFILE = "envprofile"; - writePlatformConfig( - v2Config({ - profiles: { - envprofile: profile("testuser", { workspace_id: otherUUID }), - optsprofile: profile("testuser", { workspace_id: validUUID }), - }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + envprofile: { user: "testuser", workspace_id: otherUUID }, + optsprofile: { user: "testuser", workspace_id: validUUID }, + }, + current_user: null, + }); const result = await loadWorkspaceId({ profile: "optsprofile" }); expect(result).toBe(validUUID); }); @@ -320,7 +341,13 @@ describe("loadMachineUserName", () => { resetKeyringState(); vi.stubEnv("TAILOR_PLATFORM_MACHINE_USER_NAME", undefined); vi.stubEnv("TAILOR_PLATFORM_PROFILE", undefined); - writePlatformConfig(v2Config()); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); }); afterEach(() => { @@ -346,33 +373,37 @@ describe("loadMachineUserName", () => { test("env takes precedence over profile default", async () => { vi.stubEnv("TAILOR_PLATFORM_MACHINE_USER_NAME", "env-bot"); - writePlatformConfig( - v2Config({ - profiles: { - myprofile: profile("u", { workspace_id: validUUID, machine_user: "profile-bot" }), - }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { myprofile: { user: "u", workspace_id: validUUID, machine_user: "profile-bot" } }, + current_user: null, + }); const result = await loadMachineUserName({ profile: "myprofile" }); expect(result).toBe("env-bot"); }); test("returns machine_user from profile when profile provided", async () => { - writePlatformConfig( - v2Config({ - profiles: { - myprofile: profile("u", { workspace_id: validUUID, machine_user: "profile-bot" }), - }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { myprofile: { user: "u", workspace_id: validUUID, machine_user: "profile-bot" } }, + current_user: null, + }); const result = await loadMachineUserName({ profile: "myprofile" }); expect(result).toBe("profile-bot"); }); test("returns undefined when profile has no machine_user", async () => { - writePlatformConfig( - v2Config({ profiles: { myprofile: profile("u", { workspace_id: validUUID }) } }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { myprofile: { user: "u", workspace_id: validUUID } }, + current_user: null, + }); const result = await loadMachineUserName({ profile: "myprofile" }); expect(result).toBeUndefined(); }); @@ -390,30 +421,35 @@ describe("loadMachineUserName", () => { test("returns machine_user from env profile when TAILOR_PLATFORM_PROFILE is set", async () => { vi.stubEnv("TAILOR_PLATFORM_PROFILE", "envprofile"); - writePlatformConfig( - v2Config({ - profiles: { - envprofile: profile("u", { workspace_id: validUUID, machine_user: "env-profile-bot" }), - }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + envprofile: { user: "u", workspace_id: validUUID, machine_user: "env-profile-bot" }, + }, + current_user: null, + }); const result = await loadMachineUserName(); expect(result).toBe("env-profile-bot"); }); describe("machine_user_override: deny", () => { beforeEach(() => { - writePlatformConfig( - v2Config({ - profiles: { - locked: profile("u", { - workspace_id: validUUID, - machine_user: "profile-bot", - machine_user_override: "deny", - }), + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + locked: { + user: "u", + workspace_id: validUUID, + machine_user: "profile-bot", + machine_user_override: "deny", }, - }), - ); + }, + current_user: null, + }); }); test("rejects with PROFILE_MACHINE_USER_OVERRIDE_DENIED when opts.machineUser differs", async () => { @@ -450,13 +486,15 @@ describe("loadMachineUserName", () => { }); test("explicit value wins over profile when profile has machine_user but no override (regression guard)", async () => { - writePlatformConfig( - v2Config({ - profiles: { - myprofile: profile("u", { workspace_id: validUUID, machine_user: "profile-bot" }), - }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + myprofile: { user: "u", workspace_id: validUUID, machine_user: "profile-bot" }, + }, + current_user: null, + }); const result = await loadMachineUserName({ machineUser: "explicit-bot", profile: "myprofile" }); expect(result).toBe("explicit-bot"); }); @@ -474,13 +512,13 @@ describe("loadMachineUserName", () => { }); test("rejects empty opts.machineUser instead of falling back to profile default", async () => { - writePlatformConfig( - v2Config({ - profiles: { - myprofile: profile("u", { workspace_id: validUUID, machine_user: "profile-bot" }), - }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { myprofile: { user: "u", workspace_id: validUUID, machine_user: "profile-bot" } }, + current_user: null, + }); const err = await loadMachineUserName({ machineUser: "", profile: "myprofile" }).catch( (e: unknown) => e, ); @@ -493,6 +531,7 @@ describe("loadAccessToken", () => { const validToken = "valid-access-token"; const otherToken = "other-access-token"; const futureDate = new Date(Date.now() + 3600 * 1000).toISOString(); + const pastDate = new Date(Date.now() - 3600 * 1000).toISOString(); beforeEach(() => { vi.resetModules(); @@ -505,7 +544,13 @@ describe("loadAccessToken", () => { vi.stubEnv("TAILOR_PLATFORM_PROFILE", undefined); vi.stubEnv("TAILOR_PLATFORM_URL", undefined); vi.stubEnv("TAILOR_PLATFORM_OAUTH2_CLIENT_ID", undefined); - writePlatformConfig(v2Config()); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); }); describe("env.TAILOR_PLATFORM_TOKEN", () => { @@ -534,23 +579,32 @@ describe("loadAccessToken", () => { test("TAILOR_PLATFORM_TOKEN takes precedence over profile", async () => { vi.stubEnv("TAILOR_PLATFORM_TOKEN", validToken); - writePlatformConfig( - v2Config({ - users: { testuser: fileUser(otherToken, futureDate) }, - profiles: { myprofile: profile("testuser") }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + testuser: { + access_token: otherToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", + }, + }, + profiles: { + myprofile: { user: "testuser", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + }, + current_user: null, + }); const result = await loadAccessToken({ profile: "myprofile" }); expect(result).toBe(validToken); }); }); - describe("env.TAILOR_TOKEN (deprecated)", () => { - test("returns token from TAILOR_TOKEN when TAILOR_PLATFORM_TOKEN not set", async () => { + describe("env.TAILOR_TOKEN", () => { + test("uses the deprecated TAILOR_TOKEN fallback", async () => { vi.stubEnv("TAILOR_TOKEN", validToken); using warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); - const result = await loadAccessToken(); - expect(result).toBe(validToken); + await expect(loadAccessToken()).resolves.toBe(validToken); expect(warnSpy).toHaveBeenCalledWith( "TAILOR_TOKEN is deprecated. Please use TAILOR_PLATFORM_TOKEN instead.", ); @@ -559,33 +613,62 @@ describe("loadAccessToken", () => { describe("opts.profile", () => { test("returns token from profile when profile provided", async () => { - writePlatformConfig( - v2Config({ - users: { testuser: fileUser(validToken, futureDate) }, - profiles: { myprofile: profile("testuser") }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + testuser: { + access_token: validToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", + }, + }, + profiles: { + myprofile: { user: "testuser", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + }, + current_user: null, + }); const result = await loadAccessToken({ profile: "myprofile" }); expect(result).toBe(validToken); }); test("throws error when profile not found", async () => { + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); await expect(loadAccessToken({ profile: "nonexistent" })).rejects.toThrow( 'Profile "nonexistent" not found', ); }); test("prefers the profile user over current_user", async () => { - writePlatformConfig( - v2Config({ - users: { - currentuser: fileUser(validToken, futureDate), - profileuser: fileUser(otherToken, futureDate), + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + currentuser: { + access_token: validToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", }, - profiles: { myprofile: profile("profileuser") }, - current_user: "currentuser", - }), - ); + profileuser: { + access_token: otherToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", + }, + }, + profiles: { + myprofile: { user: "profileuser", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + }, + current_user: "currentuser", + }); const result = await loadAccessToken({ profile: "myprofile" }); expect(result).toBe(otherToken); }); @@ -613,7 +696,7 @@ describe("loadAccessToken", () => { refreshToken: "refresh", }, futureDate, - { platformUrl: "https://api.dev.tailor.tech" }, + { platformConfig: { platformUrl: "https://api.dev.tailor.tech" } }, ); writePlatformConfig(config); @@ -683,7 +766,7 @@ describe("loadAccessToken", () => { vi.stubEnv("TAILOR_PLATFORM_URL", "https://api.dev.tailor.tech"); const pastDate = new Date(Date.now() - 3600 * 1000).toISOString(); const refreshedExpiresAt = Date.now() + 3600 * 1000; - refreshTokenMock.mockResolvedValueOnce({ + clientMocks.refreshToken.mockResolvedValueOnce({ accessToken: "refreshed-token", refreshToken: "refreshed-refresh", expiresAt: refreshedExpiresAt, @@ -709,41 +792,63 @@ describe("loadAccessToken", () => { const updatedConfig = await readPlatformConfig(); expect(updatedConfig.users.testuser).toBeUndefined(); expect(updatedConfig.users["https://api.dev.tailor.tech|testuser"]).toMatchObject({ - storage: "file", - access_token: "refreshed-token", - refresh_token: "refreshed-refresh", + storage: "keyring", token_expires_at: new Date(refreshedExpiresAt).toISOString(), }); + expect(keyringPasswords.get("tailor-platform-cli:https://api.dev.tailor.tech|testuser")).toBe( + JSON.stringify({ accessToken: "refreshed-token", refreshToken: "refreshed-refresh" }), + ); }); }); describe("env.TAILOR_PLATFORM_PROFILE", () => { test("returns token from env profile", async () => { vi.stubEnv("TAILOR_PLATFORM_PROFILE", "envprofile"); - writePlatformConfig( - v2Config({ - users: { testuser: fileUser(validToken, futureDate) }, - profiles: { envprofile: profile("testuser") }, - }), - ); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + testuser: { + access_token: validToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", + }, + }, + profiles: { + envprofile: { user: "testuser", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + }, + current_user: null, + }); const result = await loadAccessToken(); expect(result).toBe(validToken); }); test("opts.profile takes precedence over env profile", async () => { vi.stubEnv("TAILOR_PLATFORM_PROFILE", "envprofile"); - writePlatformConfig( - v2Config({ - users: { - envuser: fileUser(otherToken, futureDate), - optsuser: fileUser(validToken, futureDate), + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + envuser: { + access_token: otherToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", }, - profiles: { - envprofile: profile("envuser"), - optsprofile: profile("optsuser"), + optsuser: { + access_token: validToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", }, - }), - ); + }, + profiles: { + envprofile: { user: "envuser", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + optsprofile: { user: "optsuser", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + }, + current_user: null, + }); const result = await loadAccessToken({ profile: "optsprofile" }); expect(result).toBe(validToken); }); @@ -751,15 +856,175 @@ describe("loadAccessToken", () => { describe("config.current_user", () => { test("returns token from current_user when no env or profile", async () => { - writePlatformConfig( - v2Config({ - users: { currentuser: fileUser(validToken, futureDate) }, - current_user: "currentuser", - }), - ); - const result = await loadAccessToken(); - expect(result).toBe(validToken); - }); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + currentuser: { + access_token: validToken, + refresh_token: "refresh", + token_expires_at: futureDate, + storage: "file", + }, + }, + profiles: {}, + current_user: "currentuser", + }); + const result = await loadAccessToken(); + expect(result).toBe(validToken); + }); + + test("fetchLatestToken resolves a subject-keyed user by email metadata", async () => { + writePlatformConfig({ + version: 3, + min_sdk_version: "2.0.0", + users: { + "platform-user-sub": { + storage: "file", + access_token: validToken, + refresh_token: "refresh", + token_expires_at: futureDate, + email: "user@example.com", + }, + }, + profiles: {}, + current_user: "platform-user-sub", + }); + + const config = await readPlatformConfig(); + await expect(fetchLatestToken(config, "user@example.com")).resolves.toEqual({ + accessToken: validToken, + user: "platform-user-sub", + }); + }); + + test("refreshes a legacy email-key user into a subject-key V3 config", async () => { + clientMocks.refreshToken.mockResolvedValue({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + expiresAt: Date.now() + 3600 * 1000, + }); + clientMocks.fetchUserInfo.mockResolvedValue({ + sub: "platform-user-sub", + email: "legacy@example.com", + }); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "legacy@example.com": { + access_token: "expired-access-token", + refresh_token: "refresh", + token_expires_at: pastDate, + storage: "file", + }, + }, + profiles: { + default: { + user: "legacy@example.com", + workspace_id: "12345678-1234-4abc-8def-123456789012", + }, + }, + current_user: "legacy@example.com", + }); + + const token = await loadAccessToken(); + const config = await readPlatformConfig(); + + expect(token).toBe("new-access-token"); + expect(clientMocks.fetchUserInfo).toHaveBeenCalledWith("new-access-token", undefined); + expect(config.version).toBe(3); + expect(config.users["legacy@example.com"]).toBeUndefined(); + expect(config.users["platform-user-sub"]).toMatchObject({ + storage: "keyring", + email: "legacy@example.com", + }); + expect(keyringPasswords.get("tailor-platform-cli:platform-user-sub")).toBe( + JSON.stringify({ accessToken: "new-access-token", refreshToken: "new-refresh-token" }), + ); + expect(config.current_user).toBe("platform-user-sub"); + expect(config.profiles.default?.user).toBe("platform-user-sub"); + }); + + test("logs when refresh updates the stored user email", async () => { + clientMocks.refreshToken.mockResolvedValue({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + expiresAt: Date.now() + 3600 * 1000, + }); + clientMocks.fetchUserInfo.mockResolvedValue({ + sub: "platform-user-sub", + email: "new@example.com", + }); + writePlatformConfig({ + version: 3, + min_sdk_version: "2.0.0", + users: { + "platform-user-sub": { + access_token: "expired-access-token", + refresh_token: "refresh", + token_expires_at: pastDate, + storage: "file", + email: "old@example.com", + }, + }, + profiles: {}, + current_user: "platform-user-sub", + }); + + const config = await readPlatformConfig(); + using infoSpy = vi.spyOn(logger, "info").mockImplementation(() => {}); + + await fetchLatestToken(config, "platform-user-sub"); + + expect(infoSpy).toHaveBeenCalledWith( + 'Updated local user email from "old@example.com" to "new@example.com".', + ); + expect(config.users["platform-user-sub"]?.email).toBe("new@example.com"); + }); + + test("keeps the legacy email key when subject resolution fails on refresh", async () => { + clientMocks.refreshToken.mockResolvedValue({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + expiresAt: Date.now() + 3600 * 1000, + }); + clientMocks.fetchUserInfo.mockRejectedValue(new Error("network down")); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "legacy@example.com": { + access_token: "expired-access-token", + refresh_token: "refresh", + token_expires_at: pastDate, + storage: "file", + }, + }, + profiles: { + default: { + user: "legacy@example.com", + workspace_id: "12345678-1234-4abc-8def-123456789012", + }, + }, + current_user: "legacy@example.com", + }); + + const token = await loadAccessToken(); + const config = await readPlatformConfig(); + + expect(token).toBe("new-access-token"); + expect(clientMocks.fetchUserInfo).toHaveBeenCalledWith("new-access-token", undefined); + expect(config.users["platform-user-sub"]).toBeUndefined(); + expect(config.users["legacy@example.com"]).toMatchObject({ + storage: "keyring", + }); + expect(keyringPasswords.get("tailor-platform-cli:legacy@example.com")).toBe( + JSON.stringify({ accessToken: "new-access-token", refreshToken: "new-refresh-token" }), + ); + expect(config.current_user).toBe("legacy@example.com"); + expect(config.profiles.default?.user).toBe("legacy@example.com"); + }); }); describe("error case: no token source", () => { @@ -845,14 +1110,20 @@ describe("profile readonly field", () => { }); test("round-trips readonly: true through write/read", async () => { - writePlatformConfig( - v2Config({ - profiles: { - ro: profile("u@example.com", { readonly: true }), - rw: profile("u@example.com"), + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + ro: { + user: "u@example.com", + workspace_id: "12345678-1234-4abc-8def-123456789012", + readonly: true, }, - }), - ); + rw: { user: "u@example.com", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + }, + current_user: null, + }); const { readPlatformConfig } = await import("./context"); const config = await readPlatformConfig(); expect(config.profiles.ro?.readonly).toBe(true); @@ -860,14 +1131,195 @@ describe("profile readonly field", () => { }); }); -describe("V1 to V2 migration", () => { +describe("initial platform config", () => { + const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); + const legacyHomeDir = path.join(xdgTempDir, "legacy-home"); + const legacyConfigPath = path.join(legacyHomeDir, ".tailorctl", "config"); + + beforeEach(() => { + vi.resetModules(); + resetKeyringState(); + vi.stubEnv("HOME", legacyHomeDir); + fs.rmSync(configPath, { force: true }); + fs.rmSync(legacyHomeDir, { recursive: true, force: true }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test("creates an empty latest-version config when the platform config is missing", async () => { + const config = await readPlatformConfig(); + + expect(config).toEqual({ + version: 3, + min_sdk_version: "2.0.0", + users: {}, + profiles: {}, + current_user: null, + }); + expect(parseYAML(fs.readFileSync(configPath, "utf-8"))).toEqual(config); + }); + + test("ignores legacy tailorctl config when the platform config is missing", async () => { + fs.mkdirSync(path.dirname(legacyConfigPath), { recursive: true }); + fs.writeFileSync( + legacyConfigPath, + [ + "[global]", + 'context = "default"', + "", + "[default]", + 'username = "user@example.com"', + 'workspaceid = "12345678-1234-4abc-8def-123456789012"', + 'controlplaneaccesstoken = "legacy-access-token"', + 'controlplanerefreshtoken = "legacy-refresh-token"', + 'controlplanetokenexpiresat = "2099-01-01T00:00:00.000Z"', + "", + ].join("\n"), + ); + + const config = await readPlatformConfig(); + + expect(config.users).toEqual({}); + expect(config.profiles).toEqual({}); + expect(config.current_user).toBeNull(); + }); +}); + +describe("saveUserTokens", () => { + const futureDate = new Date(Date.now() + 3600 * 1000).toISOString(); + const originalEnv = process.env; + type PlatformConfig = Parameters[0]; + + function createEmptyConfig(): PlatformConfig { + return { + version: 3, + min_sdk_version: "2.0.0", + users: {}, + profiles: {}, + current_user: null, + }; + } + + beforeEach(() => { + vi.resetModules(); + resetKeyringState(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + test("stores tokens in the OS keyring by default when available", async () => { + const config = createEmptyConfig(); + + await saveUserTokens( + config, + "platform-user-sub", + { accessToken: "access-token", refreshToken: "refresh-token" }, + futureDate, + { email: "user@example.com" }, + ); + + expect(config.users["platform-user-sub"]).toEqual({ + storage: "keyring", + token_expires_at: futureDate, + email: "user@example.com", + }); + expect(keyringPasswords.get("tailor-platform-cli:platform-user-sub")).toBe( + JSON.stringify({ accessToken: "access-token", refreshToken: "refresh-token" }), + ); + }); + + test.each(["0", "false", "off"])( + "ignores TAILOR_USE_KEYRING=%s and stores tokens in the OS keyring", + async (value) => { + process.env.TAILOR_USE_KEYRING = value; + const config = createEmptyConfig(); + + await saveUserTokens( + config, + "platform-user-sub", + { accessToken: "access-token", refreshToken: "refresh-token" }, + futureDate, + ); + + expect(config.users["platform-user-sub"]).toEqual({ + storage: "keyring", + token_expires_at: futureDate, + }); + expect(keyringPasswords.get("tailor-platform-cli:platform-user-sub")).toBe( + JSON.stringify({ accessToken: "access-token", refreshToken: "refresh-token" }), + ); + }, + ); + + test("falls back to the config file when keyring storage fails", async () => { + const config = createEmptyConfig(); + + expect(await isKeyringAvailable()).toBe(true); + keyringSetPasswordFailure.error = new Error("keyring denied"); + using warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + + await saveUserTokens( + config, + "platform-user-sub", + { accessToken: "access-token", refreshToken: "refresh-token" }, + futureDate, + ); + + expect(config.users["platform-user-sub"]).toEqual({ + storage: "file", + access_token: "access-token", + refresh_token: "refresh-token", + token_expires_at: futureDate, + }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("keyring denied")); + }); + + test("deletes stale keyring tokens when falling back to the config file", async () => { + const config = createEmptyConfig(); + config.users["platform-user-sub"] = { + storage: "keyring", + token_expires_at: futureDate, + }; + keyringPasswords.set( + "tailor-platform-cli:platform-user-sub", + JSON.stringify({ accessToken: "stale-access-token", refreshToken: "stale-refresh-token" }), + ); + + expect(await isKeyringAvailable()).toBe(true); + keyringSetPasswordFailure.error = new Error("keyring denied"); + using warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + + await saveUserTokens( + config, + "platform-user-sub", + { accessToken: "access-token", refreshToken: "refresh-token" }, + futureDate, + ); + + expect(config.users["platform-user-sub"]).toEqual({ + storage: "file", + access_token: "access-token", + refresh_token: "refresh-token", + token_expires_at: futureDate, + }); + expect(keyringPasswords.has("tailor-platform-cli:platform-user-sub")).toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("keyring denied")); + }); +}); + +describe("V1 to V3 migration", () => { const futureDate = new Date(Date.now() + 3600 * 1000).toISOString(); beforeEach(() => { resetKeyringState(); }); - test("migrates V1 config to V2 in memory without rewriting disk", async () => { + test("migrates V1 config to V3 in memory without rewriting disk", async () => { const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); writePlatformConfig({ version: 1, @@ -879,7 +1331,7 @@ describe("V1 to V2 migration", () => { }, }, profiles: { - default: profile("user@example.com"), + default: { user: "user@example.com", workspace_id: "12345678-1234-4abc-8def-123456789012" }, }, current_user: "user@example.com", }); @@ -888,11 +1340,12 @@ describe("V1 to V2 migration", () => { const { readPlatformConfig } = await import("./context"); const config = await readPlatformConfig(); - // In-memory: V2 with storage: "file" - expect(config.version).toBe(2); + // In-memory: V3 with storage: "file" and inferred legacy email metadata. + expect(config.version).toBe(3); const userEntry = config.users["user@example.com"]; expect(userEntry).toBeDefined(); expect(userEntry!.storage).toBe("file"); + expect(userEntry!.email).toBe("user@example.com"); expect(userEntry!.token_expires_at).toBe(futureDate); if (userEntry!.storage !== "file") { throw new Error("Expected file-backed user entry"); @@ -900,7 +1353,7 @@ describe("V1 to V2 migration", () => { expect(userEntry!.access_token).toBe("v1-access-token"); expect(userEntry!.refresh_token).toBe("v1-refresh-token"); - // Disk: still V1 (not rewritten to V2) + // Disk: still V1 (not rewritten to V3) const diskConfig = parseYAML(fs.readFileSync(configPath, "utf-8")) as { version: number }; expect(diskConfig.version).toBe(1); }); @@ -915,30 +1368,28 @@ describe("keyring user persistence on V2 -> V1 downgrade", () => { vi.resetModules(); resetKeyringState(); process.env = { ...originalEnv }; - // Downgrade only happens when TAILOR_USE_KEYRING is unset, which is the - // default for every command that is not opting into keyring storage. - delete process.env.TAILOR_USE_KEYRING; }); afterEach(() => { process.env = originalEnv; }); - test("keeps the config V2 and preserves the keyring user when written without TAILOR_USE_KEYRING", async () => { - writePlatformConfig( - v2Config({ - users: { - "keyring@example.com": { storage: "keyring", token_expires_at: futureDate }, - "file@example.com": { - storage: "file", - access_token: "file-access-token", - refresh_token: "file-refresh-token", - token_expires_at: futureDate, - }, + test("keeps the config V2 and preserves the keyring user", async () => { + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "keyring@example.com": { storage: "keyring", token_expires_at: futureDate }, + "file@example.com": { + storage: "file", + access_token: "file-access-token", + refresh_token: "file-refresh-token", + token_expires_at: futureDate, }, - current_user: "keyring@example.com", - }), - ); + }, + profiles: {}, + current_user: "keyring@example.com", + }); // Disk: stays V2 so the keyring entry is not dropped. const diskConfig = parseYAML(fs.readFileSync(configPath, "utf-8")) as { @@ -951,27 +1402,66 @@ describe("keyring user persistence on V2 -> V1 downgrade", () => { expect(diskConfig.users["file@example.com"]?.storage).toBe("file"); expect(diskConfig.current_user).toBe("keyring@example.com"); - // Round trip: the keyring user (and current_user) survive a re-read. + // Round trip: the keyring user (and current_user) survive a re-read and + // are exposed through the latest in-memory config version. const { readPlatformConfig } = await import("./context"); const config = await readPlatformConfig(); - expect(config.version).toBe(2); + expect(config.version).toBe(3); expect(config.users["keyring@example.com"]?.storage).toBe("keyring"); + expect(config.users["keyring@example.com"]?.email).toBe("keyring@example.com"); expect(config.current_user).toBe("keyring@example.com"); }); - test("still downgrades a file-only config to V1 for backward compatibility", () => { - writePlatformConfig( - v2Config({ - users: { - "file@example.com": { - storage: "file", - access_token: "file-access-token", - token_expires_at: futureDate, - }, + test("keeps V3 configs as V3 because subject IDs and email metadata cannot downgrade", () => { + writePlatformConfig({ + version: 3, + min_sdk_version: "2.0.0", + users: { + "platform-user-sub": { + storage: "file", + access_token: "file-access-token", + refresh_token: "file-refresh-token", + token_expires_at: futureDate, + email: "user@example.com", }, - current_user: "file@example.com", - }), - ); + }, + profiles: { + default: { + user: "platform-user-sub", + workspace_id: "12345678-1234-4abc-8def-123456789012", + }, + }, + current_user: "platform-user-sub", + }); + + const diskConfig = parseYAML(fs.readFileSync(configPath, "utf-8")) as { + version: number; + users: Record; + profiles: Record; + current_user: string | null; + }; + expect(diskConfig.version).toBe(3); + expect(diskConfig.users["platform-user-sub"]?.email).toBe("user@example.com"); + expect(diskConfig.profiles.default?.user).toBe("platform-user-sub"); + expect(diskConfig.current_user).toBe("platform-user-sub"); + }); + + test("ignores TAILOR_USE_KEYRING and still downgrades a file-only config to V1", () => { + process.env.TAILOR_USE_KEYRING = "1"; + + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "file@example.com": { + storage: "file", + access_token: "file-access-token", + token_expires_at: futureDate, + }, + }, + profiles: {}, + current_user: "file@example.com", + }); const diskConfig = parseYAML(fs.readFileSync(configPath, "utf-8")) as { version: number; @@ -1023,7 +1513,7 @@ describe("keyring user persistence on V2 -> V1 downgrade", () => { >; }; expect(diskConfig.version).toBe(3); - expect(diskConfig.min_sdk_version).toBe("1.70.0"); + expect(diskConfig.min_sdk_version).toBe("2.0.0"); expect(diskConfig.profiles.dev?.platform_url).toBe("https://api.dev.tailor.tech"); expect(diskConfig.profiles.dev?.oauth2_client_id).toBe("dev-client"); expect(diskConfig.profiles.dev?.console_url).toBe("https://console.dev.tailor.tech"); @@ -1034,20 +1524,21 @@ describe("keyring user persistence on V2 -> V1 downgrade", () => { }); test("clears current_user on V1 downgrade when it points at a user not representable in V1", () => { - writePlatformConfig( - v2Config({ - users: { - "file@example.com": { - storage: "file", - access_token: "file-access-token", - token_expires_at: futureDate, - }, + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "file@example.com": { + storage: "file", + access_token: "file-access-token", + token_expires_at: futureDate, }, - // current_user references a user that is not in the users map, so it - // cannot be represented in V1 and must be cleared on downgrade. - current_user: "missing@example.com", - }), - ); + }, + profiles: {}, + // current_user references a user that is not in the users map, so it + // cannot be represented in V1 and must be cleared on downgrade. + current_user: "missing@example.com", + }); const diskConfig = parseYAML(fs.readFileSync(configPath, "utf-8")) as { version: number; @@ -1060,7 +1551,13 @@ describe("keyring user persistence on V2 -> V1 downgrade", () => { describe.skipIf(process.platform === "win32")("writePlatformConfig file permissions", () => { test("writes the config file with mode 0600 and its directory with mode 0700", () => { - writePlatformConfig(v2Config()); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); const fileMode = fs.statSync(configPath).mode & 0o777; @@ -1076,7 +1573,13 @@ describe.skipIf(process.platform === "win32")("writePlatformConfig file permissi fs.writeFileSync(configPath, "stale: true", { mode: 0o644 }); fs.chmodSync(configPath, 0o644); - writePlatformConfig(v2Config()); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: {}, + current_user: null, + }); const fileMode = fs.statSync(configPath).mode & 0o777; expect(fileMode).toBe(0o600); diff --git a/packages/sdk/src/cli/shared/context.ts b/packages/sdk/src/cli/shared/context.ts index e39ad201ab..a5745e165a 100644 --- a/packages/sdk/src/cli/shared/context.ts +++ b/packages/sdk/src/cli/shared/context.ts @@ -1,6 +1,5 @@ import * as fs from "node:fs"; -import * as os from "node:os"; -import { parseYAML, stringifyYAML, parseTOML } from "confbox"; +import { parseYAML, stringifyYAML } from "confbox"; import { findUpSync } from "find-up-simple"; import * as path from "pathe"; import { lt as semverLt } from "semver"; @@ -10,6 +9,7 @@ import { assertDefined } from "#/utils/assert"; import ml from "#/utils/multiline"; import { defaultPlatformBaseUrl, + fetchUserInfo, getConsoleBaseUrl, getPlatformBaseUrl, initOAuth2Client, @@ -28,6 +28,7 @@ import { deleteKeyringTokens, } from "./token-store"; +// strip unknown keys const pfProfileSchema = z.object({ user: z.string(), workspace_id: z.string(), @@ -39,17 +40,20 @@ const pfProfileSchema = z.object({ console_url: z.url().optional(), }); +// strip unknown keys const pfUserSchemaV1 = z.object({ access_token: z.string(), refresh_token: z.string().optional(), token_expires_at: z.string(), }); +// strip unknown keys const pfUserKeyringSchema = z.object({ storage: z.literal("keyring"), token_expires_at: z.string(), }); +// strip unknown keys const pfUserFileSchema = z.object({ storage: z.literal("file"), token_expires_at: z.string(), @@ -59,8 +63,17 @@ const pfUserFileSchema = z.object({ const pfUserSchemaV2 = z.discriminatedUnion("storage", [pfUserKeyringSchema, pfUserFileSchema]); -type PfUserV2 = z.output; +const pfUserKeyringSchemaV3 = pfUserKeyringSchema.extend({ + email: z.string().optional(), +}); + +const pfUserFileSchemaV3 = pfUserFileSchema.extend({ + email: z.string().optional(), +}); + +const pfUserSchemaV3 = z.discriminatedUnion("storage", [pfUserKeyringSchemaV3, pfUserFileSchemaV3]); +// strip unknown keys const pfConfigSchemaV1 = z.object({ version: z.literal(1), users: z.partialRecord(z.string(), pfUserSchemaV1), @@ -71,7 +84,7 @@ const pfConfigSchemaV1 = z.object({ const V2_CONFIG_VERSION = 2; const LATEST_CONFIG_VERSION = 3; const V2_MIN_SDK_VERSION = "1.29.0"; -const V3_MIN_SDK_VERSION = "1.70.0"; +const V3_MIN_SDK_VERSION = "2.0.0"; const semverSchema = z.templateLiteral([ z.number().int(), @@ -81,8 +94,9 @@ const semverSchema = z.templateLiteral([ z.number().int(), ]); -const pfConfigSchema = z.object({ - version: z.union([z.literal(V2_CONFIG_VERSION), z.literal(LATEST_CONFIG_VERSION)]), +// strip unknown keys +const pfConfigSchemaV2 = z.object({ + version: z.literal(V2_CONFIG_VERSION), min_sdk_version: semverSchema, latest_version: z.number().int().optional(), latest_min_sdk_version: semverSchema.optional(), @@ -91,10 +105,23 @@ const pfConfigSchema = z.object({ current_user: z.string().nullable(), }); +// strip unknown keys +const pfConfigSchemaV3 = z.object({ + version: z.literal(LATEST_CONFIG_VERSION), + min_sdk_version: semverSchema, + latest_version: z.number().int().optional(), + latest_min_sdk_version: semverSchema.optional(), + users: z.partialRecord(z.string(), pfUserSchemaV3), + profiles: z.partialRecord(z.string(), pfProfileSchema), + current_user: z.string().nullable(), +}); + type PfConfigV1 = z.output; -type PfConfig = z.output; -type PfConfigV2 = PfConfig & { version: typeof V2_CONFIG_VERSION }; -type PfConfigV3 = PfConfig & { version: typeof LATEST_CONFIG_VERSION }; +type PfConfigV2 = z.output; +type PfConfig = z.output; +type PfUser = z.output; +type UserTokens = { accessToken: string; refreshToken?: string }; +type PfConfigV3 = PfConfig; type LoadWorkspaceIdOptions = { workspaceId?: string; profile?: string; @@ -152,6 +179,11 @@ function platformUserKey(user: string, config?: PlatformClientConfig): string { return `${platformUrl}|${user}`; } +function userFromPlatformUserKey(userKey: string, config?: PlatformClientConfig): string { + const platformPrefix = `${getPlatformBaseUrl(config)}|`; + return userKey.startsWith(platformPrefix) ? userKey.slice(platformPrefix.length) : userKey; +} + function canUseLegacyUserKey(platformUrl: string): boolean { try { return getPlatformBaseUrl() === platformUrl; @@ -164,6 +196,20 @@ type UserEntryLookupOptions = { allowLegacyUserKey?: boolean; }; +function findUserByEmail( + users: PfConfig["users"], + email: string, + platformConfig?: PlatformClientConfig, +) { + const platformUrl = getPlatformBaseUrl(platformConfig); + const platformPrefix = `${platformUrl}|`; + const defaultPlatform = platformUrl === normalizeBaseUrl(defaultPlatformBaseUrl); + return Object.entries(users).find(([key, entry]) => { + if (entry?.email !== email) return false; + return defaultPlatform ? !key.includes("|") : key.startsWith(platformPrefix); + }); +} + function findUserEntry( config: PfConfig, user: string, @@ -175,6 +221,10 @@ function findUserEntry( if (userEntry) { return { userKey, userEntry }; } + const emailMatch = findUserByEmail(config.users, user, platformConfig); + if (emailMatch?.[1]) { + return { userKey: emailMatch[0], userEntry: emailMatch[1] }; + } const platformUrl = getPlatformBaseUrl(platformConfig); if ( userKey !== user && @@ -203,6 +253,16 @@ export function resolveUserTokenKey( return findUserEntry(config, user, platformConfig, opts).userKey; } +export function resolveConfigUser( + config: PfConfig, + user: string, + platformConfig?: PlatformClientConfig, + opts?: UserEntryLookupOptions, +): string | undefined { + const { userKey, userEntry } = findUserEntry(config, user, platformConfig, opts); + return userEntry ? userFromPlatformUserKey(userKey, platformConfig) : undefined; +} + /** * Check whether tokens are registered for a user on the selected platform. * @param config - Platform config @@ -224,6 +284,10 @@ function hasUserKeyForName(users: Record, user: string): boolea return users[user] !== undefined || Object.keys(users).some((key) => key.endsWith(`|${user}`)); } +function hasUserEmailEntry(users: PfConfig["users"], user: string): boolean { + return Object.values(users).some((entry) => entry?.email === user); +} + /** * Check whether any platform has tokens registered for a user. * @param config - Platform config @@ -231,7 +295,7 @@ function hasUserKeyForName(users: Record, user: string): boolea * @returns True when the user has a token entry for any platform */ export function hasAnyUserTokenEntry(config: Pick, user: string): boolean { - return hasUserKeyForName(config.users, user); + return hasUserKeyForName(config.users, user) || hasUserEmailEntry(config.users, user); } function hasCurrentUserEntry(users: PfConfigV1["users"], currentUser: string): boolean { @@ -246,7 +310,7 @@ function hasCurrentUserEntry(users: PfConfigV1["users"], currentUser: string): b * @returns Migrated v2 configuration */ function migrateV1ToV2(v1Config: PfConfigV1): PfConfigV2 { - const users: PfConfig["users"] = {}; + const users: PfConfigV2["users"] = {}; for (const [name, v1User] of Object.entries(v1Config.users)) { if (!v1User) continue; @@ -268,7 +332,56 @@ function migrateV1ToV2(v1Config: PfConfigV1): PfConfigV2 { }; } -async function warnIfNewerConfigAvailable(config: PfConfig) { +function inferEmailFromUserId(user: string): string | undefined { + return z.email().safeParse(user).success ? user : undefined; +} + +function migrateV2ToV3(v2Config: PfConfigV2): PfConfig { + const users: PfConfig["users"] = {}; + + for (const [user, entry] of Object.entries(v2Config.users)) { + if (!entry) continue; + const email = inferEmailFromUserId(user); + users[user] = { + ...entry, + ...(email ? { email } : {}), + }; + } + + return { + version: LATEST_CONFIG_VERSION, + min_sdk_version: V3_MIN_SDK_VERSION, + users, + profiles: v2Config.profiles, + current_user: v2Config.current_user, + }; +} + +function migrateV1ToV3(v1Config: PfConfigV1): PfConfig { + return migrateV2ToV3(migrateV1ToV2(v1Config)); +} + +function formatUnknownError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function trySaveTokensInKeyring(user: string, tokens: UserTokens): Promise { + if (!(await isKeyringAvailable())) return false; + try { + await saveKeyringTokens(user, tokens); + return true; + } catch (error) { + logger.warn( + `System keyring failed to store credentials. Tokens will be stored in the config file. ${formatUnknownError(error)}`, + ); + return false; + } +} + +async function warnIfNewerConfigAvailable(config: { + latest_version?: number; + latest_min_sdk_version?: string; +}) { if (!config.latest_min_sdk_version) return; const packageJson = await readPackageJson(); const sdkVersion = packageJson.version ?? "0.0.0"; @@ -281,21 +394,22 @@ async function warnIfNewerConfigAvailable(config: PfConfig) { } /** - * Read Tailor Platform CLI configuration, migrating from tailorctl or v1 if necessary. + * Read Tailor Platform CLI configuration, migrating from v1 if necessary. * @returns Parsed platform configuration */ export async function readPlatformConfig(): Promise { const configPath = platformConfigPath(); - // If platform config doesn't exist, try to read tailorctl config and migrate if (!fs.existsSync(configPath)) { - logger.warn(`Config not found at ${configPath}, migrating from tailorctl config...`); - const tcConfig = readTailorctlConfig(); - const v1Config = tcConfig - ? fromTailorctlConfig(tcConfig) - : ({ version: 1, users: {}, profiles: {}, current_user: null } as const); - writePlatformConfig(v1Config); - return migrateV1ToV2(v1Config); + const config: PfConfig = { + version: LATEST_CONFIG_VERSION, + min_sdk_version: V3_MIN_SDK_VERSION, + users: {}, + profiles: {}, + current_user: null, + }; + writePlatformConfig(config); + return config; } const rawConfig = parseYAML(fs.readFileSync(configPath, "utf-8")); @@ -324,26 +438,34 @@ export async function readPlatformConfig(): Promise { `); } - const configResult = pfConfigSchema.safeParse(rawConfig); - if (configResult.success) { - await warnIfNewerConfigAvailable(configResult.data); - return configResult.data; + // Try v3 first + const v3Result = pfConfigSchemaV3.safeParse(rawConfig); + if (v3Result.success) { + await warnIfNewerConfigAvailable(v3Result.data); + return v3Result.data; } - // Fall back to v1 (convert to v2 in memory, but don't rewrite disk) + // Try v2 next + const v2Result = pfConfigSchemaV2.safeParse(rawConfig); + if (v2Result.success) { + await warnIfNewerConfigAvailable(v2Result.data); + return migrateV2ToV3(v2Result.data); + } + + // Fall back to v1 (convert to v3 in memory, but don't rewrite disk) const v1Result = pfConfigSchemaV1.safeParse(rawConfig); if (v1Result.success) { - return migrateV1ToV2(v1Result.data); + return migrateV1ToV3(v1Result.data); } - // Neither v1, v2, nor the latest format + // Neither v1, v2, nor v3 throw new Error(ml` Failed to parse config file at ${configPath}. The file may be corrupted or created by an incompatible SDK version. `); } -function toV1ForDisk(config: PfConfig): PfConfigV1 { +function toV1ForDisk(config: PfConfigV2): PfConfigV1 { const users: PfConfigV1["users"] = {}; for (const [name, entry] of Object.entries(config.users)) { if (!entry || entry.storage === "keyring") continue; @@ -378,7 +500,13 @@ function hasScopedUserKeys(config: Pick): boolea return Object.keys(config.users).some((userKey) => userKey.includes("|")); } -function toLatestForDisk(config: PfConfig | PfConfigV1): PfConfigV3 { +function hasUserEmailMetadata(config: Pick): boolean { + return Object.values(config.users).some( + (user) => user != null && "email" in user && user.email !== undefined, + ); +} + +function toLatestForDisk(config: PfConfig | PfConfigV2 | PfConfigV1): PfConfigV3 { const latestInput = config.version === 1 ? migrateV1ToV2(config) : config; return { ...latestInput, @@ -393,98 +521,31 @@ function toLatestForDisk(config: PfConfig | PfConfigV1): PfConfigV3 { * backward compatibility, so an older SDK can still read the file. Configs * containing a keyring user are kept in V2 or later because the keyring storage * variant is not representable in V1. Configs containing profile-level Platform - * settings or platform-scoped user tokens are written in the latest - * min-SDK-gated format because older SDKs would silently drop or misread those - * settings. - * Set TAILOR_USE_KEYRING to write the current in-memory format unconditionally. + * settings, platform-scoped user tokens, canonical user IDs, or email metadata + * are written in the latest min-SDK-gated format because older SDKs would + * silently drop or misread those settings. * * The config file may contain access/refresh tokens when the OS keyring is * unavailable, so it is written via {@link writeSecretFile} so other users * on the host cannot read it. * @param config - Platform configuration to write */ -export function writePlatformConfig(config: PfConfig | PfConfigV1) { +export function writePlatformConfig(config: PfConfig | PfConfigV2 | PfConfigV1) { const configPath = platformConfigPath(); const hasKeyringUser = config.version !== 1 && Object.values(config.users).some((u) => u?.storage === "keyring"); const diskConfig = - hasProfilePlatformSettings(config) || hasScopedUserKeys(config) + config.version === LATEST_CONFIG_VERSION || + hasProfilePlatformSettings(config) || + hasScopedUserKeys(config) || + hasUserEmailMetadata(config) ? toLatestForDisk(config) - : config.version !== 1 && !process.env.TAILOR_USE_KEYRING && !hasKeyringUser + : config.version === V2_CONFIG_VERSION && !hasKeyringUser ? toV1ForDisk(config) : config; writeSecretFile(configPath, stringifyYAML(diskConfig)); } -const tcContextConfigSchema = z.object({ - username: z.string().optional(), - controlplaneaccesstoken: z.string().optional(), - controlplanerefreshtoken: z.string().optional(), - controlplanetokenexpiresat: z.string().optional(), - workspaceid: z.string().optional(), -}); - -const tcConfigSchema = z - .object({ - global: z - .object({ - context: z.string().optional(), - }) - .optional(), - }) - .catchall(tcContextConfigSchema.optional()); - -type TcConfig = z.output; -type TcContextConfig = z.output; - -function readTailorctlConfig(): TcConfig | undefined { - const configPath = path.join(os.homedir(), ".tailorctl", "config"); - if (!fs.existsSync(configPath)) { - return; - } - const rawConfig = parseTOML(fs.readFileSync(configPath, "utf-8")); - return tcConfigSchema.parse(rawConfig); -} - -function fromTailorctlConfig(config: TcConfig): PfConfigV1 { - const users: PfConfigV1["users"] = {}; - const profiles: PfConfigV1["profiles"] = {}; - let currentUser: PfConfigV1["current_user"] = null; - - const currentContext = config.global?.context || "default"; - for (const [key, val] of Object.entries(config)) { - if (key === "global") { - continue; - } - const context = val as TcContextConfig; - if ( - !context.username || - !context.controlplaneaccesstoken || - !context.controlplanerefreshtoken || - !context.controlplanetokenexpiresat || - !context.workspaceid - ) { - continue; - } - if (key === currentContext) { - currentUser = context.username; - } - profiles[key] = { - user: context.username, - workspace_id: context.workspaceid, - }; - const user = users[context.username]; - if (!user || new Date(user.token_expires_at) < new Date(context.controlplanetokenexpiresat)) { - users[context.username] = { - access_token: context.controlplaneaccesstoken, - refresh_token: context.controlplanerefreshtoken, - token_expires_at: context.controlplanetokenexpiresat, - }; - } - } - return { version: 1, users, profiles, current_user: currentUser }; -} - function validateUUID(value: string, source: string): string { const result = z.uuid().safeParse(value); if (!result.success) { @@ -569,7 +630,7 @@ export async function loadMachineUserName( code: "PROFILE_MACHINE_USER_OVERRIDE_DENIED", message: `Profile "${profile}" denies overriding the machine user.`, details: `This profile fixes the machine user to "${entry.machine_user}" for application-data commands.`, - suggestion: `Omit the machine user option, unset TAILOR_PLATFORM_MACHINE_USER_NAME, or run 'tailor-sdk profile update ${profile} --machine-user-override allow'.`, + suggestion: `Omit the machine user option, unset TAILOR_PLATFORM_MACHINE_USER_NAME, or run 'tailor profile update ${profile} --machine-user-override allow'.`, }); } return entry.machine_user; @@ -607,11 +668,11 @@ export async function loadAccessToken(opts?: LoadAccessTokenOptions) { if (!user) { throw new Error(ml` Tailor Platform token not found. - Please specify token via TAILOR_PLATFORM_TOKEN environment variable or login using 'tailor-sdk login' command. + Please specify token via TAILOR_PLATFORM_TOKEN environment variable or login using 'tailor login' command. `); } const fromProfile = profileEntry ? platformConfigFromProfile(profileEntry) : undefined; - return await fetchLatestToken(pfConfig, user, fromProfile); + return (await fetchLatestToken(pfConfig, user, fromProfile)).accessToken; } /** @@ -664,7 +725,7 @@ export async function loadConsoleBaseUrl(opts?: LoadConsoleBaseUrlOptions): Prom * @returns Access token and optional refresh token */ export async function resolveTokens( - userEntry: PfUserV2, + userEntry: PfUser, user: string, label = user, ): Promise<{ accessToken: string; refreshToken?: string }> { @@ -673,7 +734,7 @@ export async function resolveTokens( if (!tokens) { throw new Error(ml` Credentials not found in OS keyring for "${label}". - Please run 'tailor-sdk login' and try again. + Please run 'tailor login' and try again. `); } return tokens; @@ -685,41 +746,41 @@ export async function resolveTokens( }; } -interface UserTokenData { - accessToken: string; - refreshToken?: string; -} - /** - * Save tokens for a user, writing to keyring or config as appropriate. + * Save tokens for a user, writing to keyring by default when available. * @param config - Platform config * @param user - User identifier * @param tokens - Token data to save - * @param tokens.accessToken - Access token to persist - * @param tokens.refreshToken - Refresh token to persist when available + * @param tokens.accessToken - Access token to save + * @param tokens.refreshToken - Optional refresh token to save * @param expiresAt - Token expiration date - * @param platformConfig - Optional platform connection settings + * @param opts - Optional platform and user metadata */ export async function saveUserTokens( config: PfConfig, user: string, - tokens: UserTokenData, + tokens: UserTokens, expiresAt: string, - platformConfig?: PlatformClientConfig, + opts: { platformConfig?: PlatformClientConfig; email?: string } = {}, ): Promise { - const userKey = platformUserKey(user, platformConfig); - if (process.env.TAILOR_USE_KEYRING && (await isKeyringAvailable())) { - await saveKeyringTokens(userKey, tokens); + const userKey = platformUserKey(user, opts.platformConfig); + const email = opts.email ?? config.users[userKey]?.email; + if (await trySaveTokensInKeyring(userKey, tokens)) { config.users[userKey] = { token_expires_at: expiresAt, storage: "keyring", + ...(email ? { email } : {}), }; } else { + if (config.users[userKey]?.storage === "keyring") { + await deleteKeyringTokens(userKey); + } config.users[userKey] = { access_token: tokens.accessToken, refresh_token: tokens.refreshToken, token_expires_at: expiresAt, storage: "file", + ...(email ? { email } : {}), }; } } @@ -759,7 +820,7 @@ export async function loadStoredUserTokens( opts?: UserEntryLookupOptions, ): Promise< | { - userEntry: PfUserV2; + userEntry: PfUser; accessToken: string; refreshToken?: string; } @@ -771,37 +832,83 @@ export async function loadStoredUserTokens( return { userEntry, ...tokens }; } +function updateUserReferences(config: PfConfig, fromUser: string, toUser: string) { + if (fromUser === toUser) return; + if (config.current_user === fromUser) { + config.current_user = toUser; + } + for (const profile of Object.values(config.profiles)) { + if (profile?.user === fromUser) { + profile.user = toUser; + } + } +} + +/** + * Remove a legacy alias after a canonical user ID has been written. + * @param config - Platform config + * @param legacyUser - Previous user key + * @param canonicalUser - Canonical user key + * @param platformConfig - Platform settings for scoped token storage + */ +export async function removeLegacyUserAlias( + config: PfConfig, + legacyUser: string, + canonicalUser: string, + platformConfig?: PlatformClientConfig, +): Promise { + if (legacyUser === canonicalUser) return; + updateUserReferences(config, legacyUser, canonicalUser); + const canonicalKey = platformUserKey(canonicalUser, platformConfig); + const legacyKeys = new Set([legacyUser, platformUserKey(legacyUser, platformConfig)]); + for (const legacyKey of legacyKeys) { + if (legacyKey === canonicalKey) continue; + const entry = config.users[legacyKey]; + if (entry?.storage === "keyring") { + await deleteKeyringTokens(legacyKey); + } + delete config.users[legacyKey]; + } +} + +function shouldResolveSubjectOnRefresh(user: string, userEntry: PfUser): boolean { + return Boolean(userEntry.email || inferEmailFromUserId(user)); +} + /** * Fetch the latest access token, refreshing if necessary. * @param config - Platform config - * @param user - User name + * @param user - User identifier * @param platformConfig - Optional platform connection settings - * @returns Latest access token + * @returns Latest access token and the canonical user ID it is stored under + * (the resolved subject when a legacy email key was migrated during refresh, + * otherwise the matching config user) */ export async function fetchLatestToken( config: PfConfig, user: string, platformConfig?: PlatformClientConfig, -): Promise { - const { userKey, userEntry } = findUserEntry(config, user, platformConfig); +): Promise<{ accessToken: string; user: string }> { + const { userKey: storedUser, userEntry } = findUserEntry(config, user, platformConfig); if (!userEntry) { throw new Error(ml` User "${user}" not found. - Please verify your user name and login using 'tailor-sdk login' command. + Please verify your user name and login using 'tailor login' command. `); } - const tokens = await resolveTokens(userEntry, userKey, user); + const storedConfigUser = userFromPlatformUserKey(storedUser, platformConfig); + const tokens = await resolveTokens(userEntry, storedUser, user); if (new Date(userEntry.token_expires_at) > new Date()) { rememberPlatformConfigForToken(tokens.accessToken, platformConfig); - return tokens.accessToken; + return { accessToken: tokens.accessToken, user: storedConfigUser }; } if (!tokens.refreshToken) { throw new Error(ml` Token expired. - Please run 'tailor-sdk login' and try again. + Please run 'tailor login' and try again. `); } @@ -816,33 +923,56 @@ export async function fetchLatestToken( } catch { throw new Error(ml` Failed to refresh token. Your session may have expired. - Please run 'tailor-sdk login' and try again. + Please run 'tailor login' and try again. `); } const newExpiresAt = new Date( assertDefined(resp.expiresAt, "token refresh response missing expiresAt"), ).toISOString(); + + let resolvedUser = storedConfigUser; + const previousEmail = + userEntry.email ?? inferEmailFromUserId(user) ?? inferEmailFromUserId(storedConfigUser); + let email = previousEmail; + if ( + shouldResolveSubjectOnRefresh(user, userEntry) || + shouldResolveSubjectOnRefresh(storedConfigUser, userEntry) + ) { + try { + const userInfo = await fetchUserInfo(resp.accessToken, platformConfig); + resolvedUser = userInfo.sub; + email = userInfo.email; + } catch (error) { + logger.debug(`Failed to resolve refreshed token user info: ${String(error)}`); + } + } + await saveUserTokens( config, - user, + resolvedUser, { accessToken: resp.accessToken, refreshToken: resp.refreshToken ?? undefined, }, newExpiresAt, - platformConfig, + { platformConfig, email }, ); - const refreshedUserKey = platformUserKey(user, platformConfig); - if (userKey !== refreshedUserKey) { - if (userEntry.storage === "keyring") { - await deleteKeyringTokens(userKey); + await removeLegacyUserAlias(config, user, resolvedUser, platformConfig); + const canonicalKey = platformUserKey(resolvedUser, platformConfig); + if (storedUser !== canonicalKey) { + const entry = config.users[storedUser]; + if (entry?.storage === "keyring") { + await deleteKeyringTokens(storedUser); } - delete config.users[userKey]; + delete config.users[storedUser]; + } + if (previousEmail && email && previousEmail !== email) { + logger.info(`Updated local user email from "${previousEmail}" to "${email}".`); } writePlatformConfig(config); rememberPlatformConfigForToken(resp.accessToken, platformConfig); - return resp.accessToken; + return { accessToken: resp.accessToken, user: resolvedUser }; } const DEFAULT_CONFIG_FILENAME = "tailor.config.ts"; @@ -858,8 +988,8 @@ export function loadConfigPath(configPath?: string): string | undefined { if (configPath) { return configPath; } - if (process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH) { - return process.env.TAILOR_PLATFORM_SDK_CONFIG_PATH; + if (process.env.TAILOR_CONFIG_PATH) { + return process.env.TAILOR_CONFIG_PATH; } // Search for config file in current directory and parent directories diff --git a/packages/sdk/src/cli/shared/dist-dir.ts b/packages/sdk/src/cli/shared/dist-dir.ts index 7a8c4cef86..aa8cd376d5 100644 --- a/packages/sdk/src/cli/shared/dist-dir.ts +++ b/packages/sdk/src/cli/shared/dist-dir.ts @@ -1,11 +1,11 @@ let distPath: string | null = null; export const getDistDir = (): string => { - const configured = process.env.TAILOR_SDK_OUTPUT_DIR; + const configured = process.env.TAILOR_BUILD_OUTPUT_DIR; if (configured && configured !== distPath) { distPath = configured; } else if (distPath === null) { - distPath = configured || ".tailor-sdk"; + distPath = configured || ".tailor"; } return distPath; }; diff --git a/packages/sdk/src/cli/shared/errors.ts b/packages/sdk/src/cli/shared/errors.ts index 5d0aa170c2..9c68c92205 100644 --- a/packages/sdk/src/cli/shared/errors.ts +++ b/packages/sdk/src/cli/shared/errors.ts @@ -50,7 +50,7 @@ function formatError(error: CLIError): string { if (error.command) { parts.push( - `\n ${chalk.gray("Help:")} Run \`tailor-sdk ${error.command} --help\` for usage information.`, + `\n ${chalk.gray("Help:")} Run \`tailor ${error.command} --help\` for usage information.`, ); } diff --git a/packages/sdk/src/cli/shared/function-script-download.test.ts b/packages/sdk/src/cli/shared/function-script-download.test.ts index a0eb514b1e..872c9d7e27 100644 --- a/packages/sdk/src/cli/shared/function-script-download.test.ts +++ b/packages/sdk/src/cli/shared/function-script-download.test.ts @@ -1,4 +1,3 @@ -import { timestampFromDate } from "@bufbuild/protobuf/wkt"; import { FunctionExecution_Type } from "@tailor-platform/tailor-proto/function_resource_pb"; import { describe, test, expect, vi } from "vitest"; import { downloadFunctionScript, scriptNameToRegistryName } from "./function-script-download"; @@ -21,53 +20,32 @@ function makeStreamingClient(responses: DownloadResponse[]): OperatorClient { } as unknown as OperatorClient; } -function chunk(text: string): DownloadResponse { - return { payload: { case: "chunk", value: new TextEncoder().encode(text) } }; -} - -async function download( - client: OperatorClient, - overrides: { name?: string; contentHash?: string } = {}, -) { - return downloadFunctionScript({ - client, - workspaceId: "ws-1", - name: overrides.name ?? "my-fn", - contentHash: overrides.contentHash, - }); -} - describe("downloadFunctionScript", () => { - test("concatenates chunks into a UTF-8 string and returns registry updatedAt", async () => { - const updatedAt = new Date("2024-03-01T00:00:00Z"); + test("concatenates chunks into a UTF-8 string", async () => { const client = makeStreamingClient([ - { - payload: { - case: "metadata", - value: { function: { updatedAt: timestampFromDate(updatedAt) } }, - }, - }, - chunk("hello, "), - chunk("world"), + { payload: { case: "chunk", value: new TextEncoder().encode("hello, ") } }, + { payload: { case: "chunk", value: new TextEncoder().encode("world") } }, ]); - const result = await download(client); - - expect(result).toEqual({ code: "hello, world", registryUpdatedAt: updatedAt }); - }); - - test("returns registryUpdatedAt as null when metadata omits the timestamp", async () => { - const client = makeStreamingClient([{ payload: { case: "metadata", value: {} } }, chunk("x")]); - - const result = await download(client); + const result = await downloadFunctionScript({ + client, + workspaceId: "ws-1", + name: "my-fn", + }); - expect(result).toEqual({ code: "x", registryUpdatedAt: null }); + expect(result).toEqual({ code: "hello, world" }); }); test("returns null when no chunks are received", async () => { const client = makeStreamingClient([{ payload: { case: "metadata", value: {} } }]); - expect(await download(client)).toBeNull(); + const result = await downloadFunctionScript({ + client, + workspaceId: "ws-1", + name: "my-fn", + }); + + expect(result).toBeNull(); }); test("returns null when the streaming RPC throws", async () => { @@ -77,7 +55,13 @@ describe("downloadFunctionScript", () => { }), } as unknown as OperatorClient; - expect(await download(client, { name: "missing-fn" })).toBeNull(); + const result = await downloadFunctionScript({ + client, + workspaceId: "ws-1", + name: "missing-fn", + }); + + expect(result).toBeNull(); }); test("forwards translated registry name to the RPC request", async () => { @@ -85,11 +69,18 @@ describe("downloadFunctionScript", () => { // scriptNameToRegistryName before calling. This test verifies the // raw `name` field is forwarded as-is so the translation contract // is enforced at the call site. - const client = makeStreamingClient([chunk("x")]); + const fn = vi.fn(async function* () { + yield { payload: { case: "chunk" as const, value: new TextEncoder().encode("x") } }; + }); + const client = { downloadFunctionRegistryScript: fn } as unknown as OperatorClient; - await download(client, { name: "resolver--ns--myFn" }); + await downloadFunctionScript({ + client, + workspaceId: "ws-1", + name: "resolver--ns--myFn", + }); - expect(client.downloadFunctionRegistryScript).toHaveBeenCalledWith({ + expect(fn).toHaveBeenCalledWith({ workspaceId: "ws-1", name: "resolver--ns--myFn", contentHash: undefined, @@ -97,11 +88,19 @@ describe("downloadFunctionScript", () => { }); test("forwards contentHash to the RPC request", async () => { - const client = makeStreamingClient([chunk("x")]); + const fn = vi.fn(async function* () { + yield { payload: { case: "chunk" as const, value: new TextEncoder().encode("x") } }; + }); + const client = { downloadFunctionRegistryScript: fn } as unknown as OperatorClient; - await download(client, { contentHash: "abc123" }); + await downloadFunctionScript({ + client, + workspaceId: "ws-1", + name: "my-fn", + contentHash: "abc123", + }); - expect(client.downloadFunctionRegistryScript).toHaveBeenCalledWith({ + expect(fn).toHaveBeenCalledWith({ workspaceId: "ws-1", name: "my-fn", contentHash: "abc123", @@ -110,33 +109,10 @@ describe("downloadFunctionScript", () => { }); describe("scriptNameToRegistryName", () => { - test.each([ - [ - "translates resolver scriptName to registry name", - "my-resolver.throwError.body.js", - FunctionExecution_Type.STANDARD, - "resolver--my-resolver--throwError", - ], - [ - "translates executor scriptName to registry name", - "user-changed.operation.js", - FunctionExecution_Type.STANDARD, - "executor--user-changed", - ], - [ - "translates auth hook scriptName to registry name", - "my-auth.before-login.hook.js", - FunctionExecution_Type.STANDARD, - "auth-hook--my-auth--before-login", - ], - [ - "translates workflow job scriptName (no dots) under JOB type", - "validate-order", - FunctionExecution_Type.JOB, - "workflow--validate-order", - ], - ])("%s", (_description, scriptName, type, expected) => { - expect(scriptNameToRegistryName(scriptName, type)).toBe(expected); + test("translates resolver scriptName to registry name", () => { + expect( + scriptNameToRegistryName("my-resolver.throwError.body.js", FunctionExecution_Type.STANDARD), + ).toBe("resolver--my-resolver--throwError"); }); test("preserves dots in resolver name (non-greedy namespace)", () => { @@ -146,6 +122,24 @@ describe("scriptNameToRegistryName", () => { ); }); + test("translates executor scriptName to registry name", () => { + expect( + scriptNameToRegistryName("user-changed.operation.js", FunctionExecution_Type.STANDARD), + ).toBe("executor--user-changed"); + }); + + test("translates auth hook scriptName to registry name", () => { + expect( + scriptNameToRegistryName("my-auth.before-login.hook.js", FunctionExecution_Type.STANDARD), + ).toBe("auth-hook--my-auth--before-login"); + }); + + test("translates workflow job scriptName (no dots) under JOB type", () => { + expect(scriptNameToRegistryName("validate-order", FunctionExecution_Type.JOB)).toBe( + "workflow--validate-order", + ); + }); + test("translates workflow job scriptName that contains dots under JOB type", () => { // WorkflowJobSchema.name is an unconstrained string, so job names // may legitimately contain dots. The JOB type discriminator must @@ -158,13 +152,23 @@ describe("scriptNameToRegistryName", () => { ).toBe("workflow--looks-like-a-resolver.body.js"); }); - test.each([ - ["ad-hoc test-run scripts", "test-run--throwError.js"], - ["seed scripts", "seed-tailordb.ts"], - ["query scripts", "query-gql.js"], - ["query scripts", "query-sql-tailordb.js"], - ])("returns null for %s under STANDARD type: %j", (_kind, scriptName) => { - expect(scriptNameToRegistryName(scriptName, FunctionExecution_Type.STANDARD)).toBeNull(); + test("returns null for ad-hoc test-run scripts under STANDARD type", () => { + expect( + scriptNameToRegistryName("test-run--throwError.js", FunctionExecution_Type.STANDARD), + ).toBeNull(); + }); + + test("returns null for seed scripts under STANDARD type", () => { + expect( + scriptNameToRegistryName("seed-tailordb.ts", FunctionExecution_Type.STANDARD), + ).toBeNull(); + }); + + test("returns null for query scripts under STANDARD type", () => { + expect(scriptNameToRegistryName("query-gql.js", FunctionExecution_Type.STANDARD)).toBeNull(); + expect( + scriptNameToRegistryName("query-sql-tailordb.js", FunctionExecution_Type.STANDARD), + ).toBeNull(); }); test("falls back to extension parsing for UNSPECIFIED type (legacy servers)", () => { diff --git a/packages/sdk/src/cli/shared/function-script-download.ts b/packages/sdk/src/cli/shared/function-script-download.ts index 6eefea27ce..f789eb81b5 100644 --- a/packages/sdk/src/cli/shared/function-script-download.ts +++ b/packages/sdk/src/cli/shared/function-script-download.ts @@ -5,7 +5,6 @@ * concatenates content chunks into a single UTF-8 string. */ -import { timestampDate } from "@bufbuild/protobuf/wkt"; import { FunctionExecution_Type } from "@tailor-platform/tailor-proto/function_resource_pb"; import { logger } from "#/cli/shared/logger"; import type { OperatorClient } from "#/cli/shared/client"; @@ -96,25 +95,17 @@ export interface DownloadFunctionScriptOptions { export interface DownloadedFunctionScript { /** Bundled script content as a UTF-8 string */ code: string; - /** - * Server-side last-update timestamp of the registry entry, or null - * when the metadata message omitted it. Callers comparing against an - * execution timestamp use this to detect redeploys that happened - * after the execution ran. - */ - registryUpdatedAt: Date | null; } /** * Download a deployed function script. * - * Returns the bundled script content together with the registry - * entry's `updatedAt` timestamp. Returns null when the download fails - * (script removed, network error, etc.) or when no content chunks are - * received; errors are swallowed so callers can fall back to a - * non-sourcemap display. + * Returns the bundled script content. Returns null when the download + * fails (script removed, network error, etc.) or when no content + * chunks are received; errors are swallowed so callers can fall back + * to a non-sourcemap display. * @param options - Download options - * @returns Script content plus metadata, or null on failure / empty response + * @returns Script content, or null on failure / empty response */ export async function downloadFunctionScript( options: DownloadFunctionScriptOptions, @@ -122,24 +113,17 @@ export async function downloadFunctionScript( const { client, workspaceId, name, contentHash } = options; try { const chunks: Uint8Array[] = []; - let registryUpdatedAt: Date | null = null; for await (const response of client.downloadFunctionRegistryScript({ workspaceId, name, contentHash, })) { - if (response.payload.case === "metadata") { - const updatedAt = response.payload.value.function?.updatedAt; - if (updatedAt) registryUpdatedAt = timestampDate(updatedAt); - } else if (response.payload.case === "chunk") { + if (response.payload.case === "chunk") { chunks.push(response.payload.value); } } if (chunks.length === 0) return null; - return { - code: Buffer.concat(chunks).toString("utf-8"), - registryUpdatedAt, - }; + return { code: Buffer.concat(chunks).toString("utf-8") }; } catch (error) { logger.debug(`Failed to download function script "${options.name}": ${error}`); return null; diff --git a/packages/sdk/src/cli/shared/inline-sourcemap.ts b/packages/sdk/src/cli/shared/inline-sourcemap.ts index c11785f46a..4725d75eea 100644 --- a/packages/sdk/src/cli/shared/inline-sourcemap.ts +++ b/packages/sdk/src/cli/shared/inline-sourcemap.ts @@ -5,14 +5,14 @@ import { parseBoolean } from "./parse-boolean"; * * Resolution order: * 1. Config value (`inlineSourcemap` in defineConfig) — if explicitly set - * 2. Environment variable `TAILOR_ENABLE_INLINE_SOURCEMAP` — if explicitly set + * 2. Environment variable `TAILOR_INLINE_SOURCEMAP` — if explicitly set * 3. Default: `true` * @param configValue - The `inlineSourcemap` value from AppConfig * @returns Whether inline sourcemaps should be enabled */ export function resolveInlineSourcemap(configValue?: boolean): boolean { if (configValue !== undefined) return configValue; - const envValue = parseBoolean(process.env.TAILOR_ENABLE_INLINE_SOURCEMAP); + const envValue = parseBoolean(process.env.TAILOR_INLINE_SOURCEMAP); if (envValue !== undefined) return envValue; return true; } diff --git a/packages/sdk/src/cli/shared/platform-bundle-plugin.test.ts b/packages/sdk/src/cli/shared/platform-bundle-plugin.test.ts index e12ad50308..5f8f158d31 100644 --- a/packages/sdk/src/cli/shared/platform-bundle-plugin.test.ts +++ b/packages/sdk/src/cli/shared/platform-bundle-plugin.test.ts @@ -10,7 +10,7 @@ const run = (code: string): string | null => { describe("platformBundleDefinePlugin", () => { test("folds the gate member-expression to true", () => { - expect(run("if (!process.env.TAILOR_PLATFORM_BUNDLE) register();")).toBe( + expect(run("if (!process.env.__TAILOR_PLATFORM_BUNDLE) register();")).toBe( "if (!true) register();", ); }); @@ -20,9 +20,9 @@ describe("platformBundleDefinePlugin", () => { }); test("does not rewrite a longer key or a different owner", () => { - const longerKey = "read(process.env.TAILOR_PLATFORM_BUNDLE_MODE);"; + const longerKey = "read(process.env.__TAILOR_PLATFORM_BUNDLE_MODE);"; expect(run(longerKey)).toBe(longerKey); - const otherOwner = "read(self.process.env.TAILOR_PLATFORM_BUNDLE);"; + const otherOwner = "read(self.process.env.__TAILOR_PLATFORM_BUNDLE);"; expect(run(otherOwner)).toBe(otherOwner); }); }); diff --git a/packages/sdk/src/cli/shared/platform-bundle-plugin.ts b/packages/sdk/src/cli/shared/platform-bundle-plugin.ts index bd784ceea5..b87233443b 100644 --- a/packages/sdk/src/cli/shared/platform-bundle-plugin.ts +++ b/packages/sdk/src/cli/shared/platform-bundle-plugin.ts @@ -1,9 +1,9 @@ import type * as rolldown from "rolldown"; -// Match the exact `process.env.TAILOR_PLATFORM_BUNDLE` member-expression: the +// Match the exact `process.env.__TAILOR_PLATFORM_BUNDLE` member-expression: the // leading lookbehind rejects a longer owner (`foo.process.env…`) or identifier, // the trailing `\b` rejects a longer key (`…_BUNDLE_MODE`). -const GATE = /(? ({ + loadAccessToken: vi.fn(), + loadWorkspaceId: vi.fn(), + loadConfigPath: vi.fn(), + loadPlatformClientConfig: vi.fn(), + readPlatformConfig: vi.fn(), +})); + +vi.mock("./context", () => contextMocks); + +const isWindows = process.platform === "win32"; +const CLI = "tailor-sdk"; + +/** + * Create an executable fake plugin file. + * @param dir - Directory to create the file in + * @param name - File name + * @returns Absolute path to the created file + */ +function writeExecutable(dir: string, name: string): string { + fs.mkdirSync(dir, { recursive: true }); + const full = path.join(dir, name); + fs.writeFileSync(full, "#!/bin/sh\necho hi\n"); + if (!isWindows) fs.chmodSync(full, 0o755); + return full; +} + +describe("resolvePlugin / listPlugins", () => { + let tempDir: string; + let originalCwd: string; + let originalPath: string | undefined; + + beforeEach(() => { + tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tailor-plugin-"))); + originalCwd = process.cwd(); + originalPath = process.env.PATH; + }); + + afterEach(() => { + process.chdir(originalCwd); + process.env.PATH = originalPath; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("resolves a plugin from the nearest node_modules/.bin", () => { + const project = path.join(tempDir, "project"); + const binDir = path.join(project, "node_modules", ".bin"); + const exe = writeExecutable(binDir, `${CLI}-hello`); + process.chdir(project); + process.env.PATH = ""; + + const resolved = resolvePlugin("hello", CLI); + expect(resolved).toEqual({ name: "hello", path: exe, source: "node_modules" }); + }); + + test("resolves a plugin from PATH when not in node_modules", () => { + const project = path.join(tempDir, "project"); + fs.mkdirSync(project, { recursive: true }); + const pathDir = path.join(tempDir, "bin"); + const exe = writeExecutable(pathDir, `${CLI}-frompath`); + process.chdir(project); + process.env.PATH = pathDir; + + const resolved = resolvePlugin("frompath", CLI); + expect(resolved).toEqual({ name: "frompath", path: exe, source: "path" }); + }); + + test("prefers node_modules/.bin over PATH on collision", () => { + const project = path.join(tempDir, "project"); + const binDir = path.join(project, "node_modules", ".bin"); + const nmExe = writeExecutable(binDir, `${CLI}-dup`); + const pathDir = path.join(tempDir, "bin"); + writeExecutable(pathDir, `${CLI}-dup`); + process.chdir(project); + process.env.PATH = pathDir; + + const resolved = resolvePlugin("dup", CLI); + expect(resolved?.source).toBe("node_modules"); + expect(resolved?.path).toBe(nmExe); + }); + + test("returns null when no matching plugin exists", () => { + const project = path.join(tempDir, "project"); + fs.mkdirSync(project, { recursive: true }); + process.chdir(project); + process.env.PATH = ""; + + expect(resolvePlugin("missing", CLI)).toBeNull(); + }); + + test("rejects names containing path separators or NUL", () => { + // A traversal-shaped name must never resolve, even if such a file exists. + const binDir = path.join(tempDir, "project", "node_modules", ".bin"); + writeExecutable(binDir, `${CLI}-evil`); + process.chdir(path.join(tempDir, "project")); + process.env.PATH = ""; + + expect(resolvePlugin("../evil", CLI)).toBeNull(); + expect(resolvePlugin("a/b", CLI)).toBeNull(); + expect(resolvePlugin("a\\b", CLI)).toBeNull(); + expect(resolvePlugin("a\0b", CLI)).toBeNull(); + }); + + test("lists discovered plugins, deduping by name with node_modules precedence", () => { + const project = path.join(tempDir, "project"); + const binDir = path.join(project, "node_modules", ".bin"); + writeExecutable(binDir, `${CLI}-alpha`); + writeExecutable(binDir, `${CLI}-dup`); + const pathDir = path.join(tempDir, "bin"); + writeExecutable(pathDir, `${CLI}-beta`); + writeExecutable(pathDir, `${CLI}-dup`); + // A non-plugin executable must be ignored. + writeExecutable(pathDir, "unrelated-tool"); + process.chdir(project); + process.env.PATH = pathDir; + + const plugins = listPlugins(CLI); + const byName = Object.fromEntries(plugins.map((p) => [p.name, p])); + + expect(Object.keys(byName).toSorted()).toEqual(["alpha", "beta", "dup"]); + expect(byName.alpha?.source).toBe("node_modules"); + expect(byName.beta?.source).toBe("path"); + expect(byName.dup?.source).toBe("node_modules"); + }); +}); + +/** + * Write an executable node plugin that dumps its env and forwarded argv to + * `outFile` as JSON, then exits with `exitCode`. + * @param dir - Directory to create the plugin in + * @param name - Plugin file name + * @param outFile - Path the plugin writes its captured context to + * @param exitCode - Exit code the plugin terminates with + * @returns Absolute path to the created plugin + */ +function writeCapturePlugin(dir: string, name: string, outFile: string, exitCode = 0): string { + fs.mkdirSync(dir, { recursive: true }); + const jsBody = `const fs = require("node:fs"); +fs.writeFileSync(${JSON.stringify(outFile)}, JSON.stringify({ env: process.env, argv: process.argv.slice(2) })); +process.exit(${exitCode}); +`; + if (isWindows) { + // Windows can't execute a shebang script directly, so ship a `.cmd` wrapper + // that runs Node (by absolute path, so PATH need not contain node) on a + // companion `.js` file. This exercises the `.cmd` dispatch branch. + const jsPath = path.join(dir, `${name}.js`); + fs.writeFileSync(jsPath, jsBody); + const cmdPath = path.join(dir, `${name}.cmd`); + fs.writeFileSync(cmdPath, `@"${process.execPath}" "${jsPath}" %*\r\n`); + return cmdPath; + } + const full = path.join(dir, name); + fs.writeFileSync(full, `#!${process.execPath}\n${jsBody}`); + fs.chmodSync(full, 0o755); + return full; +} + +// Context vars that may be present in the ambient environment; cleared so the +// dispatched child only sees what buildPluginEnv injects. +const CONTEXT_ENV_KEYS = [ + "TAILOR_PLATFORM_TOKEN", + "TAILOR_PLATFORM_WORKSPACE_ID", + "TAILOR_PLATFORM_USER", + "TAILOR_CONFIG_PATH", + "TAILOR_PLATFORM_PROFILE", +] as const; + +describe("dispatchPlugin", () => { + let tempDir: string; + let originalCwd: string; + let originalPath: string | undefined; + let originalContextEnv: Record; + let outFile: string; + + beforeEach(() => { + tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tailor-dispatch-"))); + originalCwd = process.cwd(); + originalPath = process.env.PATH; + originalContextEnv = Object.fromEntries(CONTEXT_ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of CONTEXT_ENV_KEYS) delete process.env[k]; + outFile = path.join(tempDir, "capture.json"); + + contextMocks.loadAccessToken.mockResolvedValue("tok-123"); + contextMocks.loadWorkspaceId.mockResolvedValue("ws-456"); + contextMocks.loadConfigPath.mockReturnValue("/proj/tailor.config.ts"); + contextMocks.loadPlatformClientConfig.mockResolvedValue(undefined); + contextMocks.readPlatformConfig.mockResolvedValue({ + users: { u1: { email: "me@example.com" } }, + profiles: {}, + current_user: "u1", + }); + }); + + afterEach(() => { + process.chdir(originalCwd); + process.env.PATH = originalPath; + for (const [k, v] of Object.entries(originalContextEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + vi.clearAllMocks(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + /** + * Read the JSON context captured by the dispatched plugin. + * @returns The plugin's env and forwarded argv + */ + function readCapture(): { env: Record; argv: string[] } { + return JSON.parse(fs.readFileSync(outFile, "utf-8")); + } + + test("forwards args and injects the resolved platform context", async () => { + const project = path.join(tempDir, "project"); + writeCapturePlugin(path.join(project, "node_modules", ".bin"), `${CLI}-hello`, outFile); + process.chdir(project); + process.env.PATH = ""; + + const code = await dispatchPlugin({ + name: "hello", + args: ["world", "--loud"], + cliName: CLI, + }); + + expect(code).toBe(0); + const { env, argv } = readCapture(); + expect(argv).toEqual(["world", "--loud"]); + expect(env.TAILOR_PLATFORM_URL).toBe(getPlatformBaseUrl()); + expect(env.TAILOR_PLATFORM_OAUTH2_CLIENT_ID).toBe(getOAuth2ClientId()); + expect(env.TAILOR_PLATFORM_TOKEN).toBe("tok-123"); + expect(env.TAILOR_PLATFORM_WORKSPACE_ID).toBe("ws-456"); + expect(env.TAILOR_PLATFORM_USER).toBe("me@example.com"); + expect(env.TAILOR_CONFIG_PATH).toBe("/proj/tailor.config.ts"); + expect(env.TAILOR_BIN).toBeTruthy(); + expect(env.TAILOR_VERSION).toBeTruthy(); + }); + + test("injects the active profile's platform URL and OAuth client", async () => { + contextMocks.loadPlatformClientConfig.mockResolvedValue({ + platformUrl: "https://api.staging.example.com", + oauth2ClientId: "cpoc_staging", + }); + const project = path.join(tempDir, "project"); + writeCapturePlugin(path.join(project, "node_modules", ".bin"), `${CLI}-hello`, outFile); + process.chdir(project); + process.env.PATH = ""; + + const code = await dispatchPlugin({ + name: "hello", + args: [], + cliName: CLI, + profile: "staging", + }); + + expect(code).toBe(0); + const { env } = readCapture(); + expect(env.TAILOR_PLATFORM_URL).toBe("https://api.staging.example.com"); + expect(env.TAILOR_PLATFORM_OAUTH2_CLIENT_ID).toBe("cpoc_staging"); + }); + + test("omits best-effort context when it cannot be resolved but still dispatches", async () => { + contextMocks.loadAccessToken.mockRejectedValue(new Error("not logged in")); + contextMocks.loadWorkspaceId.mockRejectedValue(new Error("no workspace")); + contextMocks.readPlatformConfig.mockResolvedValue({ + users: {}, + profiles: {}, + current_user: null, + }); + + const project = path.join(tempDir, "project"); + writeCapturePlugin(path.join(project, "node_modules", ".bin"), `${CLI}-hello`, outFile); + process.chdir(project); + process.env.PATH = ""; + + const code = await dispatchPlugin({ name: "hello", args: [], cliName: CLI }); + + expect(code).toBe(0); + const { env } = readCapture(); + expect(env.TAILOR_PLATFORM_TOKEN).toBeUndefined(); + expect(env.TAILOR_PLATFORM_WORKSPACE_ID).toBeUndefined(); + expect(env.TAILOR_PLATFORM_USER).toBeUndefined(); + expect(env.TAILOR_PLATFORM_URL).toBe(getPlatformBaseUrl()); + }); + + test("builds the plugin slug from the command path for nested dispatch", async () => { + const project = path.join(tempDir, "project"); + writeCapturePlugin(path.join(project, "node_modules", ".bin"), `${CLI}-tailordb-erd`, outFile); + process.chdir(project); + process.env.PATH = ""; + + const code = await dispatchPlugin({ + commandPath: ["tailordb"], + name: "erd", + args: ["export"], + cliName: CLI, + }); + + expect(code).toBe(0); + expect(readCapture().argv).toEqual(["export"]); + }); + + test("propagates a non-zero exit code", async () => { + const project = path.join(tempDir, "project"); + writeCapturePlugin(path.join(project, "node_modules", ".bin"), `${CLI}-boom`, outFile, 3); + process.chdir(project); + process.env.PATH = ""; + + const code = await dispatchPlugin({ name: "boom", args: [], cliName: CLI }); + expect(code).toBe(3); + }); + + test("returns undefined when no matching plugin exists", async () => { + const project = path.join(tempDir, "project"); + fs.mkdirSync(project, { recursive: true }); + process.chdir(project); + process.env.PATH = ""; + + expect(await dispatchPlugin({ name: "missing", args: [], cliName: CLI })).toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/cli/shared/plugin.ts b/packages/sdk/src/cli/shared/plugin.ts new file mode 100644 index 0000000000..a0f40929bd --- /dev/null +++ b/packages/sdk/src/cli/shared/plugin.ts @@ -0,0 +1,389 @@ +import { spawn } from "node:child_process"; +import { accessSync, constants, readdirSync } from "node:fs"; +import * as os from "node:os"; +import * as path from "pathe"; +import { getOAuth2ClientId, getPlatformBaseUrl, type PlatformClientConfig } from "./client"; +import { + loadAccessToken, + loadConfigPath, + loadPlatformClientConfig, + loadWorkspaceId, + readPlatformConfig, +} from "./context"; +import { logger } from "./logger"; +import { readPackageJson } from "./package-json"; + +/** + * A plugin discovered on the filesystem. `tailor ` dispatches to the + * external `tailor-` executable. + */ +export interface DiscoveredPlugin { + /** Plugin name (the part after the `-` prefix). */ + name: string; + /** Absolute path to the executable. */ + path: string; + /** Where the executable was found. */ + source: "node_modules" | "path"; +} + +/** PATH entry separator (`;` on Windows, `:` elsewhere). */ +const pathDelimiter = process.platform === "win32" ? ";" : ":"; + +/** + * Yield `start` and each ancestor directory up to the filesystem root. + * @param start - Directory to start from + * @yields Each ancestor directory, starting with `start` + */ +function* ancestorDirs(start: string): Generator { + let dir = path.resolve(start); + // Walk up until `dirname` stops changing (the filesystem root), yielding the + // root on the final iteration. + let parent = path.dirname(dir); + while (parent !== dir) { + yield dir; + dir = parent; + parent = path.dirname(dir); + } + yield dir; +} + +/** Fallback executable extensions when `PATHEXT` is unset (Windows). */ +const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD;.PS1"; + +/** + * Lowercased executable extensions from `PATHEXT` (Windows). + * @returns Recognized executable extensions (e.g. `.exe`, `.cmd`) + */ +function windowsExecutableExts(): string[] { + return (process.env.PATHEXT ?? DEFAULT_PATHEXT) + .split(";") + .map((e) => e.trim().toLowerCase()) + .filter(Boolean); +} + +/** + * Check whether a path exists and is runnable: X_OK on POSIX; on Windows, the + * file must exist and either be extension-less (shebang script) or carry a + * `PATHEXT` extension, so plain data files are not mistaken for plugins. + * @param filePath - Path to check + * @returns Whether the path is an executable file + */ +function isExecutable(filePath: string): boolean { + try { + accessSync(filePath, process.platform === "win32" ? constants.F_OK : constants.X_OK); + } catch { + return false; + } + if (process.platform === "win32") { + const ext = path.extname(filePath).toLowerCase(); + return ext === "" || windowsExecutableExts().includes(ext); + } + return true; +} + +/** + * Candidate file names for a plugin binary, accounting for Windows extensions. + * @param binName - Base binary name (e.g. `tailor-foo`) + * @returns Ordered candidate file names + */ +function binaryCandidates(binName: string): string[] { + if (process.platform !== "win32") { + return [binName]; + } + // Bare name first (shebang scripts), then PATHEXT variants. + return [binName, ...windowsExecutableExts().map((ext) => `${binName}${ext}`)]; +} + +/** + * Directories on the user's PATH. + * @returns PATH entries + */ +function pathDirs(): string[] { + return (process.env.PATH ?? "").split(pathDelimiter).filter(Boolean); +} + +/** + * Nearest `node_modules/.bin` directories, walking up from the current working + * directory. Project-local bins take precedence over those higher in the tree. + * @returns Ordered `node_modules/.bin` directories + */ +function nodeModulesBinDirs(): string[] { + const dirs: string[] = []; + for (const dir of ancestorDirs(process.cwd())) { + dirs.push(path.join(dir, "node_modules", ".bin")); + } + return dirs; +} + +/** + * Find the first existing executable for the given base name across a set of + * directories. + * @param dirs - Directories to search + * @param binName - Base binary name + * @returns Absolute path, or undefined when not found + */ +function findExecutableIn(dirs: string[], binName: string): string | undefined { + const candidates = binaryCandidates(binName); + for (const dir of dirs) { + for (const candidate of candidates) { + const full = path.join(dir, candidate); + if (isExecutable(full)) return full; + } + } + return undefined; +} + +/** + * Resolve a plugin executable by name. + * Search order: project-local `node_modules/.bin` (nearest first), then PATH. + * @param name - Plugin name (without the `-` prefix) + * @param cliName - The host CLI name (e.g. `tailor`) + * @returns The discovered plugin, or null when not found + */ +export function resolvePlugin(name: string, cliName: string): DiscoveredPlugin | null { + // Reject names that could escape the `-` prefix via path traversal + // (e.g. `../evil`) or embed a NUL, before joining them into a filesystem path. + if (name.includes("/") || name.includes("\\") || name.includes("\0")) { + return null; + } + const binName = `${cliName}-${name}`; + + const fromNodeModules = findExecutableIn(nodeModulesBinDirs(), binName); + if (fromNodeModules) { + return { name, path: fromNodeModules, source: "node_modules" }; + } + + const fromPath = findExecutableIn(pathDirs(), binName); + if (fromPath) { + return { name, path: fromPath, source: "path" }; + } + + return null; +} + +/** + * Strip a known binary extension from a file name (Windows). + * @param fileName - File name + * @returns File name without a recognized executable extension + */ +function stripBinExt(fileName: string): string { + if (process.platform !== "win32") return fileName; + const ext = path.extname(fileName); + return ext ? fileName.slice(0, -ext.length) : fileName; +} + +/** + * Discover all plugins reachable from `node_modules/.bin` and PATH. + * Earlier entries win on name collisions (project-local over PATH). + * @param cliName - The host CLI name (e.g. `tailor`) + * @returns Discovered plugins, deduped by name + */ +export function listPlugins(cliName: string): DiscoveredPlugin[] { + const prefix = `${cliName}-`; + const seen = new Map(); + + const scan = (dirs: string[], source: DiscoveredPlugin["source"]) => { + for (const dir of dirs) { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + const base = stripBinExt(entry); + if (!base.startsWith(prefix) || base.length === prefix.length) continue; + const name = base.slice(prefix.length); + if (seen.has(name)) continue; + const full = path.join(dir, entry); + if (!isExecutable(full)) continue; + seen.set(name, { name, path: full, source }); + } + } + }; + + scan(nodeModulesBinDirs(), "node_modules"); + scan(pathDirs(), "path"); + + return [...seen.values()]; +} + +/** + * Options for {@link buildPluginEnv}. + */ +interface PluginContextOptions { + /** Active profile used to resolve the workspace, user, and token. */ + profile?: string | undefined; +} + +/** + * Resolve the active user identifier, preferring the stored email over the + * internal user key. + * @param profile - Active profile name, if any + * @returns The resolved user (email when known), or undefined when not logged in + */ +async function resolveActiveUser(profile?: string): Promise { + const config = await readPlatformConfig(); + const user = profile ? config.profiles[profile]?.user : config.current_user; + if (!user) return undefined; + return config.users[user]?.email ?? user; +} + +/** + * Build the environment variables injected into a dispatched plugin. + * Workspace, user, and token are resolved best-effort: whichever the current + * context can provide is injected, and the rest are omitted so auth-free + * plugins still run. + * @param options - Plugin context options + * @returns Environment variables to merge into the child process env + */ +async function buildPluginEnv(options: PluginContextOptions = {}): Promise> { + const { profile } = options; + // Resolve the active profile's platform settings so the injected URL and + // OAuth client match the profile the token and workspace belong to. + let platformConfig: PlatformClientConfig | undefined; + try { + platformConfig = await loadPlatformClientConfig({ profile, allowMissingProfile: true }); + } catch { + // Fall back to env/default platform settings when the profile is unreadable. + } + const env: Record = { + TAILOR_PLATFORM_URL: getPlatformBaseUrl(platformConfig), + TAILOR_PLATFORM_OAUTH2_CLIENT_ID: getOAuth2ClientId(platformConfig), + }; + + const binPath = process.argv[1]; + if (binPath) { + env.TAILOR_BIN = binPath; + } + + try { + const { version } = await readPackageJson(); + if (version) env.TAILOR_VERSION = version; + } catch { + // Version is informational; skip when unavailable. + } + + const configPath = loadConfigPath(); + if (configPath) { + env.TAILOR_CONFIG_PATH = configPath; + } + + try { + env.TAILOR_PLATFORM_WORKSPACE_ID = await loadWorkspaceId({ profile }); + } catch { + // No workspace resolvable; plugins that need it surface their own error. + } + + try { + const user = await resolveActiveUser(profile); + if (user) env.TAILOR_PLATFORM_USER = user; + } catch { + // User context is best-effort; skip when the config is unreadable. + } + + try { + const token = await loadAccessToken({ profile }); + if (token) env.TAILOR_PLATFORM_TOKEN = token; + } catch { + // Not logged in (or no token): dispatch without a token so auth-free + // plugins still work. Auth-requiring plugins surface their own error + // (e.g. via `tailor auth token`). + } + + return env; +} + +/** + * Map an exit signal to a conventional exit code (128 + signal number). + * @param signal - Signal name + * @returns Exit code + */ +function signalToExitCode(signal: NodeJS.Signals): number { + const num = os.constants.signals[signal]; + return typeof num === "number" ? 128 + num : 1; +} + +/** + * Build the command + args for spawning a plugin, handling Windows shebang + * scripts via `sh` (following the `gh` extension dispatcher). + * @param pluginPath - Resolved plugin executable path + * @param args - Args to forward to the plugin + * @returns Spawn command, args, and whether a shell is required + */ +function buildSpawnTarget( + pluginPath: string, + args: readonly string[], +): { command: string; args: string[]; shell: boolean } { + if (process.platform !== "win32") { + return { command: pluginPath, args: [...args], shell: false }; + } + + const ext = path.extname(pluginPath).toLowerCase(); + if (ext === ".exe" || ext === ".com") { + return { command: pluginPath, args: [...args], shell: false }; + } + if (ext === ".cmd" || ext === ".bat") { + // .cmd/.bat must run through the shell on Windows. + return { command: pluginPath, args: [...args], shell: true }; + } + if (ext === ".ps1") { + // PowerShell scripts are not directly executable via spawn. + const pwsh = + findExecutableIn(pathDirs(), "pwsh") ?? + findExecutableIn(pathDirs(), "powershell") ?? + "powershell"; + return { + command: pwsh, + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", pluginPath, ...args], + shell: false, + }; + } + + // Extension-less shebang script: dispatch through `sh` (Git for Windows). + const sh = findExecutableIn(pathDirs(), "sh") ?? "sh"; + return { command: sh, args: ["-c", 'command "$@"', "--", pluginPath, ...args], shell: false }; +} + +/** + * Resolve and execute a plugin, forwarding stdio and propagating its exit code. + * @param params - Dispatch parameters + * @param params.name - Plugin name (without the `-` prefix) + * @param params.args - Args to forward to the plugin + * @param params.cliName - The host CLI name + * @param params.profile - Active profile name, if any + * @returns The plugin's exit code, or undefined when no matching plugin exists + */ +export async function dispatchPlugin(params: { + name: string; + args: readonly string[]; + cliName: string; + commandPath?: readonly string[] | undefined; + profile?: string | undefined; +}): Promise { + // Build the plugin slug from the known command path plus the unknown name, + // so `tailor tailordb erd` resolves `tailor-tailordb-erd`. + const slug = [...(params.commandPath ?? []), params.name].join("-"); + const plugin = resolvePlugin(slug, params.cliName); + if (!plugin) { + return undefined; + } + + const env = { ...process.env, ...(await buildPluginEnv({ profile: params.profile })) }; + const { command, args, shell } = buildSpawnTarget(plugin.path, params.args); + + return await new Promise((resolve) => { + const child = spawn(command, args, { stdio: "inherit", env, shell }); + child.on("error", (error) => { + logger.error(`Failed to run plugin "${params.cliName}-${slug}": ${error.message}`); + resolve(1); + }); + child.on("close", (code, signal) => { + if (signal) { + resolve(signalToExitCode(signal)); + } else { + resolve(code ?? 0); + } + }); + }); +} diff --git a/packages/sdk/src/cli/shared/readonly-guard.test.ts b/packages/sdk/src/cli/shared/readonly-guard.test.ts index 307776357d..8204f8d24c 100644 --- a/packages/sdk/src/cli/shared/readonly-guard.test.ts +++ b/packages/sdk/src/cli/shared/readonly-guard.test.ts @@ -37,6 +37,9 @@ const READ_OR_LOCAL_COMMAND_PATHS = new Set([ // (including Create*/Update*/Delete*) and must guard. "api/inspect.ts", "api/list.ts", + // Auth token retrieval (may refresh via the OAuth server and persist tokens + // locally, but mutates no workspace/platform state) + "auth/token.ts", // Auth connections (read-only) "authconnection/index.ts", "authconnection/list.ts", @@ -80,6 +83,9 @@ const READ_OR_LOCAL_COMMAND_PATHS = new Set([ "organization/folder/index.ts", "organization/folder/get.ts", "organization/folder/list.ts", + // Plugin discovery (local filesystem listing only) + "plugin/index.ts", + "plugin/list.ts", // Profile management (local config only, never platform state) "profile/index.ts", "profile/create.ts", diff --git a/packages/sdk/src/cli/shared/readonly-guard.ts b/packages/sdk/src/cli/shared/readonly-guard.ts index dfabff37ed..1ba3242feb 100644 --- a/packages/sdk/src/cli/shared/readonly-guard.ts +++ b/packages/sdk/src/cli/shared/readonly-guard.ts @@ -38,6 +38,6 @@ export async function assertWritable(opts?: AssertWritableOptions): Promise ({ + registerHooks: vi.fn(), +})); + +vi.mock("node:module", () => nodeModuleMock); + +import { registerTsHook } from "./register-ts-hook"; + +describe("registerTsHook", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + test("skips hook registration on Bun (native TypeScript runtime)", async () => { + vi.stubGlobal("Bun", {}); + await registerTsHook(new URL("file:///ts-hook.mjs")); + expect(nodeModuleMock.registerHooks).not.toHaveBeenCalled(); + }); + + test("skips hook registration on Deno (native TypeScript runtime)", async () => { + vi.stubGlobal("Deno", {}); + await registerTsHook(new URL("file:///ts-hook.mjs")); + expect(nodeModuleMock.registerHooks).not.toHaveBeenCalled(); + }); + + test("calls module.registerHooks() with resolve/load on Node.js", async () => { + const tsHookUrl = new URL("../ts-hook.mjs", import.meta.url); + await registerTsHook(tsHookUrl); + expect(nodeModuleMock.registerHooks).toHaveBeenCalledWith({ + resolve: expect.any(Function), + load: expect.any(Function), + }); + }); +}); diff --git a/packages/sdk/src/cli/shared/register-ts-hook.ts b/packages/sdk/src/cli/shared/register-ts-hook.ts new file mode 100644 index 0000000000..0c7bd636f9 --- /dev/null +++ b/packages/sdk/src/cli/shared/register-ts-hook.ts @@ -0,0 +1,11 @@ +import * as mod from "node:module"; +import { isNativeTypeScriptRuntime } from "./runtime"; + +export async function registerTsHook(tsHookUrl: URL): Promise { + if (isNativeTypeScriptRuntime()) return; + const { resolveSync, loadSync } = (await import(tsHookUrl.href)) as { + resolveSync: Parameters[0]["resolve"]; + loadSync: Parameters[0]["load"]; + }; + mod.registerHooks({ resolve: resolveSync, load: loadSync }); +} diff --git a/packages/sdk/src/cli/shared/runtime-exprs.test.ts b/packages/sdk/src/cli/shared/runtime-exprs.test.ts index 6a8e0ce488..97a39eee65 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.test.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.test.ts @@ -1,5 +1,33 @@ import { describe, test, expect } from "vitest"; -import { buildExecutorArgsExpr, buildResolverOperationHookExpr } from "./runtime-exprs"; +import { + INVOKER_EXPR, + buildExecutorArgsExpr, + buildResolverOperationHookExpr, +} from "./runtime-exprs"; + +const runInvokerExpr = (invoker: unknown): unknown => + Function( + "tailor", + `return ${INVOKER_EXPR};`, + )({ + context: { getInvoker: () => invoker }, + }); + +const runExecutorArgsExpr = (args: Record): Record => + Function("args", `return ${buildExecutorArgsExpr("schedule", {})};`)(args) as Record< + string, + unknown + >; + +const runResolverOperationHookExpr = ( + user: unknown, + context = { pipeline: {}, args: {} }, +): Record => + Function( + "context", + "user", + `return ${buildResolverOperationHookExpr({})}`, + )(context, user) as Record; describe("buildExecutorArgsExpr", () => { const env = { API_URL: "https://example.com", DEBUG: true }; @@ -7,48 +35,104 @@ describe("buildExecutorArgsExpr", () => { describe("event triggers (with actor)", () => { const eventTriggerKinds = ["schedule", "tailordb", "idpUser", "authAccessToken"] as const; - test.each(eventTriggerKinds)("%s includes appNamespace, actor transform, and env", (kind) => { - const expr = buildExecutorArgsExpr(kind, env); - expect(expr).toContain("...args"); - expect(expr).toContain("appNamespace: args.namespaceName"); - expect(expr).toContain("actor: args.actor"); - expect(expr).toContain("attributeMap"); - expect(expr).toContain("attributeList"); - expect(expr).toContain(`env: ${JSON.stringify(env)}`); - }); - - test.each(["tailordb", "idpUser", "authAccessToken"] as const)( - "%s trigger injects kind and rawKind from args.eventType", - (kind) => { + for (const kind of eventTriggerKinds) { + test(`${kind} includes appNamespace, actor transform, and env`, () => { + const expr = buildExecutorArgsExpr(kind, env); + expect(expr).toContain("...args"); + expect(expr).toContain("appNamespace: args.namespaceName"); + expect(expr).toContain("actor: (($raw)"); + expect(expr).toContain("})(args.actor)"); + expect(expr).toContain("userType"); + expect(expr).toContain("attributeMap"); + expect(expr).toContain("attributeList"); + expect(expr).toContain(`env: ${JSON.stringify(env)}`); + }); + } + + test("event triggers inject kind and rawKind from args.eventType", () => { + const eventKinds = ["tailordb", "idpUser", "authAccessToken"] as const; + for (const kind of eventKinds) { const expr = buildExecutorArgsExpr(kind, env); expect(expr).toContain('event: args.eventType?.split(".").pop()'); expect(expr).toContain("rawEvent: args.eventType"); - }, - ); + } + }); test("schedule trigger does not inject event", () => { const expr = buildExecutorArgsExpr("schedule", env); expect(expr).not.toContain("event:"); }); + + test("maps actor payloads to TailorPrincipal shape", () => { + expect( + runExecutorArgsExpr({ + namespaceName: "app", + actor: { + userType: "USER_TYPE_USER", + userId: "11111111-1111-4111-8111-111111111111", + workspaceId: "workspace-1", + attributeMap: { role: "admin" }, + attributes: ["role"], + }, + }).actor, + ).toEqual({ + id: "11111111-1111-4111-8111-111111111111", + type: "user", + workspaceId: "workspace-1", + attributes: { role: "admin" }, + attributeList: ["role"], + }); + }); + + test("maps absent actor payloads to null", () => { + const nilUuid = "00000000-0000-0000-0000-000000000000"; + const actors = [ + null, + undefined, + { userType: "USER_TYPE_UNSPECIFIED", userId: "11111111-1111-4111-8111-111111111111" }, + { userType: "USER_TYPE_USER", userId: nilUuid }, + { userType: "USER_TYPE_USER" }, + ]; + for (const actor of actors) { + expect(runExecutorArgsExpr({ namespaceName: "app", actor }).actor).toBeNull(); + } + }); }); describe("resolverExecuted trigger", () => { - test("includes success/result/error transformations, actor, appNamespace, and env", () => { + test("includes success/result/error transformations", () => { const expr = buildExecutorArgsExpr("resolverExecuted", env); expect(expr).toContain("success: !!args.succeeded"); expect(expr).toContain("result: args.succeeded?.result.resolver"); expect(expr).toContain("error: args.failed?.error"); - expect(expr).toContain("actor: args.actor"); + }); + + test("includes actor transform and appNamespace", () => { + const expr = buildExecutorArgsExpr("resolverExecuted", env); + expect(expr).toContain("actor: (($raw)"); + expect(expr).toContain("})(args.actor)"); expect(expr).toContain("appNamespace: args.namespaceName"); + }); + + test("includes env", () => { + const expr = buildExecutorArgsExpr("resolverExecuted", env); expect(expr).toContain(`env: ${JSON.stringify(env)}`); }); }); describe("incomingWebhook trigger", () => { - test("includes rawBody mapping, appNamespace, and env, but not actor transform", () => { + test("includes rawBody mapping", () => { const expr = buildExecutorArgsExpr("incomingWebhook", env); expect(expr).toContain("rawBody: args.raw_body"); + }); + + test("does NOT include actor transform", () => { + const expr = buildExecutorArgsExpr("incomingWebhook", env); expect(expr).not.toContain("actor:"); + }); + + test("includes appNamespace and env", () => { + const expr = buildExecutorArgsExpr("incomingWebhook", env); expect(expr).toContain("appNamespace: args.namespaceName"); expect(expr).toContain(`env: ${JSON.stringify(env)}`); }); @@ -80,12 +164,12 @@ describe("buildResolverOperationHookExpr", () => { expect(expr).toContain("input: context.args"); }); - test("includes user transformation via tailorUserMap", () => { + test("includes caller transformation via tailorPrincipalMap", () => { const expr = buildResolverOperationHookExpr(env); - expect(expr).toContain("user:"); - expect(expr).toContain("user.workspace_id"); - expect(expr).toContain("user.attribute_map"); - expect(expr).toContain("user.attributes"); + expect(expr).toContain("caller:"); + expect(expr).toContain("workspace_id"); + expect(expr).toContain("attribute_map"); + expect(expr).toContain("attributes"); }); test("includes env injection", () => { @@ -102,4 +186,59 @@ describe("buildResolverOperationHookExpr", () => { const expr = buildResolverOperationHookExpr({}); expect(expr).toContain("env: {}"); }); + + test("maps caller payloads to TailorPrincipal shape", () => { + expect( + runResolverOperationHookExpr({ + type: "USER_TYPE_MACHINE_USER", + id: "machine-1", + workspace_id: "workspace-1", + attribute_map: { team: "ops" }, + attributes: ["team"], + }).caller, + ).toEqual({ + id: "machine-1", + type: "machine_user", + workspaceId: "workspace-1", + attributes: { team: "ops" }, + attributeList: ["team"], + }); + }); + + test("maps absent caller payloads to null", () => { + const nilUuid = "00000000-0000-0000-0000-000000000000"; + const users = [ + null, + undefined, + { type: "USER_TYPE_UNSPECIFIED", id: "11111111-1111-4111-8111-111111111111" }, + { type: "USER_TYPE_USER", id: nilUuid }, + ]; + for (const user of users) { + expect(runResolverOperationHookExpr(user).caller).toBeNull(); + } + }); +}); + +describe("INVOKER_EXPR", () => { + test("maps invoker payloads to TailorPrincipal shape", () => { + expect( + runInvokerExpr({ + id: "11111111-1111-4111-8111-111111111111", + type: "user", + workspaceId: "workspace-1", + attributeMap: { role: "member" }, + attributes: ["role"], + }), + ).toEqual({ + id: "11111111-1111-4111-8111-111111111111", + type: "user", + workspaceId: "workspace-1", + attributes: { role: "member" }, + attributeList: ["role"], + }); + }); + + test("maps anonymous invokers to null", () => { + expect(runInvokerExpr(null)).toBeNull(); + }); }); diff --git a/packages/sdk/src/cli/shared/runtime-exprs.ts b/packages/sdk/src/cli/shared/runtime-exprs.ts index 056c785660..ddfb648586 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.ts @@ -7,10 +7,13 @@ * - Bundle inline: interpolated into the generated `.entry.js` wrapper and * evaluated inside the bundled script at function entry. * - * The user field mapping (server → SDK) shared across services is defined in - * `@/parser/service/tailordb` as `tailorUserMap`. + * The principal field mapping (server → SDK) shared across services is built by + * `makePrincipalExpr` from `@/parser/service/tailordb`; `tailorPrincipalMap` + * (the `caller` mapping) is one of its outputs. `INVOKER_EXPR` and + * `ACTOR_TRANSFORM_EXPR` below come from the same factory so the three stay in + * sync. */ -import { tailorUserMap } from "#/parser/service/tailordb/index"; +import { makePrincipalExpr, tailorPrincipalMap } from "#/parser/service/tailordb/index"; import type { Trigger } from "#/types/executor.generated"; // --------------------------------------------------------------------------- @@ -21,15 +24,21 @@ import type { Trigger } from "#/types/executor.generated"; * `invoker` value expression, inlined into bundler entry wrappers. * * Calls `tailor.context.getInvoker()` at function entry and maps the server - * shape to TailorInvoker. Anonymous callers (`null`) pass through as `null`. + * shape to `TailorPrincipal | null`. The payload is already in SDK type shape, + * so no `USER_TYPE_*` normalization is needed; anonymous callers (`null`) pass + * through as `null`. */ -export const INVOKER_EXPR = `(($raw) => $raw ? ({ - id: $raw.id, - type: $raw.type, - workspaceId: $raw.workspaceId, - attributes: $raw.attributeMap, - attributeList: $raw.attributes, -}) : null)(tailor.context.getInvoker())`; +export const INVOKER_EXPR = makePrincipalExpr({ + source: "tailor.context.getInvoker()", + normalize: false, + fields: { + type: { raw: "$raw.type" }, + id: "$raw.id", + workspaceId: "$raw.workspaceId", + attributes: "$raw.attributeMap", + attributeList: "$raw.attributes", + }, +}); // --------------------------------------------------------------------------- // Executor @@ -38,15 +47,20 @@ export const INVOKER_EXPR = `(($raw) => $raw ? ({ /** * Actor field transformation expression. * - * Transforms the server's actor object to match the SDK's TailorActor type: - * server `attributeMap` → SDK `attributes` - * server `attributes` → SDK `attributeList` - * other fields → passed through - * null/undefined actor → null + * Transforms the server's actor object to match `TailorPrincipal | null`. */ -const ACTOR_TRANSFORM_EXPR = - `actor: args.actor ? (({ attributeMap, attributes: attrList, ...rest }) => ` + - `({ ...rest, attributes: attributeMap, attributeList: attrList }))(args.actor) : null`; +const ACTOR_TRANSFORM_EXPR = `actor: ${makePrincipalExpr({ + source: "args.actor", + normalize: true, + requireId: true, + fields: { + type: { raw: "$raw?.userType", fallback: "$raw?.type" }, + id: "$raw?.userId ?? $raw?.id", + workspaceId: "$raw.workspaceId", + attributes: "$raw.attributeMap ?? {}", + attributeList: "$raw.attributes ?? []", + }, +})}`; /** * Build the JavaScript expression that transforms server-format executor event @@ -92,7 +106,7 @@ export function buildExecutorArgsExpr( * Transforms server context to SDK resolver context: * context.args → input * context.pipeline → spread into result - * user (global var) → TailorUser (via tailorUserMap: workspace_id→workspaceId, attribute_map→attributes, attributes→attributeList) + * user (global var) → caller (`TailorPrincipal | null`) * env → injected as JSON * @param env - Application env record to embed in the expression * @returns A JavaScript expression string for the operationHook @@ -100,5 +114,5 @@ export function buildExecutorArgsExpr( export function buildResolverOperationHookExpr( env: Record, ): string { - return `({ ...context.pipeline, input: context.args, user: ${tailorUserMap}, env: ${JSON.stringify(env)} });`; + return `({ ...context.pipeline, input: context.args, caller: ${tailorPrincipalMap}, env: ${JSON.stringify(env)} });`; } diff --git a/packages/sdk/src/cli/shared/script-executor.test.ts b/packages/sdk/src/cli/shared/script-executor.test.ts index 8e9b3c7bd8..76048d96e1 100644 --- a/packages/sdk/src/cli/shared/script-executor.test.ts +++ b/packages/sdk/src/cli/shared/script-executor.test.ts @@ -1,6 +1,11 @@ import { FunctionExecution_Status } from "@tailor-platform/tailor-proto/function_resource_pb"; import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; -import { waitForExecution, executeScript, DEFAULT_POLL_INTERVAL } from "./script-executor"; +import { + waitForExecution, + executeScript, + DEFAULT_POLL_INTERVAL, + type ScriptExecutionOptions, +} from "./script-executor"; import type { OperatorClient } from "#/cli/shared/client"; import type { AuthInvoker } from "@tailor-platform/tailor-proto/auth_resource_pb"; @@ -165,7 +170,7 @@ describe("executeScript", () => { workspaceId: "workspace-1", name: "test-script.js", code: "export function main() { return { success: true }; }", - arg: '{"input":"value"}', + arg: { input: "value" }, invoker: mockAuthInvoker, }); @@ -197,6 +202,29 @@ describe("executeScript", () => { expect(client.testExecScript).toHaveBeenCalledWith(expect.objectContaining({ arg: "{}" })); }); + test("accepts options typed as the bare ScriptExecutionOptions", async () => { + const client = createMockClient({ + testExecScript: vi.fn().mockResolvedValue({ executionId: "exec-123" }), + getFunctionExecution: vi.fn().mockResolvedValue({ + execution: { status: FunctionExecution_Status.SUCCESS, logs: "", result: "" }, + }), + }); + + // Regression: a typed-out options object must stay assignable to + // executeScript's parameter (arg is Jsonifiable, usable on its own). + const options: ScriptExecutionOptions = { + client, + workspaceId: "workspace-1", + name: "test-script.js", + code: "code", + arg: { a: 1 }, + invoker: mockAuthInvoker, + }; + + const result = await executeScript(options); + expect(result.success).toBe(true); + }); + test("returns failure result when script fails", async () => { const client = createExecScriptMockClient( execution( diff --git a/packages/sdk/src/cli/shared/script-executor.ts b/packages/sdk/src/cli/shared/script-executor.ts index b15f0ff0f3..db1c60aabb 100644 --- a/packages/sdk/src/cli/shared/script-executor.ts +++ b/packages/sdk/src/cli/shared/script-executor.ts @@ -8,6 +8,7 @@ import { FunctionExecution_Status } from "@tailor-platform/tailor-proto/function_resource_pb"; import type { OperatorClient } from "#/cli/shared/client"; import type { AuthInvoker } from "@tailor-platform/tailor-proto/auth_resource_pb"; +import type { Jsonifiable } from "type-fest"; /** * Default polling interval for script execution status in milliseconds (1 second) @@ -17,7 +18,7 @@ export const DEFAULT_POLL_INTERVAL = 1000; /** * Options for script execution */ -export interface ScriptExecutionOptions { +export interface ScriptExecutionOptions { /** Operator client instance */ client: OperatorClient; /** Workspace ID */ @@ -26,8 +27,8 @@ export interface ScriptExecutionOptions { name: string; /** Bundled script code to execute */ code: string; - /** Optional JSON string argument to pass to the script */ - arg?: string; + /** Optional JSON-serializable argument to pass to the script */ + arg?: T; /** Auth invoker for script execution */ invoker: AuthInvoker; /** Polling interval in milliseconds (default: 1000ms) */ @@ -117,8 +118,8 @@ export async function waitForExecution( * @param {ScriptExecutionOptions} options - Execution options * @returns {Promise} Execution result */ -export async function executeScript( - options: ScriptExecutionOptions, +export async function executeScript( + options: ScriptExecutionOptions, ): Promise { const { client, workspaceId, name, code, arg, invoker, pollInterval } = options; @@ -127,7 +128,7 @@ export async function executeScript( workspaceId, name, code, - arg: arg ?? JSON.stringify({}), + arg: JSON.stringify(arg === undefined ? {} : arg), invoker, }); const executionId = response.executionId; diff --git a/packages/sdk/src/cli/shared/skills-installer.test.ts b/packages/sdk/src/cli/shared/skills-installer.test.ts index c5a45ebd0e..cf9a9c1e16 100644 --- a/packages/sdk/src/cli/shared/skills-installer.test.ts +++ b/packages/sdk/src/cli/shared/skills-installer.test.ts @@ -49,8 +49,8 @@ describe("skills-installer", () => { ]); }); - test("prefers TAILOR_SDK_SKILLS_SOURCE env var over the passed source", () => { - vi.stubEnv("TAILOR_SDK_SKILLS_SOURCE", "/override/skills"); + test("prefers TAILOR_SKILLS_SOURCE env var over the passed source", () => { + vi.stubEnv("TAILOR_SKILLS_SOURCE", "/override/skills"); try { expect(buildSkillsAddArgs({ source: TEST_SOURCE })[2]).toBe("/override/skills"); } finally { diff --git a/packages/sdk/src/cli/shared/skills-installer.ts b/packages/sdk/src/cli/shared/skills-installer.ts index 3effafb918..aaa262b9a5 100644 --- a/packages/sdk/src/cli/shared/skills-installer.ts +++ b/packages/sdk/src/cli/shared/skills-installer.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; -export const SKILL_NAME = "tailor-sdk"; -const SKILLS_SOURCE_ENV_KEY = "TAILOR_SDK_SKILLS_SOURCE"; +export const SKILL_NAME = "tailor"; +const SKILLS_SOURCE_ENV_KEY = "TAILOR_SKILLS_SOURCE"; interface ChildProcessLike { on(event: "close", listener: (code: number | null) => void): ChildProcessLike; @@ -36,7 +36,7 @@ function resolveSkillsSource(source: string): string { } /** - * Build CLI arguments for `skills add` with the fixed tailor-sdk skill target. + * Build CLI arguments for `skills add` with the fixed tailor skill target. * `--copy` is included so the installed skill survives `pnpm install` wiping `node_modules`. * @param options - Options controlling the generated `skills add` arguments * @param options.source - Skill source package or path passed to `skills add` @@ -59,7 +59,7 @@ export function buildSkillsAddArgs(options: BuildSkillsAddArgsOptions): string[] } /** - * Run `npx skills add` to install the tailor-sdk skill. + * Run `npx skills add` to install the tailor skill. * @param options - Runtime options for skill installation * @returns Process exit code from the spawned `npx` command */ diff --git a/packages/sdk/src/cli/shared/stack-trace.test.ts b/packages/sdk/src/cli/shared/stack-trace.test.ts index ad363e86e2..9db4004617 100644 --- a/packages/sdk/src/cli/shared/stack-trace.test.ts +++ b/packages/sdk/src/cli/shared/stack-trace.test.ts @@ -478,14 +478,14 @@ describe("formatMappedError", () => { }); test("prefixes dotfile-rooted paths with ./ so they are not mistaken for relative-path markers", () => { - // Regression: paths like `.tailor-sdk/test-run/...` start with `.` but + // Regression: paths like `.tailor/test-run/...` start with `.` but // are not `../` escapes. The display must prefix them with `./` so // users can tell they are cwd-relative. const frames: MappedStackFrame[] = [ { original: { functionName: "main", file: "file:///bundle.js", line: 1, column: 1 }, mapped: { - source: ".tailor-sdk/test-run/test-run--add.entry.js", + source: ".tailor/test-run/test-run--add.entry.js", line: 16, column: 13, name: null, @@ -496,7 +496,7 @@ describe("formatMappedError", () => { const result = formatMappedError("Error: test", frames, null, process.cwd()); const plain = stripVTControlCharacters(result); - expect(plain).toContain("./.tailor-sdk/test-run/test-run--add.entry.js:16:13"); + expect(plain).toContain("./.tailor/test-run/test-run--add.entry.js:16:13"); }); }); diff --git a/packages/sdk/src/cli/shared/stack-trace.ts b/packages/sdk/src/cli/shared/stack-trace.ts index a35d0115c4..2f05c7dad8 100644 --- a/packages/sdk/src/cli/shared/stack-trace.ts +++ b/packages/sdk/src/cli/shared/stack-trace.ts @@ -295,7 +295,7 @@ export function formatMappedError( const rel = path.relative(process.cwd(), absolutePath); // Only paths escaping cwd (starting with `..`) are shown as-is; all // other relative paths get an explicit `./` prefix so dotfiles like - // `.tailor-sdk/...` are not mistaken for relative-path markers. + // `.tailor/...` are not mistaken for relative-path markers. const displaySource = rel.startsWith("..") ? rel : `./${rel}`; const fnName = name ?? frame.original.functionName; const link = buildSourceLink(displaySource, absolutePath, line, column); diff --git a/packages/sdk/src/cli/shared/trigger-context.ts b/packages/sdk/src/cli/shared/trigger-context.ts index f0fce5d43f..12eb8e50b5 100644 --- a/packages/sdk/src/cli/shared/trigger-context.ts +++ b/packages/sdk/src/cli/shared/trigger-context.ts @@ -18,7 +18,7 @@ export interface TriggerContext { /** Maps file path (without extension) to workflow name for default exports */ workflowFileMap: Map; /** - * Auth service namespace used to expand a string-literal `authInvoker` + * Auth service namespace used to expand a string-literal `invoker` * (e.g. `"kiosk"`) to the `{ namespace, machineUserName }` form expected by * the runtime. Undefined when no Auth service is configured. */ @@ -40,7 +40,7 @@ export function normalizeFilePath(filePath: string): string { * Build trigger context from workflow configuration * Scans workflow files to collect workflow and job mappings * @param workflowConfig - Workflow file loading configuration - * @param authNamespace - Auth service namespace (optional, used for string-literal authInvoker expansion) + * @param authNamespace - Auth service namespace (optional, used for string-literal invoker expansion) * @returns Trigger context built from workflow sources */ export async function buildTriggerContext( diff --git a/packages/sdk/src/cli/shared/type-generator.test.ts b/packages/sdk/src/cli/shared/type-generator.test.ts index 45862967a9..0f12761b7a 100644 --- a/packages/sdk/src/cli/shared/type-generator.test.ts +++ b/packages/sdk/src/cli/shared/type-generator.test.ts @@ -1,13 +1,14 @@ import * as path from "pathe"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { defineAuth } from "#/configure/services/auth/index"; +import { db } from "#/configure/services/tailordb/schema"; import { t } from "#/configure/types/type"; import { extractAttributesFromConfig, generateTypeDefinition, resolveTypeDefinitionPath, } from "./type-generator"; -import type { AttributeListConfig, AttributeMapConfig } from "./type-generator"; +import type { AttributeListConfig, AttributesConfig } from "./type-generator"; describe("generateTypeDefinition", () => { test.each<{ @@ -21,14 +22,14 @@ describe("generateTypeDefinition", () => { expected: ["__tuple?: [string, string]"], }, { - name: "generates AttributeMap interface", + name: "generates Attributes interface", args: [{ role: '"MANAGER" | "STAFF"', isActive: "boolean" }, undefined], - expected: ["interface AttributeMap", 'role: "MANAGER" | "STAFF"', "isActive: boolean"], + expected: ["interface Attributes", 'role: "MANAGER" | "STAFF"', "isActive: boolean"], }, { - name: "generates empty AttributeMap when no attributes", + name: "generates empty Attributes when no attributes", args: [undefined, undefined], - expected: ["interface AttributeMap {}", "interface AttributeList", "__tuple?: []"], + expected: ["interface Attributes {}", "interface AttributeList", "__tuple?: []"], }, { name: "includes proper file header and structure", @@ -72,18 +73,75 @@ describe("generateTypeDefinition", () => { }); test("should generate interface AttributeList for declaration merging", () => { - const attributeMap: AttributeMapConfig = { + const attributes: AttributesConfig = { role: '"MANAGER" | "STAFF"', }; const attributeList: AttributeListConfig = []; - const result = generateTypeDefinition(attributeMap, attributeList); + const result = generateTypeDefinition(attributes, attributeList); expect(result).toContain("interface AttributeList"); expect(result).not.toContain("type AttributeList ="); expect(result).toContain("__tuple?: []"); }); + test("should generate Attributes interface", () => { + const attributes: AttributesConfig = { + role: '"MANAGER" | "STAFF"', + isActive: "boolean", + }; + + const result = generateTypeDefinition(attributes, undefined); + + expect(result).toContain("interface Attributes"); + expect(result).toContain('role: "MANAGER" | "STAFF"'); + expect(result).toContain("isActive: boolean"); + }); + + test("should generate empty Attributes when no attributes", () => { + const result = generateTypeDefinition(undefined, undefined); + + expect(result).toContain("interface Attributes {}"); + expect(result).toContain("interface AttributeList"); + expect(result).toContain("__tuple?: []"); + }); + + test("should include proper file header and structure", () => { + const result = generateTypeDefinition(undefined, undefined); + + expect(result).toContain("// This file is auto-generated"); + expect(result).toContain('declare module "@tailor-platform/sdk"'); + expect(result).toContain("export {};"); + }); + + test("should generate Env interface with literal types", () => { + const env = { + hoge: 1, + fuga: "hello", + piyo: true, + }; + + const result = generateTypeDefinition(undefined, undefined, env); + + expect(result).toContain("interface Env"); + expect(result).toContain("hoge: 1;"); + expect(result).toContain('fuga: "hello";'); + expect(result).toContain("piyo: true;"); + }); + + test("should generate empty Env interface when no env provided", () => { + const result = generateTypeDefinition(undefined, undefined); + + expect(result).toContain("interface Env {}"); + }); + + test("should generate empty MachineUserNameRegistry when no machine users provided", () => { + const result = generateTypeDefinition(undefined, undefined); + + expect(result).toContain("interface MachineUserNameRegistry {}"); + expect(result).not.toContain('declare module "@tailor-platform/sdk/cli"'); + }); + test("should generate MachineUserNameRegistry with machine user names", () => { const result = generateTypeDefinition(undefined, undefined, undefined, [ "manager-machine-user", @@ -91,6 +149,7 @@ describe("generateTypeDefinition", () => { ]); expect(result).toContain("interface MachineUserNameRegistry"); + expect(result).not.toContain('declare module "@tailor-platform/sdk/cli"'); // Names with hyphens are quoted expect(result).toContain('"manager-machine-user": true;'); // Valid identifiers are emitted unquoted (matches formatter output) @@ -139,17 +198,17 @@ describe("generateTypeDefinition", () => { }); describe("resolveTypeDefinitionPath", () => { - const originalEnv = process.env.TAILOR_PLATFORM_SDK_DTS_PATH; + const originalEnv = process.env.TAILOR_DTS_PATH; beforeEach(() => { - delete process.env.TAILOR_PLATFORM_SDK_DTS_PATH; + delete process.env.TAILOR_DTS_PATH; }); afterEach(() => { if (originalEnv !== undefined) { - process.env.TAILOR_PLATFORM_SDK_DTS_PATH = originalEnv; + process.env.TAILOR_DTS_PATH = originalEnv; } else { - delete process.env.TAILOR_PLATFORM_SDK_DTS_PATH; + delete process.env.TAILOR_DTS_PATH; } }); @@ -158,26 +217,28 @@ describe("resolveTypeDefinitionPath", () => { expect(result).toBe(path.resolve("/project", "tailor.d.ts")); }); - test("should use TAILOR_PLATFORM_SDK_DTS_PATH when set to an absolute path", () => { - process.env.TAILOR_PLATFORM_SDK_DTS_PATH = "/custom/output/types.d.ts"; + test("should use TAILOR_DTS_PATH when set to an absolute path", () => { + process.env.TAILOR_DTS_PATH = "/custom/output/types.d.ts"; const result = resolveTypeDefinitionPath("/project/tailor.config.ts"); expect(result).toBe("/custom/output/types.d.ts"); }); - test("should resolve TAILOR_PLATFORM_SDK_DTS_PATH relative to cwd when relative", () => { - process.env.TAILOR_PLATFORM_SDK_DTS_PATH = "custom/types.d.ts"; + test("should resolve TAILOR_DTS_PATH relative to cwd when relative", () => { + process.env.TAILOR_DTS_PATH = "custom/types.d.ts"; const result = resolveTypeDefinitionPath("/project/tailor.config.ts"); expect(result).toBe(path.resolve("custom/types.d.ts")); }); }); describe("extractAttributesFromConfig + generateTypeDefinition", () => { - test("renders machineUserAttributes into AttributeMap", () => { + test("renders machineUserAttributes into Attributes", () => { const config = { name: "test-app", auth: defineAuth("auth", { machineUserAttributes: { role: t.enum(["ADMIN", "WORKER"]), + externalId: t.uuid(), + balance: t.decimal(), isActive: t.bool(), tags: t.string({ array: true }), }, @@ -185,6 +246,8 @@ describe("extractAttributesFromConfig + generateTypeDefinition", () => { admin: { attributes: { role: "ADMIN", + externalId: "123e4567-e89b-12d3-a456-426614174000", + balance: "123.45", isActive: true, tags: ["root"], }, @@ -193,14 +256,51 @@ describe("extractAttributesFromConfig + generateTypeDefinition", () => { }), }; - const { attributeMap } = extractAttributesFromConfig(config); - const content = generateTypeDefinition(attributeMap, undefined); + const { attributes } = extractAttributesFromConfig(config); + const content = generateTypeDefinition(attributes, undefined); expect(content).toContain('role: "ADMIN" | "WORKER";'); + expect(content).toContain( + 'import type { DecimalString, UUIDString } from "@tailor-platform/sdk";', + ); + expect(content).toContain("externalId: UUIDString;"); + expect(content).toContain("balance: DecimalString;"); expect(content).toContain("isActive: boolean;"); expect(content).toContain("tags: string[];"); }); + test("preserves scalar types from userProfile attributes and attributeList", () => { + const userType = db.type("User", { + email: db.string().unique(), + externalId: db.uuid(), + balance: db.decimal(), + }); + const config = { + name: "test-app", + auth: defineAuth("auth", { + userProfile: { + type: userType, + usernameField: "email", + attributes: { + externalId: true, + balance: true, + }, + attributeList: ["externalId"] as ["externalId"], + }, + }), + }; + + const { attributes, attributeList } = extractAttributesFromConfig(config); + const content = generateTypeDefinition(attributes, attributeList); + + expect(content).toContain( + 'import type { DecimalString, UUIDString } from "@tailor-platform/sdk";', + ); + expect(content).toContain("externalId: UUIDString;"); + expect(content).toContain("balance: DecimalString;"); + expect(content).toContain("__tuple?: [UUIDString];"); + }); + test("extracts machine user names into MachineUserNameRegistry", () => { const config = { name: "test-app", @@ -215,10 +315,10 @@ describe("extractAttributesFromConfig + generateTypeDefinition", () => { }), }; - const { attributeMap, machineUserNames } = extractAttributesFromConfig(config); + const { attributes, machineUserNames } = extractAttributesFromConfig(config); expect(machineUserNames).toEqual(["admin", "worker"]); - const content = generateTypeDefinition(attributeMap, undefined, undefined, machineUserNames); + const content = generateTypeDefinition(attributes, undefined, undefined, machineUserNames); expect(content).toContain("interface MachineUserNameRegistry"); expect(content).toContain("admin: true;"); expect(content).toContain("worker: true;"); diff --git a/packages/sdk/src/cli/shared/type-generator.ts b/packages/sdk/src/cli/shared/type-generator.ts index 5f5dcd6ad3..907346bba2 100644 --- a/packages/sdk/src/cli/shared/type-generator.ts +++ b/packages/sdk/src/cli/shared/type-generator.ts @@ -4,14 +4,49 @@ import { logger } from "#/cli/shared/logger"; import ml from "#/utils/multiline"; import type { AppConfig } from "#/configure/config/types"; -export interface AttributeMapConfig { +export interface AttributesConfig { [key: string]: string; } export type AttributeListConfig = readonly string[]; +type ScalarTypeName = + | "DateString" + | "DateTimeString" + | "DecimalString" + | "TimeString" + | "UUIDString"; + +const scalarTypeNames: ScalarTypeName[] = [ + "DateString", + "DateTimeString", + "DecimalString", + "TimeString", + "UUIDString", +]; + +const knownAttributeListTypes = new Set([ + "boolean", + "number", + "string", + ...scalarTypeNames, +]); + +const fieldTypeScriptTypes: Record = { + boolean: "boolean", + bool: "boolean", + uuid: "UUIDString", + date: "DateString", + datetime: "DateTimeString | Date", + time: "TimeString", + decimal: "DecimalString", + integer: "number", + float: "number", + number: "number", +}; + interface ExtractedAttributes { - attributeMap?: AttributeMapConfig; + attributes?: AttributesConfig; attributeList?: AttributeListConfig; env?: Record; machineUserNames?: string[]; @@ -31,7 +66,7 @@ type AttributeFieldLike = { /** * Extract attribute definitions from the app config for user-defined typing. * @param config - Application config to inspect - * @returns Extracted attribute map/list and env values + * @returns Extracted attributes/list and env values * @internal */ export function extractAttributesFromConfig(config: AppConfig): ExtractedAttributes { @@ -40,17 +75,17 @@ export function extractAttributesFromConfig(config: AppConfig): ExtractedAttribu /** * Generate the contents of the user-defined type definition file. - * @param attributeMap - Attribute map configuration + * @param attributes - Attribute configuration * @param attributeList - Attribute list configuration * @param env - Environment configuration - * @param machineUserNames - Registered machine user names (used to narrow `authInvoker` strings) + * @param machineUserNames - Registered machine user names (used to narrow `invoker` strings) * @param idpNames - Registered IdP names (used to narrow `idpUser*Trigger({ idp })` strings) * @param connectionNames - Registered auth connection names (used to narrow `getConnectionToken()` strings) * @param aiGatewayNames - Registered AI Gateway names (used to narrow `aigateway.get()` strings) * @returns Generated type definition source */ export function generateTypeDefinition( - attributeMap: AttributeMapConfig | undefined, + attributes: AttributesConfig | undefined, attributeList: AttributeListConfig | undefined, env?: Record, machineUserNames?: readonly string[], @@ -58,23 +93,33 @@ export function generateTypeDefinition( connectionNames?: readonly string[], aiGatewayNames?: readonly string[], ): string { - // Generate AttributeMap interface - // attributeMap values are type string representations (e.g., "string", "boolean", "string[]") - const mapFields = attributeMap - ? Object.entries(attributeMap) + const attributeListTypes = attributeList?.map(normalizeAttributeListType); + const scalarTypeImports = collectScalarTypeImports([ + ...Object.values(attributes ?? {}), + ...(attributeListTypes ?? []), + ]); + const scalarTypeImportSource = + scalarTypeImports.length > 0 + ? `import type { ${scalarTypeImports.join(", ")} } from "@tailor-platform/sdk";\n\n` + : ""; + + // Generate Attributes interface + // attributes values are type string representations (e.g., "string", "boolean", "string[]") + const attributeFields = attributes + ? Object.entries(attributes) .map(([key, value]) => ` ${key}: ${value};`) .join("\n") : ""; - const mapBody = - !attributeMap || Object.keys(attributeMap).length === 0 + const attributesBody = + !attributes || Object.keys(attributes).length === 0 ? "{}" : `{ -${mapFields} +${attributeFields} }`; // Generate AttributeList type as a tuple of strings based on the length - const listType = attributeList ? `[${attributeList.map(() => "string").join(", ")}]` : "[]"; + const listType = attributeListTypes ? `[${attributeListTypes.join(", ")}]` : "[]"; // Use interface with __tuple marker for declaration merging and tuple type support const listBody = `{ @@ -157,13 +202,13 @@ ${connectionNameFields} ${aiGatewayNameFields} }`; - return ml /* ts */ ` + return `${scalarTypeImportSource}${ml /* ts */ ` // This file is auto-generated by @tailor-platform/sdk // Do not edit this file manually -// Regenerated automatically when running 'tailor-sdk deploy' or 'tailor-sdk generate' +// Regenerated automatically when running 'tailor deploy' or 'tailor generate' declare module "@tailor-platform/sdk" { - interface AttributeMap ${mapBody} + interface Attributes ${attributesBody} interface AttributeList ${listBody} interface Env ${envBody} interface MachineUserNameRegistry ${machineUserBody} @@ -174,7 +219,17 @@ declare module "@tailor-platform/sdk" { export {}; -`; +`}`; +} + +function collectScalarTypeImports(types: readonly string[]): ScalarTypeName[] { + return scalarTypeNames.filter((typeName) => + types.some((type) => new RegExp(`\\b${typeName}\\b`).test(type)), + ); +} + +function normalizeAttributeListType(typeOrKey: string): string { + return knownAttributeListTypes.has(typeOrKey) ? typeOrKey : "string"; } function collectAttributesFromConfig(config: AppConfig): ExtractedAttributes { @@ -208,23 +263,20 @@ function collectAttributesFromConfig(config: AppConfig): ExtractedAttributes { const type = field?.type; const metadata = field?.metadata; - // Default to string if no metadata - if (!metadata) { + if (!type && !metadata) { return "string"; } - let typeStr = "string"; - - if (type === "boolean") { - typeStr = "boolean"; - } else if (type === "enum" && metadata.allowedValues) { - // Generate union type from enum values - typeStr = metadata.allowedValues.map((v) => `"${v.value}"`).join(" | "); - } + const typeStr = + type === "enum" && metadata?.allowedValues + ? metadata.allowedValues.map((v) => `"${v.value}"`).join(" | ") + : type + ? (fieldTypeScriptTypes[type] ?? "string") + : "string"; // Add array suffix if needed - if (metadata.array) { - typeStr += "[]"; + if (metadata?.array) { + return typeStr.includes("|") ? `(${typeStr})[]` : `${typeStr}[]`; } return typeStr; @@ -244,20 +296,22 @@ function collectAttributesFromConfig(config: AppConfig): ExtractedAttributes { } ).userProfile; - const attributes = userProfile?.attributes; + const selectedAttributes = userProfile?.attributes; const fields = userProfile?.type?.fields; - const attributeList = userProfile?.attributeList; + const attributeList = userProfile?.attributeList?.map((key) => + inferAttributeType(fields?.[key]), + ); - // Convert attributes to AttributeMapConfig by inferring types from field metadata - const attributeMap: AttributeMapConfig | undefined = attributes - ? Object.keys(attributes).reduce((acc, key) => { + // Convert attributes to AttributesConfig by inferring types from field metadata + const attributes: AttributesConfig | undefined = selectedAttributes + ? Object.keys(selectedAttributes).reduce((acc, key) => { acc[key] = inferAttributeType(fields?.[key]); return acc; - }, {} as AttributeMapConfig) + }, {} as AttributesConfig) : undefined; return { - attributeMap, + attributes, attributeList, machineUserNames, idpNames, @@ -277,13 +331,13 @@ function collectAttributesFromConfig(config: AppConfig): ExtractedAttributes { return { machineUserNames, idpNames, connectionNames, aiGatewayNames }; } - const attributeMap = Object.entries(machineUserAttributes).reduce((acc, [key, field]) => { + const attributes = Object.entries(machineUserAttributes).reduce((acc, [key, field]) => { acc[key] = inferAttributeType(field); return acc; - }, {} as AttributeMapConfig); + }, {} as AttributesConfig); return { - attributeMap, + attributes, machineUserNames, idpNames, connectionNames, @@ -297,14 +351,14 @@ function collectAttributesFromConfig(config: AppConfig): ExtractedAttributes { /** * Resolve the output path for the generated type definition file. * - * When the `TAILOR_PLATFORM_SDK_DTS_PATH` environment variable is set, the value is + * When the `TAILOR_DTS_PATH` environment variable is set, the value is * used as the output path (resolved relative to cwd when relative). * Otherwise, the file is written next to the config file as `tailor.d.ts`. * @param configPath - Path to Tailor config file * @returns Absolute path to the type definition file */ export function resolveTypeDefinitionPath(configPath: string): string { - const envPath = process.env.TAILOR_PLATFORM_SDK_DTS_PATH; + const envPath = process.env.TAILOR_DTS_PATH; if (envPath) { return path.resolve(envPath); } @@ -330,19 +384,19 @@ export async function generateUserTypes(options: GenerateUserTypesOptions): Prom const { config, configPath } = options; try { const { - attributeMap, + attributes, attributeList, machineUserNames, idpNames, connectionNames, aiGatewayNames, } = extractAttributesFromConfig(config); - if (!attributeMap && !attributeList) { + if (!attributes && !attributeList) { logger.info("No attributes found in configuration", { mode: "plain" }); } - if (attributeMap) { - logger.debug(`Extracted AttributeMap: ${JSON.stringify(attributeMap)}`); + if (attributes) { + logger.debug(`Extracted Attributes: ${JSON.stringify(attributes)}`); } if (attributeList) { logger.debug(`Extracted AttributeList: ${JSON.stringify(attributeList)}`); @@ -367,7 +421,7 @@ export async function generateUserTypes(options: GenerateUserTypesOptions): Prom // Generate type definition const typeDefContent = generateTypeDefinition( - attributeMap, + attributes, attributeList, env, machineUserNames, diff --git a/packages/sdk/src/cli/shared/user-agent.ts b/packages/sdk/src/cli/shared/user-agent.ts index 38bfb8f788..f9836ed06a 100644 --- a/packages/sdk/src/cli/shared/user-agent.ts +++ b/packages/sdk/src/cli/shared/user-agent.ts @@ -8,7 +8,7 @@ import { readPackageJson } from "./package-json"; * @returns User-Agent header value */ export function userAgentFromVersion(version: string): string { - return `tailor-sdk/${version}`; + return `tailor/${version}`; } /** diff --git a/packages/sdk/src/cli/skills.ts b/packages/sdk/src/cli/skills.ts deleted file mode 100644 index bda77fc164..0000000000 --- a/packages/sdk/src/cli/skills.ts +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "pathe"; -import { logger } from "./shared/logger"; - -logger.warn( - "`tailor-sdk-skills` is deprecated and will be removed in v2. Use `tailor-sdk skills install` instead.", -); - -const here = dirname(fileURLToPath(import.meta.url)); -const mainCli = resolve(here, "index.mjs"); - -const child = spawn(process.execPath, [mainCli, "skills", "install", ...process.argv.slice(2)], { - stdio: "inherit", -}); - -child.on("exit", (code) => { - process.exit(code ?? 1); -}); diff --git a/packages/sdk/src/cli/telemetry/index.ts b/packages/sdk/src/cli/telemetry/index.ts index d3466ebeb2..ecb7a7d8d4 100644 --- a/packages/sdk/src/cli/telemetry/index.ts +++ b/packages/sdk/src/cli/telemetry/index.ts @@ -44,7 +44,7 @@ export async function initTelemetry(): Promise { const version = packageJson.version ?? "unknown"; const resource = resourceFromAttributes({ - [ATTR_SERVICE_NAME]: "tailor-sdk", + [ATTR_SERVICE_NAME]: "tailor", [ATTR_SERVICE_VERSION]: version, }); @@ -79,7 +79,7 @@ export async function shutdownTelemetry(): Promise { * @returns Result of fn */ export async function withSpan(name: string, fn: (span: Span) => Promise): Promise { - const tracer = trace.getTracer("tailor-sdk"); + const tracer = trace.getTracer("tailor"); return tracer.startActiveSpan(name, async (span) => { try { diff --git a/packages/sdk/src/cli/telemetry/interceptor.ts b/packages/sdk/src/cli/telemetry/interceptor.ts index 55f4a8b44c..6a5f8f5b77 100644 --- a/packages/sdk/src/cli/telemetry/interceptor.ts +++ b/packages/sdk/src/cli/telemetry/interceptor.ts @@ -9,7 +9,7 @@ import type { Interceptor } from "@connectrpc/connect"; */ export function createTracingInterceptor(): Interceptor { return (next) => async (req) => { - const tracer = trace.getTracer("tailor-sdk"); + const tracer = trace.getTracer("tailor"); return tracer.startActiveSpan(`rpc.${req.method.name}`, async (span) => { span.setAttribute("rpc.method", req.method.name); diff --git a/packages/sdk/src/cli/ts-hook.d.mts b/packages/sdk/src/cli/ts-hook.d.mts new file mode 100644 index 0000000000..d8daefaa1e --- /dev/null +++ b/packages/sdk/src/cli/ts-hook.d.mts @@ -0,0 +1,4 @@ +export declare function resolve(...args: unknown[]): Promise; +export declare function load(...args: unknown[]): Promise; +export declare function resolveSync(...args: unknown[]): unknown; +export declare function loadSync(...args: unknown[]): unknown; diff --git a/packages/sdk/src/cli/ts-hook.mjs b/packages/sdk/src/cli/ts-hook.mjs new file mode 100644 index 0000000000..ee48593b1b --- /dev/null +++ b/packages/sdk/src/cli/ts-hook.mjs @@ -0,0 +1,303 @@ +import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve as resolvePath } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +// Node.js module hook: TypeScript resolver + type stripper via amaro. +// Registered programmatically so the CLI can import user .ts files without +// requiring --experimental-strip-types at process startup. +import { transformSync } from "amaro"; + +const TS_EXTENSIONS = [".ts", ".mts"]; +const JS_TO_TS = new Map([ + [".js", ".ts"], + [".mjs", ".mts"], +]); + +// --- tsconfig paths resolution --- + +const tsconfigPathsCache = new Map(); + +function collectPathsInto(out, configFilePath, content, visited) { + if (visited.has(configFilePath)) return; + visited.add(configFilePath); + + const baseDir = dirname(configFilePath); + + const extendsField = content.extends; + if (typeof extendsField === "string") { + const base = resolvePath(baseDir, extendsField); + const extendsPath = base.endsWith(".json") ? base : base + ".json"; + try { + const sub = JSON.parse(readFileSync(extendsPath, "utf-8")); + collectPathsInto(out, extendsPath, sub, visited); + } catch (e) { + if (e?.code !== "ENOENT" && !(e instanceof SyntaxError)) throw e; + } + } + + const opts = content.compilerOptions ?? {}; + const rawPaths = opts.paths; + const baseUrl = opts.baseUrl; + if (rawPaths && baseUrl) { + const absBase = resolvePath(baseDir, baseUrl); + for (const [alias, targets] of Object.entries(rawPaths)) { + out[alias] = targets.map((t) => { + const isWildcard = t.endsWith("/*"); + const resolved = resolvePath(absBase, isWildcard ? t.slice(0, -2) : t); + return pathToFileURL(resolved).href + (isWildcard ? "/*" : ""); + }); + } + } +} + +function loadTsconfigPaths(startDir) { + if (tsconfigPathsCache.has(startDir)) return tsconfigPathsCache.get(startDir); + + const paths = Object.create(null); + let dir = startDir; + let prev = ""; + while (dir !== prev) { + try { + const configFilePath = join(dir, "tsconfig.json"); + const content = JSON.parse(readFileSync(configFilePath, "utf-8")); + collectPathsInto(paths, configFilePath, content, new Set()); + break; + } catch (e) { + if (e?.code !== "ENOENT" && !(e instanceof SyntaxError)) throw e; + if (e instanceof SyntaxError) break; + } + prev = dir; + dir = dirname(dir); + } + + tsconfigPathsCache.set(startDir, paths); + return paths; +} + +function matchTsconfigPaths(specifier, paths) { + if (paths[specifier]?.length > 0) { + return paths[specifier]; + } + const wildcardEntries = Object.entries(paths) + .filter(([alias]) => alias.endsWith("/*")) + .toSorted((a, b) => b[0].length - a[0].length); + for (const [alias, targets] of wildcardEntries) { + const prefix = alias.slice(0, -2); + if (specifier.startsWith(prefix + "/")) { + const rest = specifier.slice(prefix.length + 1); + return targets.map((t) => (t.endsWith("/*") ? t.slice(0, -2) + "/" + rest : t)); + } + } + return null; +} + +async function tryResolveWithExtensions(base, context, nextResolve) { + if (!TS_EXTENSIONS.some((ext) => base.endsWith(ext))) { + for (const ext of TS_EXTENSIONS) { + for (const suffix of ["", "/index"]) { + try { + return await nextResolve(base + suffix + ext, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + } + } + try { + return await nextResolve(base, context); + } catch (e) { + const code = e?.code; + if (code !== "ERR_MODULE_NOT_FOUND" && code !== "ERR_UNSUPPORTED_DIR_IMPORT") throw e; + } + return null; +} + +function tryResolveWithExtensionsSync(base, context, nextResolve) { + if (!TS_EXTENSIONS.some((ext) => base.endsWith(ext))) { + for (const ext of TS_EXTENSIONS) { + for (const suffix of ["", "/index"]) { + try { + return nextResolve(base + suffix + ext, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + } + } + try { + return nextResolve(base, context); + } catch (e) { + const code = e?.code; + if (code !== "ERR_MODULE_NOT_FOUND" && code !== "ERR_UNSUPPORTED_DIR_IMPORT") throw e; + } + return null; +} + +// --- module hooks --- + +export async function resolve(specifier, context, nextResolve) { + try { + return await nextResolve(specifier, context); + } catch (err) { + if (err.code !== "ERR_MODULE_NOT_FOUND" && err.code !== "ERR_UNSUPPORTED_DIR_IMPORT") throw err; + + if ( + err.code === "ERR_UNSUPPORTED_DIR_IMPORT" && + (specifier.startsWith(".") || specifier.startsWith("/")) + ) { + for (const ext of TS_EXTENSIONS) { + try { + return await nextResolve(specifier + "/index" + ext, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + throw err; + } + + if (!specifier.startsWith(".") && !specifier.startsWith("/")) { + // Non-relative: try tsconfig path aliases + if (context.parentURL?.startsWith("file://")) { + const parentParsed = new URL(context.parentURL); + parentParsed.search = ""; + parentParsed.hash = ""; + const parentDir = dirname(fileURLToPath(parentParsed)); + const tsconfigPaths = loadTsconfigPaths(parentDir); + const candidates = matchTsconfigPaths(specifier, tsconfigPaths); + if (candidates) { + for (const candidate of candidates) { + const result = await tryResolveWithExtensions(candidate, context, nextResolve); + if (result) return result; + } + } + } + throw err; + } + + const lastSegment = specifier.split("/").pop() ?? ""; + if (!lastSegment.includes(".")) { + for (const ext of TS_EXTENSIONS) { + try { + return await nextResolve(specifier + ext, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + } + + for (const [jsExt, tsExt] of JS_TO_TS) { + if (specifier.endsWith(jsExt)) { + try { + return await nextResolve(specifier.slice(0, -jsExt.length) + tsExt, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + } + + throw err; + } +} + +export async function load(url, context, nextLoad) { + if (url.startsWith("file:")) { + const parsedUrl = new URL(url); + if ( + TS_EXTENSIONS.some((ext) => parsedUrl.pathname.endsWith(ext)) && + !parsedUrl.pathname.endsWith(".d.ts") && + !parsedUrl.pathname.endsWith(".d.mts") + ) { + parsedUrl.search = ""; + parsedUrl.hash = ""; + const filePath = fileURLToPath(parsedUrl); + const source = await readFile(filePath, "utf-8"); + const { code } = transformSync(source, { mode: "transform", filename: filePath }); + return { format: "module", shortCircuit: true, source: `${code}\n//# sourceURL=${url}` }; + } + } + return nextLoad(url, context); +} + +// Sync hooks for module.registerHooks() (Node >= 22.15.0). +export function resolveSync(specifier, context, nextResolve) { + try { + return nextResolve(specifier, context); + } catch (err) { + if (err.code !== "ERR_MODULE_NOT_FOUND" && err.code !== "ERR_UNSUPPORTED_DIR_IMPORT") throw err; + + if ( + err.code === "ERR_UNSUPPORTED_DIR_IMPORT" && + (specifier.startsWith(".") || specifier.startsWith("/")) + ) { + for (const ext of TS_EXTENSIONS) { + try { + return nextResolve(specifier + "/index" + ext, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + throw err; + } + + if (!specifier.startsWith(".") && !specifier.startsWith("/")) { + // Non-relative: try tsconfig path aliases + if (context.parentURL?.startsWith("file://")) { + const parentParsed = new URL(context.parentURL); + parentParsed.search = ""; + parentParsed.hash = ""; + const parentDir = dirname(fileURLToPath(parentParsed)); + const tsconfigPaths = loadTsconfigPaths(parentDir); + const candidates = matchTsconfigPaths(specifier, tsconfigPaths); + if (candidates) { + for (const candidate of candidates) { + const result = tryResolveWithExtensionsSync(candidate, context, nextResolve); + if (result) return result; + } + } + } + throw err; + } + + const lastSegment = specifier.split("/").pop() ?? ""; + if (!lastSegment.includes(".")) { + for (const ext of TS_EXTENSIONS) { + try { + return nextResolve(specifier + ext, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + } + + for (const [jsExt, tsExt] of JS_TO_TS) { + if (specifier.endsWith(jsExt)) { + try { + return nextResolve(specifier.slice(0, -jsExt.length) + tsExt, context); + } catch (e) { + if (e?.code !== "ERR_MODULE_NOT_FOUND") throw e; + } + } + } + + throw err; + } +} + +export function loadSync(url, context, nextLoad) { + if (url.startsWith("file:")) { + const parsedUrl = new URL(url); + if ( + TS_EXTENSIONS.some((ext) => parsedUrl.pathname.endsWith(ext)) && + !parsedUrl.pathname.endsWith(".d.ts") && + !parsedUrl.pathname.endsWith(".d.mts") + ) { + parsedUrl.search = ""; + parsedUrl.hash = ""; + const filePath = fileURLToPath(parsedUrl); + const source = readFileSync(filePath, "utf-8"); + const { code } = transformSync(source, { mode: "transform", filename: filePath }); + return { format: "module", shortCircuit: true, source: `${code}\n//# sourceURL=${url}` }; + } + } + return nextLoad(url, context); +} diff --git a/packages/sdk/src/cli/ts-hook.test.ts b/packages/sdk/src/cli/ts-hook.test.ts new file mode 100644 index 0000000000..261d001aed --- /dev/null +++ b/packages/sdk/src/cli/ts-hook.test.ts @@ -0,0 +1,367 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test, vi } from "vitest"; +import { load, loadSync, resolve, resolveSync } from "./ts-hook.mjs"; + +vi.mock("node:fs/promises", () => ({ + readFile: vi.fn().mockResolvedValue("const x: number = 1;"), +})); + +vi.mock("node:fs", () => ({ + readFileSync: vi.fn().mockReturnValue("const x: number = 1;"), +})); + +vi.mock("amaro", () => ({ + transformSync: vi.fn().mockReturnValue({ code: "const x = 1;" }), +})); + +describe("load", () => { + test("strips query string before fileURLToPath to avoid ERR_INVALID_FILE_URL_PATH", async () => { + const nextLoad = vi.fn(); + const result = await load("file:///path/to/foo.ts?tailorImportNonce=1", {}, nextLoad); + expect(result).toMatchObject({ format: "module", shortCircuit: true }); + expect(nextLoad).not.toHaveBeenCalled(); + }); + + test("strips hash before fileURLToPath", async () => { + const nextLoad = vi.fn(); + const result = await load("file:///path/to/foo.mts#anchor", {}, nextLoad); + expect(result).toMatchObject({ format: "module", shortCircuit: true }); + expect(nextLoad).not.toHaveBeenCalled(); + }); + + test("delegates non-file: URLs to nextLoad", async () => { + const nextLoad = vi.fn(); + await load("node:path", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("node:path", {}); + }); + + test("delegates non-TS file URLs to nextLoad", async () => { + const nextLoad = vi.fn(); + await load("file:///path/to/foo.js", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("file:///path/to/foo.js", {}); + }); + + test("delegates .d.ts declaration files to nextLoad without transforming", async () => { + const nextLoad = vi.fn(); + await load("file:///path/to/foo.d.ts", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("file:///path/to/foo.d.ts", {}); + }); + + test("delegates .d.mts declaration files to nextLoad without transforming", async () => { + const nextLoad = vi.fn(); + await load("file:///path/to/foo.d.mts", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("file:///path/to/foo.d.mts", {}); + }); +}); + +describe("loadSync", () => { + test("strips query string before fileURLToPath to avoid ERR_INVALID_FILE_URL_PATH", () => { + const nextLoad = vi.fn(); + const result = loadSync("file:///path/to/foo.ts?tailorImportNonce=1", {}, nextLoad); + expect(result).toMatchObject({ format: "module", shortCircuit: true }); + expect(nextLoad).not.toHaveBeenCalled(); + }); + + test("delegates non-file: URLs to nextLoad", () => { + const nextLoad = vi.fn(); + loadSync("node:path", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("node:path", {}); + }); + + test("delegates non-TS file URLs to nextLoad", () => { + const nextLoad = vi.fn(); + loadSync("file:///path/to/foo.js", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("file:///path/to/foo.js", {}); + }); + + test("delegates .d.ts declaration files to nextLoad without transforming", () => { + const nextLoad = vi.fn(); + loadSync("file:///path/to/foo.d.ts", {}, nextLoad); + expect(nextLoad).toHaveBeenCalledWith("file:///path/to/foo.d.ts", {}); + }); +}); + +const notFound = (specifier: string) => + Object.assign(new Error(`Cannot find '${specifier}'`), { code: "ERR_MODULE_NOT_FOUND" }); + +const dirImport = (specifier: string) => + Object.assign(new Error(`Directory import not allowed for '${specifier}'`), { + code: "ERR_UNSUPPORTED_DIR_IMPORT", + }); + +describe("resolve", () => { + test("retries with /index.ts for ERR_UNSUPPORTED_DIR_IMPORT on relative directory specifier", async () => { + const resolved = { url: "file:///path/to/models/index.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(dirImport("./models")) + .mockResolvedValueOnce(resolved); + const result = await resolve("./models", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./models/index.ts", {}); + }); + + test("retries with .ts extension for extensionless relative specifier on ERR_MODULE_NOT_FOUND", async () => { + const resolved = { url: "file:///path/to/foo.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("./foo")) + .mockResolvedValueOnce(resolved); + const result = await resolve("./foo", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./foo.ts", {}); + }); + + test("retries with .ts extension for .js specifier on ERR_MODULE_NOT_FOUND", async () => { + const resolved = { url: "file:///path/to/foo.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("./foo.js")) + .mockResolvedValueOnce(resolved); + const result = await resolve("./foo.js", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./foo.ts", {}); + }); + + test("does not append extensions when specifier already has a TS extension", async () => { + const nextResolve = vi.fn().mockRejectedValue(notFound("./foo.ts")); + await expect(resolve("./foo.ts", {}, nextResolve)).rejects.toMatchObject({ + code: "ERR_MODULE_NOT_FOUND", + }); + expect(nextResolve).toHaveBeenCalledTimes(1); + }); + + test("rethrows ERR_MODULE_NOT_FOUND for non-relative specifiers without retrying", async () => { + const nextResolve = vi.fn().mockRejectedValue(notFound("some-package")); + await expect(resolve("some-package", {}, nextResolve)).rejects.toMatchObject({ + code: "ERR_MODULE_NOT_FOUND", + }); + expect(nextResolve).toHaveBeenCalledTimes(1); + }); + + test("resolves non-relative specifier via tsconfig path alias", async () => { + const tsconfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///alias-project/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@/tailordb/user")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@/tailordb/user", + { parentURL: "file:///alias-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("resolves tsconfig path alias when parentURL has tailorImportNonce query string", async () => { + const tsconfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///alias-project/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@/tailordb/user")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@/tailordb/user", + { parentURL: "file:///alias-project/tailor.config.ts?tailorImportNonce=1" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("collects paths from same-directory extends (visited key tracks file path, not dir)", async () => { + const baseConfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/*": ["./*"] } }, + }); + const rootConfig = JSON.stringify({ extends: "./tsconfig.base.json" }); + vi.mocked(readFileSync).mockImplementation((path) => { + const p = String(path); + if (p.endsWith("tsconfig.base.json")) return baseConfig as unknown as string; + if (p.endsWith("tsconfig.json")) return rootConfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///extends-project/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@/tailordb/user")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@/tailordb/user", + { parentURL: "file:///extends-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("prefers more specific wildcard alias over less specific", async () => { + const tsconfig = JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { "@/*": ["./*"], "@foo/*": ["./foo-pkg/*"] }, + }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///specificity-project/foo-pkg/bar.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@foo/bar")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@foo/bar", + { parentURL: "file:///specificity-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenLastCalledWith( + expect.stringContaining("foo-pkg/bar"), + expect.anything(), + ); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("does not append extensions when tsconfig path target already has a .ts extension", async () => { + const tsconfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/utils": ["./utils/index.ts"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///ext-project/utils/index.ts" }; + const nextResolve = vi + .fn() + .mockRejectedValueOnce(notFound("@/utils")) + .mockResolvedValueOnce(resolved); + const result = await resolve( + "@/utils", + { parentURL: "file:///ext-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledTimes(2); + expect(nextResolve).toHaveBeenLastCalledWith( + expect.stringContaining("utils/index.ts"), + expect.anything(), + ); + expect(nextResolve).not.toHaveBeenCalledWith( + expect.stringContaining("index.ts.ts"), + expect.anything(), + ); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); +}); + +describe("resolveSync", () => { + test("retries with /index.ts for ERR_UNSUPPORTED_DIR_IMPORT on relative directory specifier", () => { + const resolved = { url: "file:///path/to/models/index.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw dirImport("./models"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync("./models", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./models/index.ts", {}); + }); + + test("retries with .ts extension for extensionless relative specifier on ERR_MODULE_NOT_FOUND", () => { + const resolved = { url: "file:///path/to/foo.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("./foo"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync("./foo", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./foo.ts", {}); + }); + + test("retries with .ts extension for .js specifier on ERR_MODULE_NOT_FOUND", () => { + const resolved = { url: "file:///path/to/foo.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("./foo.js"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync("./foo.js", {}, nextResolve); + expect(result).toEqual(resolved); + expect(nextResolve).toHaveBeenCalledWith("./foo.ts", {}); + }); + + test("does not append extensions when specifier already has a TS extension", () => { + const nextResolve = vi.fn().mockImplementation(() => { + throw notFound("./foo.ts"); + }); + expect(() => resolveSync("./foo.ts", {}, nextResolve)).toThrow("Cannot find './foo.ts'"); + expect(nextResolve).toHaveBeenCalledTimes(1); + }); + + test("resolves non-relative specifier via tsconfig path alias", () => { + const tsconfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///alias-sync-project/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("@/tailordb/user"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync( + "@/tailordb/user", + { parentURL: "file:///alias-sync-project/tailor.config.ts" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); + + test("resolves tsconfig path alias when parentURL has tailorImportNonce query string", () => { + const tsconfig = JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/*": ["./*"] } }, + }); + vi.mocked(readFileSync).mockImplementation((path) => { + if (String(path).endsWith("tsconfig.json")) return tsconfig as unknown as string; + return "const x: number = 1;" as unknown as string; + }); + const resolved = { url: "file:///alias-sync-project/tailordb/user.ts" }; + const nextResolve = vi + .fn() + .mockImplementationOnce(() => { + throw notFound("@/tailordb/user"); + }) + .mockReturnValueOnce(resolved); + const result = resolveSync( + "@/tailordb/user", + { parentURL: "file:///alias-sync-project/tailor.config.ts?tailorImportNonce=1" }, + nextResolve, + ); + expect(result).toEqual(resolved); + vi.mocked(readFileSync).mockReturnValue("const x: number = 1;" as unknown as string); + }); +}); diff --git a/packages/sdk/src/configure/config/index.test.ts b/packages/sdk/src/configure/config/index.test.ts index 55fb024924..1c68dad105 100644 --- a/packages/sdk/src/configure/config/index.test.ts +++ b/packages/sdk/src/configure/config/index.test.ts @@ -14,7 +14,7 @@ describe("defineConfig", () => { test("accepts logLevel from an environment variable fallback", () => { defineConfig({ name: "my-app", - logLevel: process.env.LOG_LEVEL ?? "DEBUG", + logLevel: process.env.TAILOR_APP_LOG_LEVEL ?? "DEBUG", }); }); }); diff --git a/packages/sdk/src/configure/config/index.ts b/packages/sdk/src/configure/config/index.ts index d003101bc5..290a850a2d 100644 --- a/packages/sdk/src/configure/config/index.ts +++ b/packages/sdk/src/configure/config/index.ts @@ -1,5 +1,5 @@ import type { AppConfig } from "#/configure/config/types"; -import type { GeneratorConfig, Plugin } from "#/plugin/types"; +import type { Plugin } from "#/plugin/types"; /** * Define a Tailor SDK application configuration with shallow exactness. @@ -16,17 +16,6 @@ export function defineConfig< return config; } -/** - * Define generators to be used with the Tailor SDK. - * @deprecated Use definePlugins() with generation hooks (onTypeLoaded, generate, etc.) instead. - * @param configs - Generator configurations - * @returns Generator configurations as given - */ -/* @__NO_SIDE_EFFECTS__ */ -export function defineGenerators(...configs: GeneratorConfig[]) { - return configs; -} - /** * Define plugins to be used with the Tailor SDK. * Plugins can generate additional types, resolvers, and executors diff --git a/packages/sdk/src/configure/index.ts b/packages/sdk/src/configure/index.ts index 35c0bc1928..8f1da15eb0 100644 --- a/packages/sdk/src/configure/index.ts +++ b/packages/sdk/src/configure/index.ts @@ -16,14 +16,37 @@ export namespace t { } export { type TailorField } from "#/configure/types/type"; +export type { + DateString, + DateTimeString, + DecimalString, + TimeString, + TimeZoneOffsetString, + UUIDString, +} from "#/configure/types/scalar.types"; +export { + assertDateString, + assertDateTimeString, + assertDecimalString, + assertTimeString, + assertUUIDString, + isDateString, + isDateTimeString, + isDecimalString, + isTimeString, + isUUIDString, + parseDateString, + parseDateTimeString, + parseDecimalString, + parseTimeString, + parseUUIDString, +} from "#/configure/types/scalar"; export { - type TailorUser, - type TailorInvoker, - type AttributeMap, + type TailorPrincipal, + type Attributes, type AttributeList, type Env, } from "#/runtime/types"; -export { unauthenticatedTailorUser } from "#/configure/user"; export { type MachineUserNameRegistry, type MachineUserName } from "#/configure/types/machine-user"; export { type IdpNameRegistry, type IdpName } from "#/configure/types/idp-name"; export { @@ -34,7 +57,7 @@ export { type AIGatewayNameRegistry, type AIGatewayName } from "#/configure/type export * from "#/configure/services/index"; -export { defineConfig, defineGenerators, definePlugins } from "#/configure/config/index"; +export { defineConfig, definePlugins } from "#/configure/config/index"; // Plugin types for custom plugin development export type { diff --git a/packages/sdk/src/configure/services/auth/index.test.ts b/packages/sdk/src/configure/services/auth/index.test.ts index bf0af207c2..9b7f10f6cc 100644 --- a/packages/sdk/src/configure/services/auth/index.test.ts +++ b/packages/sdk/src/configure/services/auth/index.test.ts @@ -1,15 +1,13 @@ // oxlint-disable vitest/expect-expect -- Type-only assertions are checked by TypeScript. -import { randomUUID } from "node:crypto"; import { describe, expect, test, expectTypeOf } from "vitest"; import { t } from "#/configure/types/type"; import { db } from "../tailordb/schema"; -import { defineAuth, type AuthInvoker } from "./index"; +import { defineAuth } from "./index"; import type { BeforeLoginHook, BeforeLoginHookArgs, FederatedIdentity, } from "#/configure/services/auth/types"; -import type { AuthInvoker as ProtoAuthInvoker } from "@tailor-platform/tailor-proto/auth_resource_pb"; import type { JsonObject } from "type-fest"; const userType = db.type("User", { @@ -20,7 +18,7 @@ const userType = db.type("User", { externalId: db.uuid(), }); -type AttributeMap = { +type Attributes = { role: true; isActive: true; tags: true; @@ -29,7 +27,7 @@ type AttributeMap = { type AttributeList = ["externalId"]; -const attributeMapConfig: AttributeMap = { +const attributeMapConfig: Attributes = { role: true, isActive: true, tags: true, @@ -37,7 +35,9 @@ const attributeMapConfig: AttributeMap = { }; const attributeListConfig: AttributeList = ["externalId"]; -const machineUserAttributeList: [string] = [randomUUID()]; +const adminExternalId = "00000000-0000-4000-8000-000000000001"; +const workerExternalId = "00000000-0000-4000-8000-000000000002"; +const machineUserAttributeList: [typeof adminExternalId] = [adminExternalId]; const basicUserProfile = { type: userType, usernameField: "email" } as const; describe("defineAuth", () => { @@ -55,7 +55,7 @@ describe("defineAuth", () => { role: "ADMIN", isActive: true, tags: ["root"], - externalId: "admin-external-id", + externalId: adminExternalId, }, attributeList: machineUserAttributeList, }, @@ -68,24 +68,6 @@ describe("defineAuth", () => { expect(authConfig.machineUsers?.admin.attributes?.role).toBe("ADMIN"); }); - test("creates auth configuration with invoker method", () => { - const authConfig = defineAuth("test-service", { - userProfile: basicUserProfile, - machineUsers: { - admin: {}, - worker: {}, - }, - }); - - const invoker = authConfig.invoker("admin"); - expect(invoker.namespace).toBe("test-service"); - expect(invoker.machineUserName).toBe("admin"); - - const workerInvoker = authConfig.invoker("worker"); - expect(workerInvoker.namespace).toBe("test-service"); - expect(workerInvoker.machineUserName).toBe("worker"); - }); - test("creates minimal auth configuration", () => { const authConfig = defineAuth("minimal", { userProfile: basicUserProfile, @@ -94,6 +76,8 @@ describe("defineAuth", () => { expect(authConfig.name).toBe("minimal"); expect(authConfig.userProfile.type).toBe(userType); expect(authConfig.machineUsers).toBeUndefined(); + expect(authConfig).not.toHaveProperty("getConnectionToken"); + expectTypeOf(authConfig).not.toHaveProperty("getConnectionToken"); }); test("creates auth configuration with machineUsers only", () => { @@ -110,7 +94,7 @@ describe("defineAuth", () => { role: "ADMIN", isActive: true, tags: ["root"], - externalId: "admin-external-id", + externalId: adminExternalId, }, attributeList: machineUserAttributeList, }, @@ -119,7 +103,7 @@ describe("defineAuth", () => { role: "WORKER", isActive: false, tags: [], - externalId: "worker-external-id", + externalId: workerExternalId, }, }, }, @@ -237,8 +221,6 @@ describe("defineAuth", () => { }); expect(authConfig.hooks!.beforeLogin!.invoker).toBe("admin"); - // invoker should not narrow MachineUserNames — both machine users must remain valid - expectTypeOf(authConfig.invoker).parameter(0).toEqualTypeOf<"admin" | "worker">(); }); test("typed claims expose federated_identity while keeping arbitrary claims", () => { @@ -265,64 +247,4 @@ describe("defineAuth", () => { expect(authConfig.hooks).toBeUndefined(); }); }); - - describe("AuthInvoker type compatibility with tailor-proto", () => { - test("AuthInvoker has namespace field compatible with proto", () => { - // Verify the field name matches tailor.v1.AuthInvoker - type HasNamespace = AuthInvoker extends { namespace: string } ? true : false; - expectTypeOf().toEqualTypeOf(); - - // Verify proto type has the same field - type ProtoHasNamespace = ProtoAuthInvoker extends { namespace: string } ? true : false; - expectTypeOf().toEqualTypeOf(); - }); - - test("AuthInvoker has machineUserName field compatible with proto", () => { - // Verify the field name matches tailor.v1.AuthInvoker - type HasMachineUserName = - AuthInvoker extends { - machineUserName: string; - } - ? true - : false; - expectTypeOf().toEqualTypeOf(); - - // Verify proto type has the same field - type ProtoHasMachineUserName = ProtoAuthInvoker extends { - machineUserName: string; - } - ? true - : false; - expectTypeOf().toEqualTypeOf(); - }); - - test("AuthInvoker is assignable to proto AuthInvoker fields", () => { - // This ensures that our AuthInvoker can be used where proto AuthInvoker is expected - // (checking the common properties) - type IsCompatible = - AuthInvoker extends Pick - ? true - : false; - expectTypeOf().toEqualTypeOf(); - }); - - test("invoker() returns AuthInvoker compatible object", () => { - const authConfig = defineAuth("test-auth", { - userProfile: basicUserProfile, - machineUsers: { - admin: {}, - }, - }); - - const invoker = authConfig.invoker("admin"); - - // Verify at runtime that the object has the correct field names - expect(invoker).toHaveProperty("namespace"); - expect(invoker).toHaveProperty("machineUserName"); - - // Verify it does NOT have the old field names - expect(invoker).not.toHaveProperty("authName"); - expect(invoker).not.toHaveProperty("machineUser"); - }); - }); }); diff --git a/packages/sdk/src/configure/services/auth/index.ts b/packages/sdk/src/configure/services/auth/index.ts index 4a716c7bf8..87bca9d679 100644 --- a/packages/sdk/src/configure/services/auth/index.ts +++ b/packages/sdk/src/configure/services/auth/index.ts @@ -1,11 +1,10 @@ import { type TailorDBInstance } from "../tailordb/schema"; import type { - AuthConnectionTokenResult, AuthDefinitionBrand, AuthServiceInput, DefinedAuth, UserAttributeListKey, - UserAttributeMap, + UserAttributes, } from "#/configure/services/auth/types"; import type { DefinedFieldMetadata, @@ -13,7 +12,6 @@ import type { TailorFieldType, TailorField, } from "#/configure/types/field.types"; -import type { AuthInvoker as ParserAuthInvoker } from "#/types/auth.generated"; type MachineUserAttributeFields = Record< string, @@ -21,21 +19,21 @@ type MachineUserAttributeFields = Record< >; type PlaceholderUser = TailorDBInstance, Record>; -type PlaceholderAttributeMap = UserAttributeMap; +type PlaceholderAttributes = UserAttributes; type PlaceholderAttributeList = UserAttributeListKey[]; type UserProfileAuthInput< User extends TailorDBInstance, - AttributeMap extends UserAttributeMap, + Attributes extends UserAttributes, AttributeList extends UserAttributeListKey[], MachineUserNames extends string, ConnectionNames extends string = string, > = Omit< - AuthServiceInput, + AuthServiceInput, "userProfile" | "machineUserAttributes" > & { userProfile: NonNullable< - AuthServiceInput["userProfile"] + AuthServiceInput["userProfile"] >; machineUserAttributes?: never; }; @@ -47,7 +45,7 @@ type MachineUserOnlyAuthInput< > = Omit< AuthServiceInput< PlaceholderUser, - PlaceholderAttributeMap, + PlaceholderAttributes, PlaceholderAttributeList, MachineUserNames, MachineUserAttributes, @@ -91,7 +89,7 @@ export type { UsernameFieldKey, UserAttributeKey, UserAttributeListKey, - UserAttributeMap, + UserAttributes, AuthConnectionTokenResult, AuthServiceInput, AuthConfig, @@ -100,23 +98,13 @@ export type { DefinedAuth, } from "#/configure/services/auth/types"; -/** - * Invoker type compatible with tailor.v1.AuthInvoker - * - namespace: auth service name - * - machineUserName: machine user name - */ -export type AuthInvoker = Omit & { - machineUserName: M; -}; - /** * Define an auth service for the Tailor SDK. * @template Name * @template User - * @template AttributeMap + * @template Attributes * @template AttributeList * @template MachineUserNames - * @template M * @param name - Auth service name * @param config - Auth service configuration * @returns Defined auth service @@ -124,23 +112,16 @@ export type AuthInvoker = Omit, + const Attributes extends UserAttributes, const AttributeList extends UserAttributeListKey[], const MachineUserNames extends string, const ConnectionNames extends string = string, >( name: Name, - config: UserProfileAuthInput< - User, - AttributeMap, - AttributeList, - MachineUserNames, - ConnectionNames - >, + config: UserProfileAuthInput, ): DefinedAuth< Name, - UserProfileAuthInput, - MachineUserNames + UserProfileAuthInput >; export function defineAuth< const Name extends string, @@ -152,14 +133,13 @@ export function defineAuth< config: MachineUserOnlyAuthInput, ): DefinedAuth< Name, - MachineUserOnlyAuthInput, - MachineUserNames + MachineUserOnlyAuthInput >; /* @__NO_SIDE_EFFECTS__ */ export function defineAuth< const Name extends string, const User extends TailorDBInstance, - const AttributeMap extends UserAttributeMap, + const Attributes extends UserAttributes, const AttributeList extends UserAttributeListKey[], const MachineUserAttributes extends MachineUserAttributeFields, const MachineUserNames extends string, @@ -167,25 +147,17 @@ export function defineAuth< >( name: Name, config: - | UserProfileAuthInput + | UserProfileAuthInput | MachineUserOnlyAuthInput, ) { const result = { ...config, name, - invoker(machineUser: M) { - return { namespace: name, machineUserName: machineUser } as const; - }, - getConnectionToken(connectionName: C): Promise { - return tailor.authconnection.getConnectionToken(connectionName); - }, } as const satisfies ( - | UserProfileAuthInput - | MachineUserOnlyAuthInput + | UserProfileAuthInput + | MachineUserOnlyAuthInput ) & { name: string; - invoker(machineUser: M): AuthInvoker; - getConnectionToken(connectionName: C): Promise; }; return result as typeof result & AuthDefinitionBrand; diff --git a/packages/sdk/src/configure/services/auth/types.ts b/packages/sdk/src/configure/services/auth/types.ts index 336528dc62..ad47bd1f9b 100644 --- a/packages/sdk/src/configure/services/auth/types.ts +++ b/packages/sdk/src/configure/services/auth/types.ts @@ -12,7 +12,6 @@ import type { TailorEnv } from "#/runtime/types"; // references, importable type-only from any layer. import type { AuthConnectionConfig } from "#/types/auth-connection.generated"; import type { - AuthInvoker, IdProvider as IdProviderConfig, OAuth2Client, OAuth2ClientInput, @@ -27,9 +26,21 @@ import type { IsAny, JsonObject, JsonValue } from "type-fest"; export type OAuth2ClientGrantType = OAuth2Client["grantTypes"][number]; export type SCIMAttributeType = SCIMAttribute["type"]; -export type AuthInvokerWithName = Omit & { - machineUserName: M; -}; +// Interface for module augmentation +// Users can extend via: declare module "@tailor-platform/sdk" { interface MachineUserNameRegistry { ... } } +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface MachineUserNameRegistry {} + +/** + * Machine user name. + * + * When `tailor.d.ts` is generated (via `tailor deploy`/`generate`), this is narrowed + * to the union of defined machine user names. When no machine users are registered yet, + * falls back to `string` to avoid blocking editing before the first generate run. + */ +export type MachineUserName = keyof MachineUserNameRegistry extends never + ? string + : keyof MachineUserNameRegistry & string; /** Result of retrieving a connection token at runtime. */ export type AuthConnectionTokenResult = { @@ -107,7 +118,7 @@ export type UserAttributeListKey = { : never; }[UserFieldKeys]; -export type UserAttributeMap = { +export type UserAttributes = { [K in UserAttributeKey]?: true; }; @@ -130,19 +141,19 @@ type AttributeListToTuple< : never; }; -type AttributeMapSelectedKeys< +type SelectedAttributeKeys< User extends TailorDBInstance, - AttributeMap extends UserAttributeMap, + Attributes extends UserAttributes, > = Extract< { - [K in keyof AttributeMap]-?: undefined extends AttributeMap[K] ? never : K; - }[keyof AttributeMap], + [K in keyof Attributes]-?: undefined extends Attributes[K] ? never : K; + }[keyof Attributes], UserAttributeKey >; type UserProfile< User extends TailorDBInstance, - AttributeMap extends UserAttributeMap, + Attributes extends UserAttributes, AttributeList extends UserAttributeListKey[], > = { /** @@ -154,7 +165,7 @@ type UserProfile< namespace?: string; type: User; usernameField: UsernameFieldKey; - attributes?: DisallowExtraKeys>; + attributes?: DisallowExtraKeys>; attributeList?: AttributeList; }; @@ -183,7 +194,7 @@ type MachineUserFromAttributes = type MachineUser< User extends TailorDBInstance, - AttributeMap extends UserAttributeMap = UserAttributeMap, + Attributes extends UserAttributes = UserAttributes, AttributeList extends UserAttributeListKey[] = [], MachineUserAttributes extends MachineUserAttributeFields | undefined = undefined, > = @@ -193,18 +204,15 @@ type MachineUser< attributes: Record; attributeList?: string[]; } - : (AttributeMapSelectedKeys extends never + : (SelectedAttributeKeys extends never ? { attributes?: never } : { attributes: { - [K in AttributeMapSelectedKeys]: K extends keyof output + [K in SelectedAttributeKeys]: K extends keyof output ? output[K] : never; } & { - [K in Exclude< - keyof output, - AttributeMapSelectedKeys - >]?: never; + [K in Exclude, SelectedAttributeKeys>]?: never; }; }) & ([] extends AttributeList @@ -217,17 +225,17 @@ type MachineUser< attributes: Record; attributeList?: string[]; } - : (AttributeMapSelectedKeys extends never + : (SelectedAttributeKeys extends never ? { attributes?: never } : { attributes: { - [K in AttributeMapSelectedKeys]: K extends keyof output + [K in SelectedAttributeKeys]: K extends keyof output ? output[K] : never; } & { [K in Exclude< keyof output, - AttributeMapSelectedKeys + SelectedAttributeKeys >]?: never; }; }) & @@ -295,7 +303,7 @@ export type AuthHooks = { // Input type (before parsing) - used by configure layer export type AuthServiceInput< User extends TailorDBInstance, - AttributeMap extends UserAttributeMap, + Attributes extends UserAttributes, AttributeList extends UserAttributeListKey[], MachineUserNames extends string, MachineUserAttributes extends MachineUserAttributeFields | undefined = @@ -304,11 +312,11 @@ export type AuthServiceInput< ConnectionNames extends string = string, > = { hooks?: AuthHooks; - userProfile?: UserProfile; + userProfile?: UserProfile; machineUserAttributes?: MachineUserAttributes; machineUsers?: Record< MachineUserNames, - MachineUser + MachineUser >; oauth2Clients?: Record; idProvider?: IdProviderConfig; @@ -321,25 +329,8 @@ export type AuthServiceInput< declare const authDefinitionBrand: unique symbol; export type AuthDefinitionBrand = { readonly [authDefinitionBrand]: true }; -type ConnectionNames = Config extends { connections?: Record } - ? K & string - : string; - -export type DefinedAuth = Config & { +export type DefinedAuth = Config & { name: Name; - /** - * @deprecated Pass the machine user name directly as a string instead, e.g. `authInvoker: "machine-user-name"`. - * Using this function pulls config-layer (Node-only) dependencies into runtime bundles. - */ - invoker(machineUser: M): AuthInvokerWithName; - /** - * @deprecated Use `authconnection.getConnectionToken(...)` from `@tailor-platform/sdk/runtime` instead. - * Importing `auth` from `tailor.config.ts` into runtime files pulls config-layer (Node-only) - * dependencies into the bundle. - */ - getConnectionToken>( - connectionName: C, - ): Promise; } & AuthDefinitionBrand; export type AuthExternalConfig = { name: string; external: true }; @@ -352,8 +343,7 @@ export type AuthOwnConfig = DefinedAuth< // Intentionally permissive: AuthConfig is the "container" type for AppConfig.auth. // We want any concrete `defineAuth(...)` result to be assignable here, while the // strong typing remains on the `defineAuth` return type itself. - AuthServiceInputLoose, - string + AuthServiceInputLoose >; export type AuthConfig = AuthOwnConfig | AuthExternalConfig; diff --git a/packages/sdk/src/configure/services/executor/executor.test.ts b/packages/sdk/src/configure/services/executor/executor.test.ts index ce87166a0a..25710ffd49 100644 --- a/packages/sdk/src/configure/services/executor/executor.test.ts +++ b/packages/sdk/src/configure/services/executor/executor.test.ts @@ -16,7 +16,8 @@ import { } from "./trigger/event"; import { scheduleTrigger } from "./trigger/schedule"; import { incomingWebhookTrigger } from "./trigger/webhook"; -import type { TailorInvoker } from "#/runtime/types"; +import type { UUIDString } from "#/configure/types/scalar.types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { Operation } from "./operation"; const createUserType = () => @@ -74,7 +75,7 @@ describe("createExecutor", () => { operation: { kind: "function", body: (args) => { - expectTypeOf(args).toEqualTypeOf(); + expectTypeOf(args).toEqualTypeOf(); }, }, }); @@ -281,7 +282,7 @@ describe("recordCreatedTrigger", () => { appNamespace: string; typeName: string; newRecord: { - id: string; + id: UUIDString; name: string; age: number; }; @@ -297,7 +298,7 @@ describe("recordCreatedTrigger", () => { appNamespace: string; typeName: string; newRecord: { - id: string; + id: UUIDString; name: string; age: number; }; @@ -343,12 +344,12 @@ describe("recordUpdatedTrigger", () => { appNamespace: string; typeName: string; newRecord: { - id: string; + id: UUIDString; name: string; age: number; }; oldRecord: { - id: string; + id: UUIDString; name: string; age: number; }; @@ -364,12 +365,12 @@ describe("recordUpdatedTrigger", () => { appNamespace: string; typeName: string; newRecord: { - id: string; + id: UUIDString; name: string; age: number; }; oldRecord: { - id: string; + id: UUIDString; name: string; age: number; }; @@ -415,7 +416,7 @@ describe("recordDeletedTrigger", () => { appNamespace: string; typeName: string; oldRecord: { - id: string; + id: UUIDString; name: string; age: number; }; @@ -431,7 +432,7 @@ describe("recordDeletedTrigger", () => { appNamespace: string; typeName: string; oldRecord: { - id: string; + id: UUIDString; name: string; age: number; }; @@ -641,7 +642,7 @@ describe("resolverExecutedTrigger", () => { const resolver = createResolver({ name: "test", operation: "query", - body: () => ({ userId: "user-123" }), + body: () => ({ userId: "123e4567-e89b-12d3-a456-426614174000" }), output: t.object({ userId: t.string(), }), @@ -779,19 +780,19 @@ describe("recordTrigger (multi-event)", () => { // Can narrow by kind if (args.event === "created") { expectTypeOf(args.newRecord).toExtend<{ - id: string; + id: UUIDString; name: string; age: number; }>(); } if (args.event === "updated") { expectTypeOf(args.newRecord).toExtend<{ - id: string; + id: UUIDString; name: string; age: number; }>(); expectTypeOf(args.oldRecord).toExtend<{ - id: string; + id: UUIDString; name: string; age: number; }>(); @@ -826,7 +827,7 @@ describe("recordTrigger (multi-event)", () => { body: (args) => { if (args.event === "deleted") { expectTypeOf(args.oldRecord).toExtend<{ - id: string; + id: UUIDString; name: string; age: number; }>(); @@ -861,7 +862,7 @@ describe("idpUserTrigger (multi-event)", () => { workspaceId: string; appNamespace: string; namespaceName: string; - userId: string; + userId: UUIDString; }>(); if (args.event === "created") { expectTypeOf(args.event).toEqualTypeOf<"created">(); @@ -890,7 +891,7 @@ describe("authAccessTokenTrigger (multi-event)", () => { workspaceId: string; appNamespace: string; namespaceName: string; - userId: string; + userId: UUIDString; }>(); if (args.event === "issued") { expectTypeOf(args.event).toEqualTypeOf<"issued">(); @@ -972,7 +973,7 @@ describe("functionTarget", () => { operation: { kind: "function", body: (args) => { - expectTypeOf(args.invoker).toEqualTypeOf(); + expectTypeOf(args.invoker).toEqualTypeOf(); }, }, }); @@ -1009,7 +1010,7 @@ describe("gqlTarget", () => { } `, variables: () => ({ - id: "test-id", + id: "123e4567-e89b-12d3-a456-426614174000", }), }, }); @@ -1190,7 +1191,7 @@ describe("workflowTarget", () => { operation: { kind: "workflow", workflow: testWorkflow, - args: { orderId: "test-id" }, + args: { orderId: "123e4567-e89b-12d3-a456-426614174000" }, }, }); expect(executor.operation.kind).toBe("workflow"); @@ -1264,15 +1265,15 @@ describe("workflowTarget", () => { }); }); - test("can specify authInvoker", () => { + test("can specify invoker", () => { createExecutor({ name: "test", trigger: scheduleTrigger({ cron: "0 12 * * *" }), operation: { kind: "workflow", workflow: testWorkflow, - args: { orderId: "test-id" }, - authInvoker: { namespace: "my-auth", machineUserName: "admin" }, + args: { orderId: "123e4567-e89b-12d3-a456-426614174000" }, + invoker: "admin", }, }); }); @@ -1307,7 +1308,7 @@ describe("workflowTarget", () => { workflow: testWorkflow, args: (args) => { expectTypeOf(args).not.toHaveProperty("invoker"); - return { orderId: "test-id" }; + return { orderId: "123e4567-e89b-12d3-a456-426614174000" }; }, }, }); diff --git a/packages/sdk/src/configure/services/executor/executor.ts b/packages/sdk/src/configure/services/executor/executor.ts index ba9edc21c9..42ec21b275 100644 --- a/packages/sdk/src/configure/services/executor/executor.ts +++ b/packages/sdk/src/configure/services/executor/executor.ts @@ -1,5 +1,4 @@ import { brandValue } from "#/utils/brand"; -import type { AuthInvoker } from "#/configure/services/auth/index"; import type { Workflow } from "#/configure/services/workflow/workflow"; import type { MachineUserName } from "#/configure/types/machine-user"; import type { ExecutorInput } from "#/types/executor.generated"; @@ -31,7 +30,7 @@ type Executor, O> = O extends { kind: "workflow"; workflow: W; args?: WorkflowInput | ((args: TriggerArgs) => WorkflowInput); - authInvoker?: AuthInvoker | MachineUserName; + invoker?: MachineUserName; }; } : ExecutorBase & { diff --git a/packages/sdk/src/configure/services/executor/operation.ts b/packages/sdk/src/configure/services/executor/operation.ts index 92b6b34c5c..6b92d008be 100644 --- a/packages/sdk/src/configure/services/executor/operation.ts +++ b/packages/sdk/src/configure/services/executor/operation.ts @@ -1,7 +1,6 @@ -import type { AuthInvoker } from "#/configure/services/auth/index"; import type { Workflow } from "#/configure/services/workflow/workflow"; import type { MachineUserName } from "#/configure/types/machine-user"; -import type { TailorInvoker } from "#/runtime/types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { FunctionOperation as ParserFunctionOperation, GqlOperation as ParserGqlOperation, @@ -11,18 +10,18 @@ import type { import type { Client } from "@urql/core"; /** Function-based executor operation. The body receives the trigger args and the `invoker`. */ -export type FunctionOperation = Omit & { - body: (args: Args & { invoker?: TailorInvoker }) => void | Promise; - authInvoker?: AuthInvoker | MachineUserName; +export type FunctionOperation = Omit & { + body: (args: Args & { invoker: TailorPrincipal | null }) => void | Promise; + invoker?: MachineUserName; }; type UrqlOperationArgs = Parameters; /** GraphQL-based executor operation. Executes a GraphQL query or mutation. */ -export type GqlOperation = Omit & { +export type GqlOperation = Omit & { query: UrqlOperationArgs[0]; variables?: (args: Args) => UrqlOperationArgs[1]; - authInvoker?: AuthInvoker | MachineUserName; + invoker?: MachineUserName; }; type RequestHeader = @@ -291,11 +290,11 @@ type WorkflowInput = Parameters[0]; /** Workflow-triggering executor operation. Triggers a workflow in response to an event. */ export type WorkflowOperation = Omit< ParserWorkflowOperation, - "workflowName" | "args" | "authInvoker" + "workflowName" | "args" | "invoker" > & { workflow: W; args?: WorkflowInput | ((args: Args) => WorkflowInput); - authInvoker?: AuthInvoker | MachineUserName; + invoker?: MachineUserName; }; export type Operation = diff --git a/packages/sdk/src/configure/services/executor/trigger/event.ts b/packages/sdk/src/configure/services/executor/trigger/event.ts index e8286546ea..d3fabe194d 100644 --- a/packages/sdk/src/configure/services/executor/trigger/event.ts +++ b/packages/sdk/src/configure/services/executor/trigger/event.ts @@ -1,7 +1,8 @@ import type { ResolverConfig } from "#/configure/services/resolver/resolver"; import type { TailorDBType } from "#/configure/services/tailordb/schema"; import type { IdpName } from "#/configure/types/idp-name"; -import type { TailorActor, TailorEnv } from "#/runtime/types"; +import type { UUIDString } from "#/configure/types/scalar.types"; +import type { TailorEnv, TailorPrincipal } from "#/runtime/types"; import type { TailorDBTrigger as ParserTailorDBTrigger, ResolverExecutedTrigger as ParserResolverExecutedTrigger, @@ -14,7 +15,7 @@ interface EventArgs { workspaceId: string; appNamespace: string; env: TailorEnv; - actor: TailorActor | null; + actor: TailorPrincipal | null; } interface RecordArgs extends EventArgs { @@ -76,21 +77,21 @@ export interface IdpUserCreatedArgs extends EventArgs { event: "created"; rawEvent: "idp.user.created"; namespaceName: string; - userId: string; + userId: UUIDString; } export interface IdpUserUpdatedArgs extends EventArgs { event: "updated"; rawEvent: "idp.user.updated"; namespaceName: string; - userId: string; + userId: UUIDString; } export interface IdpUserDeletedArgs extends EventArgs { event: "deleted"; rawEvent: "idp.user.deleted"; namespaceName: string; - userId: string; + userId: UUIDString; } export type IdpUserArgs = IdpUserCreatedArgs | IdpUserUpdatedArgs | IdpUserDeletedArgs; @@ -100,21 +101,21 @@ export interface AuthAccessTokenIssuedArgs extends EventArgs { event: "issued"; rawEvent: "auth.access_token.issued"; namespaceName: string; - userId: string; + userId: UUIDString; } export interface AuthAccessTokenRefreshedArgs extends EventArgs { event: "refreshed"; rawEvent: "auth.access_token.refreshed"; namespaceName: string; - userId: string; + userId: UUIDString; } export interface AuthAccessTokenRevokedArgs extends EventArgs { event: "revoked"; rawEvent: "auth.access_token.revoked"; namespaceName: string; - userId: string; + userId: UUIDString; } export type AuthAccessTokenArgs = diff --git a/packages/sdk/src/configure/services/idp/permission.ts b/packages/sdk/src/configure/services/idp/permission.ts index 679750708e..59e77c6abe 100644 --- a/packages/sdk/src/configure/services/idp/permission.ts +++ b/packages/sdk/src/configure/services/idp/permission.ts @@ -1,5 +1,5 @@ import type { IdPUserField } from "#/parser/service/idp/types"; -import type { InferredAttributeMap } from "#/runtime/types"; +import type { InferredAttributes } from "#/runtime/types"; type EqualityOperator = "=" | "!="; type ContainsOperator = "in" | "not in"; @@ -20,19 +20,19 @@ type BooleanArrayFieldKeys = { [K in keyof User]: User[K] extends boolean[] ? K : never; }[keyof User]; -type UserStringOperand = { +type UserStringOperand = { user: StringFieldKeys | "id"; }; -type UserStringArrayOperand = { +type UserStringArrayOperand = { user: StringArrayFieldKeys; }; -type UserBooleanOperand = { +type UserBooleanOperand = { user: BooleanFieldKeys | "_loggedIn"; }; -type UserBooleanArrayOperand = { +type UserBooleanArrayOperand = { user: BooleanArrayFieldKeys; }; @@ -63,7 +63,7 @@ type BooleanEqualityCondition = | readonly [boolean | UserBooleanOperand, EqualityOperator, IdPUserOperand]; type EqualityCondition< - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, > = StringEqualityCondition | BooleanEqualityCondition; @@ -80,17 +80,17 @@ type BooleanContainsCondition = | readonly [IdPUserOperand, ContainsOperator, boolean[] | UserBooleanArrayOperand]; type ContainsCondition< - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, > = StringContainsCondition | BooleanContainsCondition; export type IdPPermissionCondition< - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, > = EqualityCondition | ContainsCondition; type IdPActionPermission< - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, > = | { @@ -124,7 +124,7 @@ type IdPActionPermission< * unenrollMfa: [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }], * }; */ -export type IdPPermission = { +export type IdPPermission = { create: readonly IdPActionPermission[]; read: readonly IdPActionPermission[]; update: readonly IdPActionPermission[]; diff --git a/packages/sdk/src/configure/services/resolver/resolver.test.ts b/packages/sdk/src/configure/services/resolver/resolver.test.ts index 063c1e19cb..b33e6d51fb 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -2,7 +2,13 @@ import { describe, expectTypeOf, test, expect } from "vitest"; import { db } from "#/configure/services/tailordb/index"; import { t } from "#/configure/types/index"; import { createResolver } from "./resolver"; -import type { TailorInvoker, TailorUser } from "#/runtime/types"; +import type { + DateString, + DateTimeString, + TimeString, + UUIDString, +} from "#/configure/types/scalar.types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { output } from "#/types/helpers"; import type { ResolverInput } from "#/types/resolver.generated"; @@ -14,11 +20,11 @@ describe("createResolver", () => { operation: "query", output: t.object({ result: t.string() }), body: (context) => { - expectTypeOf(context).toHaveProperty("user"); + expectTypeOf(context).toHaveProperty("caller"); expectTypeOf(context).toHaveProperty("input"); expectTypeOf(context).toHaveProperty("invoker"); - expectTypeOf(context.user).toEqualTypeOf(); - expectTypeOf(context.invoker).toEqualTypeOf(); + expectTypeOf(context.caller).toEqualTypeOf(); + expectTypeOf(context.invoker).toEqualTypeOf(); expectTypeOf(context.input).toBeNever(); return { result: "hello" }; }, @@ -31,9 +37,9 @@ describe("createResolver", () => { operation: "mutation", output: t.object({ success: t.bool() }), body: (context) => { - expectTypeOf(context).toHaveProperty("user"); + expectTypeOf(context).toHaveProperty("caller"); expectTypeOf(context).toHaveProperty("input"); - expectTypeOf(context.user).toEqualTypeOf(); + expectTypeOf(context.caller).toEqualTypeOf(); expectTypeOf(context.input).toBeNever(); return { success: true }; }, @@ -53,7 +59,7 @@ describe("createResolver", () => { output: t.object({ message: t.string() }), body: (context) => { expectTypeOf(context).toHaveProperty("input"); - expectTypeOf(context).toHaveProperty("user"); + expectTypeOf(context).toHaveProperty("caller"); expectTypeOf(context.input).toEqualTypeOf<{ name: string; age: number; @@ -170,12 +176,12 @@ describe("createResolver", () => { input: inputType, output: t.object({ success: t.bool() }), body: (context) => { - expectTypeOf(context.input.id).toBeString(); + expectTypeOf(context.input.id).toEqualTypeOf(); expectTypeOf(context.input.name).toBeString(); expectTypeOf(context.input.active).toBeBoolean(); expectTypeOf(context.input.count).toBeNumber(); expectTypeOf(context.input.score).toBeNumber(); - expectTypeOf(context.input.createdAt).toExtend(); + expectTypeOf(context.input.createdAt).toEqualTypeOf(); expectTypeOf(context.input.tags).toBeArray(); expectTypeOf(context.input.metadata.key).toBeString(); return { success: true }; @@ -221,7 +227,7 @@ describe("createResolver", () => { output: t.object({ data: t.string() }), body: async (context) => { expectTypeOf(context).toHaveProperty("input"); - expectTypeOf(context).toHaveProperty("user"); + expectTypeOf(context).toHaveProperty("caller"); await new Promise((resolve) => setTimeout(resolve, 0)); return { data: context.input.id }; }, @@ -236,23 +242,24 @@ describe("createResolver", () => { output: t.object({ success: t.bool() }), body: async (context) => { expectTypeOf(context).toHaveProperty("input"); - expectTypeOf(context).toHaveProperty("user"); + expectTypeOf(context).toHaveProperty("caller"); return { success: true }; }, }); }); - test("user context always available", () => { + test("caller context is nullable", () => { createResolver({ name: "withUser", operation: "query", output: t.object({ userId: t.string() }), body: (context) => { - expectTypeOf(context.user).toEqualTypeOf(); - expectTypeOf(context.user.id).toBeString(); - expectTypeOf(context.user.type).toBeString(); - expectTypeOf(context.user.workspaceId).toBeString(); - return { userId: context.user.id }; + expectTypeOf(context.caller).toEqualTypeOf(); + if (!context.caller) return { userId: "anonymous" }; + expectTypeOf(context.caller.id).toEqualTypeOf(); + expectTypeOf(context.caller.type).toEqualTypeOf<"user" | "machine_user">(); + expectTypeOf(context.caller.workspaceId).toBeString(); + return { userId: context.caller.id }; }, }); }); @@ -310,14 +317,14 @@ describe("createResolver", () => { input: inputType, output: t.object({ summary: t.string() }), body: (context) => { - expectTypeOf(context.input.uuid).toBeString(); + expectTypeOf(context.input.uuid).toEqualTypeOf(); expectTypeOf(context.input.string).toBeString(); expectTypeOf(context.input.bool).toBeBoolean(); expectTypeOf(context.input.int).toBeNumber(); expectTypeOf(context.input.float).toBeNumber(); - expectTypeOf(context.input.date).toExtend(); - expectTypeOf(context.input.datetime).toExtend(); - expectTypeOf(context.input.time).toBeString(); + expectTypeOf(context.input.date).toEqualTypeOf(); + expectTypeOf(context.input.datetime).toEqualTypeOf(); + expectTypeOf(context.input.time).toEqualTypeOf(); return { summary: "ok" }; }, }); @@ -450,19 +457,21 @@ describe("createResolver", () => { expect(typeof resolver.body).toBe("function"); }); - test("creates resolver with authInvoker", () => { - const outputType = t.object({ result: t.string() }); + test("creates resolver with invoker", () => { + const outputType = t.object({ + result: t.string(), + }); const resolver = createResolver({ - name: "withAuthInvoker", + name: "withInvoker", operation: "query", output: outputType, body: () => ({ result: "ok" }), - authInvoker: { namespace: "my-auth", machineUserName: "batch-user" }, + invoker: "batch-user", }); - expect(resolver.name).toBe("withAuthInvoker"); - expect(resolver.authInvoker).toEqual({ namespace: "my-auth", machineUserName: "batch-user" }); + expect(resolver.name).toBe("withInvoker"); + expect(resolver.invoker).toBe("batch-user"); }); test("creates minimal resolver without optional fields", () => { diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index 24db05630c..bffccc2fd1 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -1,15 +1,14 @@ import { t, type TailorAnyField, type TailorField } from "#/configure/types/type"; import { brandValue } from "#/utils/brand"; -import type { AuthInvoker } from "#/configure/services/auth/index"; import type { MachineUserName } from "#/configure/types/machine-user"; -import type { TailorEnv, TailorInvoker, TailorUser } from "#/runtime/types"; +import type { TailorEnv, TailorPrincipal } from "#/runtime/types"; import type { InferFieldsOutput, output } from "#/types/helpers"; import type { ResolverInput } from "#/types/resolver.generated"; type Context | undefined> = { input: Input extends Record ? InferFieldsOutput : never; - user: TailorUser; - invoker?: TailorInvoker; + caller: TailorPrincipal | null; + invoker: TailorPrincipal | null; env: TailorEnv; }; @@ -35,19 +34,19 @@ type NormalizedOutput | undefined, Output extends TailorAnyField | Record, -> = Omit & +> = Omit & Readonly<{ input?: Input; output: NormalizedOutput; body: (context: Context) => OutputType | Promise>; - authInvoker?: AuthInvoker | MachineUserName; + invoker?: MachineUserName; }>; /** * Create a resolver definition for the Tailor SDK. * * The `body` function receives a context with `input` (typed from `config.input`), - * `user`, `invoker` (reflects `authInvoker` delegation), and `env`. + * `caller`, `invoker` (reflects configured machine-user delegation), and `env`. * The return value of `body` must match the `output` type. * * `output` accepts either a single TailorField (e.g. `t.string()`) or a @@ -70,7 +69,7 @@ type ResolverReturn< * input: { * id: t.string(), * }, - * body: async ({ input, user }) => { + * body: async ({ input, caller }) => { * const db = getDB("tailordb"); * const result = await db.selectFrom("User").selectAll().where("id", "=", input.id).executeTakeFirst(); * return { name: result?.name ?? "", email: result?.email ?? "" }; @@ -86,12 +85,12 @@ export function createResolver< Input extends Record | undefined = undefined, Output extends TailorAnyField | Record = TailorAnyField, >( - config: Omit & + config: Omit & Readonly<{ input?: Input; output: Output; body: (context: Context) => OutputType | Promise>; - authInvoker?: AuthInvoker | MachineUserName; + invoker?: MachineUserName; }>, ): ResolverReturn { // Check if output is already a TailorField using duck typing. diff --git a/packages/sdk/src/configure/services/tailordb/permission.ts b/packages/sdk/src/configure/services/tailordb/permission.ts index 5562ecebb1..e03847db11 100644 --- a/packages/sdk/src/configure/services/tailordb/permission.ts +++ b/packages/sdk/src/configure/services/tailordb/permission.ts @@ -1,4 +1,4 @@ -import type { InferredAttributeMap } from "#/runtime/types"; +import type { InferredAttributes } from "#/runtime/types"; // --- Permission types (UX-focused, for configure layer) --- @@ -19,7 +19,7 @@ import type { InferredAttributeMap } from "#/runtime/types"; * }; */ export type TailorTypePermission< - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Type extends object = object, > = { create: readonly ActionPermission<"record", User, Type, false>[]; @@ -30,7 +30,7 @@ export type TailorTypePermission< type ActionPermission< Level extends "record" | "gql" = "record" | "gql", - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Type extends object = object, Update extends boolean = boolean, > = @@ -50,14 +50,11 @@ type ActionPermission< | readonly [...PermissionCondition[], ...([] | [boolean])]; // multiple array condition export type TailorTypeGqlPermission< - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Type extends object = object, > = readonly GqlPermissionPolicy[]; -type GqlPermissionPolicy< - User extends object = InferredAttributeMap, - Type extends object = object, -> = { +type GqlPermissionPolicy = { conditions: readonly PermissionCondition<"gql", User, boolean, Type>[]; actions: "all" | readonly GqlPermissionAction[]; /** @@ -91,19 +88,19 @@ type BooleanArrayFieldKeys = { [K in keyof User]: User[K] extends boolean[] ? K : never; }[keyof User]; -type UserStringOperand = { +type UserStringOperand = { user: StringFieldKeys | "id"; }; -type UserStringArrayOperand = { +type UserStringArrayOperand = { user: StringArrayFieldKeys; }; -type UserBooleanOperand = { +type UserBooleanOperand = { user: BooleanFieldKeys | "_loggedIn"; }; -type UserBooleanArrayOperand = { +type UserBooleanArrayOperand = { user: BooleanArrayFieldKeys; }; @@ -160,7 +157,7 @@ type BooleanEqualityCondition< type EqualityCondition< Level extends "record" | "gql" = "record", - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, Type extends object = object, > = @@ -216,7 +213,7 @@ type BooleanContainsCondition< type ContainsCondition< Level extends "record" | "gql" = "record", - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, Type extends object = object, > = @@ -251,7 +248,7 @@ type HasAnyCondition< /** * Type representing a permission condition that combines user attributes, record fields, and literal values using comparison operators. * - * The User type is extended by `tailor.d.ts`, which is automatically generated when running `tailor-sdk generate`. + * The User type is extended by `tailor.d.ts`, which is automatically generated when running `tailor generate`. * Attributes enabled in the config file's `auth.userProfile.attributes` (or * `auth.machineUserAttributes` when userProfile is omitted) become available as types. * @example @@ -270,7 +267,7 @@ type HasAnyCondition< */ export type PermissionCondition< Level extends "record" | "gql" = "record", - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, Update extends boolean = boolean, Type extends object = object, > = diff --git a/packages/sdk/src/configure/services/tailordb/schema.test.ts b/packages/sdk/src/configure/services/tailordb/schema.test.ts index b732c98f08..1f7b5aec0e 100644 --- a/packages/sdk/src/configure/services/tailordb/schema.test.ts +++ b/packages/sdk/src/configure/services/tailordb/schema.test.ts @@ -1,10 +1,16 @@ // oxlint-disable vitest/expect-expect -- Type-only assertions are checked by TypeScript. import { describe, expectTypeOf, expect, test } from "vitest"; import { t } from "#/configure/types/index"; -import { unauthenticatedTailorUser } from "#/configure/user"; import { db, type TailorAnyDBField } from "./schema"; -import type { FieldValidateInput, ValidateConfig } from "#/configure/types/field.types"; -import type { TailorUser } from "#/runtime/types"; +import type { FieldValidateInput } from "#/configure/types/field.types"; +import type { + DateString, + DateTimeString, + DecimalString, + TimeString, + UUIDString, +} from "#/configure/types/scalar.types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { output, TypeLevelError } from "#/types/helpers"; import type { Hook } from "./types"; import type { StandardSchemaV1 } from "@standard-schema/spec"; @@ -15,7 +21,7 @@ describe("TailorDBField basic field type tests", () => { name: db.string(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; name: string; }>(); }); @@ -25,7 +31,7 @@ describe("TailorDBField basic field type tests", () => { age: db.int(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; age: number; }>(); }); @@ -35,7 +41,7 @@ describe("TailorDBField basic field type tests", () => { active: db.bool(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; active: boolean; }>(); }); @@ -45,48 +51,111 @@ describe("TailorDBField basic field type tests", () => { price: db.float(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; price: number; }>(); }); - test("uuid field outputs string type correctly", () => { + test("uuid field outputs UUID string type correctly", () => { const _uuidType = db.type("Test", { uuid: db.uuid(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; - uuid: string; + id: UUIDString; + uuid: UUIDString; }>(); }); - test("date field outputs string type correctly", () => { + test("date field outputs date string type correctly", () => { const _dateType = db.type("Test", { birthDate: db.date(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; - birthDate: string; + id: UUIDString; + birthDate: DateString; }>(); }); - test("datetime field outputs string | Date type correctly", () => { + test("datetime field outputs datetime string | Date type correctly", () => { const _datetimeType = db.type("Test", { timestamp: db.datetime(), }); expectTypeOf>().toMatchObjectType<{ - id: string; - timestamp: string | Date; + id: UUIDString; + timestamp: DateTimeString | Date; }>(); }); - test("time field outputs string type correctly", () => { + test("time field outputs time string type correctly", () => { const _timeType = db.type("Test", { openingTime: db.time(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; - openingTime: string; + id: UUIDString; + openingTime: TimeString; + }>(); + }); + + test("pickFields preserves the generated id UUID type", () => { + const _schemaType = t.object({ + ...db + .type("Test", { + name: db.string(), + }) + .pickFields(["id"], { optional: true }), + }); + + expectTypeOf>().toEqualTypeOf<{ + id?: UUIDString | null; + }>(); + }); + + test("pickFields recomputes output from the base field type", () => { + const _schemaType = t.object({ + ...db + .type("Test", { + names: db.string({ array: true }), + nickname: db.string({ optional: true }), + }) + .pickFields(["id", "names", "nickname"], { array: false, optional: false }), + }); + + expectTypeOf>().toEqualTypeOf<{ + id: UUIDString; + names: string; + nickname: string; + }>(); + }); + + test("pickFields recomputes enum and nested object output from the base field type", () => { + const _schemaType = t.object({ + ...db + .type("Test", { + status: db.enum(["active", "inactive"], { array: true }), + profile: db.object({ name: db.string() }, { array: true }), + }) + .pickFields(["status", "profile"], { array: false }), + }); + + expectTypeOf>().toEqualTypeOf<{ + status: "active" | "inactive"; + profile: { name: string }; + }>(); + }); + + test("pickFields preserves existing options that are not overridden", () => { + const _schemaType = t.object({ + ...db + .type("Test", { + names: db.string({ array: true }), + nickname: db.string({ optional: true }), + }) + .pickFields(["names", "nickname"], { array: true }), + }); + + expectTypeOf>().toEqualTypeOf<{ + names: string[]; + nickname?: string[] | null; }>(); }); }); @@ -97,7 +166,7 @@ describe("TailorDBField optional option tests", () => { description: db.string({ optional: true }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; description?: string | null; }>(); }); @@ -109,7 +178,7 @@ describe("TailorDBField optional option tests", () => { count: db.int({ optional: true }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; title: string; description?: string | null; count?: number | null; @@ -123,7 +192,7 @@ describe("TailorDBField array option tests", () => { tags: db.string({ array: true }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; tags: string[]; }>(); }); @@ -133,7 +202,7 @@ describe("TailorDBField array option tests", () => { items: db.string({ optional: true, array: true }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; items?: string[] | null; }>(); }); @@ -145,7 +214,7 @@ describe("TailorDBField array option tests", () => { flags: db.bool({ array: true }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; tags: string[]; numbers: number[]; flags: boolean[]; @@ -200,7 +269,7 @@ describe("TailorDBField enum field tests", () => { priority: db.enum(["high", "medium", "low"], { optional: true }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; priority?: "high" | "medium" | "low" | null; }>(); }); @@ -221,7 +290,7 @@ describe("TailorDBField enum field tests", () => { categories: db.enum(["a", "b", "c"], { array: true }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; categories: ("a" | "b" | "c")[]; }>(); }); @@ -363,7 +432,7 @@ describe("TailorDBField modifier chain tests", () => { email: db.string().index(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; email: string; }>(); }); @@ -373,7 +442,7 @@ describe("TailorDBField modifier chain tests", () => { username: db.string().unique(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; username: string; }>(); }); @@ -444,7 +513,7 @@ describe("TailorDBField type error message tests", () => { TypeLevelError<"hooks cannot be set on nested type fields"> >(); - const validated = db.string().validate(() => true); + const validated = db.string().validate(() => undefined); expectTypeOf(validated.validate).toEqualTypeOf< TypeLevelError<".validate() has already been set"> >(); @@ -461,6 +530,23 @@ describe("TailorDBField type error message tests", () => { expectTypeOf(db.string({ optional: true }).serial).toEqualTypeOf< TypeLevelError<"serial can only be set on non-array integer or string fields"> >(); + + const defaulted = db.string().default("hello"); + expectTypeOf(defaulted.default).toEqualTypeOf< + TypeLevelError<".default() has already been set"> + >(); + + expectTypeOf(db.object({ x: db.string() }).default).toEqualTypeOf< + TypeLevelError<"default cannot be set on nested type fields"> + >(); + + expectTypeOf(db.string().serial({ start: 0 }).default).toEqualTypeOf< + TypeLevelError<"default cannot be set on serial fields"> + >(); + + expectTypeOf(db.string({ optional: true }).default).toEqualTypeOf< + TypeLevelError<"default cannot be set on optional fields"> + >(); }); }); @@ -478,9 +564,9 @@ describe("TailorDBField relation modifier tests", () => { }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; title: string; - authorId: string; + authorId: UUIDString; }>(); }); @@ -510,7 +596,7 @@ describe("TailorDBField hooks modifier tests", () => { }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; name: string; }>(); }); @@ -526,65 +612,59 @@ describe("TailorDBField hooks modifier tests", () => { test("hooks modifier on string field receives string", () => { const _hooks = db.string().hooks; - expectTypeOf[0]>().toEqualTypeOf>(); + expectTypeOf[0]>().toEqualTypeOf>(); }); test("hooks modifier on optional field receives null", () => { const _hooks = db.string({ optional: true }).hooks; - expectTypeOf[0]>().toEqualTypeOf>(); + expectTypeOf[0]>().toEqualTypeOf>(); }); }); describe("TailorDBField validate modifier tests", () => { test("validate modifier does not affect type", () => { const _validateType = db.type("Test", { - email: db.string().validate(() => true), + email: db.string().validate(() => undefined), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; email: string; }>(); }); - test("validate modifier can receive object with message", () => { + test("validate modifier can receive function returning error message", () => { const _validateType = db.type("Test", { - email: db.string().validate([({ value }) => value.includes("@"), "Email must contain @"]), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Email must contain @" : undefined)), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; email: string; }>(); - // Validate that the validation is stored correctly in metadata const fieldMetadata = _validateType.fields.email.metadata; expect(fieldMetadata.validate).toBeDefined(); expect(fieldMetadata.validate).toHaveLength(1); - // Error message is part of the tuple [fn, message] - expect(fieldMetadata.validate?.[0]).toEqual([expect.any(Function), "Email must contain @"]); }); test("validate modifier can receive multiple validators", () => { const _validateType = db.type("Test", { - password: db - .string() - .validate( - ({ value }) => value.length >= 8, - [({ value }) => /[A-Z]/.test(value), "Password must contain uppercase letter"], - ), + password: db.string().validate( + ({ value }) => (value.length < 8 ? "Password must be at least 8 characters" : undefined), + ({ value }) => + !/[A-Z]/.test(value) ? "Password must contain uppercase letter" : undefined, + ), }); const fieldMetadata = _validateType.fields.password.metadata; expect(fieldMetadata.validate).toHaveLength(2); - // Second validator is a tuple [fn, errorMessage] - expect((fieldMetadata.validate?.[1] as [unknown, string])[1]).toBe( - "Password must contain uppercase letter", - ); }); test("calling validate modifier more than once causes type error", () => { - const validated = db.string().validate(() => true); + const validated = db.string().validate(() => undefined); // @ts-expect-error validate() cannot be called after validate() has already been called - validated.validate(() => true); + validated.validate(() => undefined); }); test("validate modifier on string field receives string", () => { @@ -686,14 +766,14 @@ describe("TailorDBType withTimestamps option tests", () => { ...db.fields.timestamps(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; name: string; - createdAt: string | Date; - updatedAt?: string | Date | null; + createdAt: DateTimeString | Date; + updatedAt: DateTimeString | Date; }>(); }); - const timestampHookUser = unauthenticatedTailorUser; + const timestampHookInvoker = null; test("createdAt create hook respects a user-specified value", () => { const { createdAt } = db.fields.timestamps(); @@ -701,7 +781,13 @@ describe("TailorDBType withTimestamps option tests", () => { expect(createHook).toBeDefined(); const specified = new Date("2025-02-10T09:00:00Z"); - const result = createHook!({ value: specified, data: {}, user: timestampHookUser }); + const now = new Date("2025-06-01T00:00:00Z"); + const result = createHook!({ + value: specified, + oldValue: null, + invoker: timestampHookInvoker, + now, + }); expect(result).toBe(specified); }); @@ -710,12 +796,60 @@ describe("TailorDBType withTimestamps option tests", () => { const createHook = createdAt.metadata.hooks?.create; expect(createHook).toBeDefined(); - const before = Date.now(); - const result = createHook!({ value: null, data: {}, user: timestampHookUser }); - const after = Date.now(); - expect(result).toBeInstanceOf(Date); - expect((result as Date).getTime()).toBeGreaterThanOrEqual(before); - expect((result as Date).getTime()).toBeLessThanOrEqual(after); + const now = new Date("2025-06-01T12:00:00Z"); + const result = createHook!({ + value: null, + oldValue: null, + invoker: timestampHookInvoker, + now, + }); + expect(result).toBe(now); + }); + + test("updatedAt create hook respects a user-specified value", () => { + const { updatedAt } = db.fields.timestamps(); + const createHook = updatedAt.metadata.hooks?.create; + expect(createHook).toBeDefined(); + + const specified = new Date("2025-02-10T09:00:00Z"); + const now = new Date("2025-06-01T12:00:00Z"); + const result = createHook!({ + value: specified, + oldValue: null, + invoker: timestampHookInvoker, + now, + }); + expect(result).toBe(specified); + }); + + test("updatedAt create hook falls back to now when no value is given", () => { + const { updatedAt } = db.fields.timestamps(); + const createHook = updatedAt.metadata.hooks?.create; + expect(createHook).toBeDefined(); + + const now = new Date("2025-06-01T12:00:00Z"); + const result = createHook!({ + value: null, + oldValue: null, + invoker: timestampHookInvoker, + now, + }); + expect(result).toBe(now); + }); + + test("updatedAt update hook uses now", () => { + const { updatedAt } = db.fields.timestamps(); + const updateHook = updatedAt.metadata.hooks?.update; + expect(updateHook).toBeDefined(); + + const now = new Date("2025-06-01T12:00:00Z"); + const result = updateHook!({ + value: null, + oldValue: null, + invoker: timestampHookInvoker, + now, + }); + expect(result).toBe(now); }); }); @@ -734,7 +868,7 @@ describe("TailorDBType composite type tests", () => { closingTime: db.time(), }); expectTypeOf>().toMatchObjectType<{ - id: string; + id: UUIDString; name: string; email: string; age?: number | null; @@ -742,9 +876,9 @@ describe("TailorDBType composite type tests", () => { tags: string[]; role: "admin" | "user" | "guest"; score: number; - birthDate: string; - lastLogin?: string | Date | null; - closingTime: string; + birthDate: DateString; + lastLogin?: DateTimeString | Date | null; + closingTime: TimeString; }>(); }); }); @@ -755,7 +889,7 @@ describe("TailorDBType edge case tests", () => { value: db.string(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; value: string; }>(); }); @@ -767,7 +901,7 @@ describe("TailorDBType edge case tests", () => { c: db.bool({ optional: true }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; a?: string | null; b?: number | null; c?: boolean | null; @@ -781,7 +915,7 @@ describe("TailorDBType edge case tests", () => { booleans: db.bool({ array: true }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; strings: string[]; numbers: number[]; booleans: boolean[]; @@ -807,7 +941,7 @@ describe("TailorDBType type consistency tests", () => { name: db.string(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; name: string; }>(); }); @@ -918,11 +1052,11 @@ describe("TailorDBType plural form tests", () => { }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; title: string; content?: string | null; - createdAt: string | Date; - updatedAt?: string | Date | null; + createdAt: DateTimeString | Date; + updatedAt: DateTimeString | Date; }>(); expect(_postType.name).toBe("Post"); @@ -930,20 +1064,18 @@ describe("TailorDBType plural form tests", () => { }); test("validation and plural form coexist in tuple format", () => { - const _userType = db - .type(["User", "Users"], { - name: db.string(), - email: db.string(), - }) - .validate({ - name: [({ value }) => value.length > 0], - email: [({ value }) => value.includes("@"), "Invalid email format"], - }); + const _userType = db.type(["User", "Users"], { + name: db + .string() + .validate(({ value }) => (value.length <= 0 ? "Name must not be empty" : undefined)), + email: db + .string() + .validate(({ value }) => (!value.includes("@") ? "Invalid email format" : undefined)), + }); expect(_userType.name).toBe("User"); expect(_userType.metadata.settings?.pluralForm).toBe("Users"); - // Validate that the validation function is stored correctly in metadata const emailMetadata = _userType.fields.email.metadata; expect(emailMetadata.validate).toBeDefined(); expect(emailMetadata.validate).toHaveLength(1); @@ -974,165 +1106,115 @@ describe("TailorDBType hooks modifier tests", () => { name: db.string(), }) .hooks({ - name: { - create: () => "created", - update: () => "updated", - }, + create: ({ input }) => ({ name: `${input.name}_created` }), + update: ({ input }) => ({ name: `${input.name}_updated` }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; name: string; }>(); }); - test("setting hooks on id causes type error", () => { - db.type("Test", { - name: db.string(), - }).hooks({ - // @ts-expect-error hooks() cannot be called on the "id" field - id: { - create: () => "created", - }, + test("type hook stores create/update functions in metadata", () => { + const createFn = () => ({ name: "created" }); + const updateFn = () => ({ name: "updated" }); + const hookType = db.type("Test", { name: db.string() }).hooks({ + create: createFn, + update: updateFn, }); + expect(hookType.metadata.typeHook?.create).toBe(createFn); + expect(hookType.metadata.typeHook?.update).toBe(updateFn); }); - test("setting hooks on nested field causes type error", () => { - db.type("Test", { - name: db.object({ - first: db.string(), - last: db.string(), - }), - // @ts-expect-error hooks() cannot be called on nested fields - }).hooks({ - name: { - create: () => "created", - }, + test("type hook return type excludes id", () => { + db.type("Test", { name: db.string() }).hooks({ + // @ts-expect-error id cannot be returned from type hook + create: () => ({ id: "00000000-0000-0000-0000-000000000001" }), }); }); - test("hooks modifier on string field receives string", () => { - const testType = db.type("Test", { name: db.string() }); - const _hooks = testType.hooks; - type ExpectedHooksParam = Parameters[0]; - type ActualNameType = Exclude; - - expectTypeOf().toEqualTypeOf< - Hook< - { - id: string; - readonly name: string; - }, - string - > - >(); - }); - - test("hooks modifier on optional field receives null", () => { - const testType = db.type("Test", { - name: db.string({ optional: true }), + test("type hook input args receive correct types", () => { + db.type("Test", { name: db.string(), age: db.int({ optional: true }) }).hooks({ + create: ({ input, oldRecord, invoker, now }) => { + expectTypeOf(input.name).toEqualTypeOf(); + expectTypeOf(input.age).toEqualTypeOf(); + expectTypeOf(oldRecord).toBeNullable(); + expectTypeOf(invoker).toBeNullable(); + expectTypeOf(now).toEqualTypeOf(); + return {}; + }, }); - const _hooks = testType.hooks; - type ExpectedHooksParam = Parameters[0]; - type ActualNameType = Exclude; - - expectTypeOf().toEqualTypeOf< - Hook< - { - id: string; - name?: string | null; - }, - string | null - > - >(); }); }); -describe("TailorDBType validate modifier tests", () => { - test("validate modifier can receive function", () => { - const _validateType = db +describe("TailorDBType type-level validate (function form) tests", () => { + test("accepts type-level validate function", () => { + const _type = db .type("Test", { + name: db.string(), email: db.string(), }) - .validate({ - email: () => true, + .validate(({ newRecord }, issues) => { + if (!newRecord.name) issues("name", "Name is required"); + if (!newRecord.email) issues("email", "Email is required"); }); - expectTypeOf>().toEqualTypeOf<{ - id: string; + expectTypeOf>().toEqualTypeOf<{ + id: UUIDString; + name: string; email: string; }>(); - const fieldMetadata = _validateType.fields.email.metadata; - expect(fieldMetadata.validate).toHaveLength(1); }); - test("validate modifier can receive object with message", () => { - const _validateType = db - .type("Test", { - email: db.string(), - }) - .validate({ - email: [({ value }) => value.includes("@"), "Email must contain @"], - }); + test("issues function only accepts valid field paths", () => { + db.type("Test", { + name: db.string(), + email: db.string(), + }).validate(({ newRecord: _newRecord }, issues) => { + issues("name", "ok"); + issues("email", "ok"); + // @ts-expect-error "nonexistent" is not a valid field path + issues("nonexistent", "bad"); + }); + }); - const fieldMetadata = _validateType.fields.email.metadata; - expect(fieldMetadata.validate).toHaveLength(1); - // Validator is a tuple [fn, errorMessage] - expect((fieldMetadata.validate?.[0] as [unknown, string])[1]).toBe("Email must contain @"); + test("issues function accepts dotted paths for nested fields", () => { + db.type("Test", { + profile: db.object({ + displayName: db.string(), + email: db.string(), + }), + }).validate((_args, issues) => { + issues("profile", "ok"); + issues("profile.displayName", "ok"); + issues("profile.email", "ok"); + // @ts-expect-error "profile.nonexistent" is not a valid field path + issues("profile.nonexistent", "bad"); + }); }); - test("validate modifier can receive multiple validators", () => { - const _validateType = db + test("type-level validate function stores in metadata", () => { + const type = db .type("Test", { - password: db.string(), + name: db.string(), }) - .validate({ - password: [ - ({ value }) => value.length >= 8, - [({ value }) => /[A-Z]/.test(value), "Password must contain uppercase letter"], - ], - }); - - const fieldMetadata = _validateType.fields.password.metadata; - expect(fieldMetadata.validate).toHaveLength(2); - // Second validator is a tuple [fn, errorMessage] - expect((fieldMetadata.validate?.[1] as [unknown, string])[1]).toBe( - "Password must contain uppercase letter", - ); - }); + .validate((_args, _issues) => {}); - test("type error occurs when validate is already set on TailorDBField", () => { - db.type("Test", { - name: db.string().validate(() => true), - // @ts-expect-error validate() cannot be called after validate() has already been called - }).validate({ - name: () => true, - }); + expect(type.metadata.typeValidate).toBeDefined(); }); - test("setting validate on id causes type error", () => { + test("type-level validate receives newRecord and oldRecord", () => { db.type("Test", { name: db.string(), - }).validate({ - // @ts-expect-error validate() cannot be called on the "id" field - id: () => true, + age: db.int({ optional: true }), + }).validate(({ newRecord, oldRecord }) => { + expectTypeOf(newRecord.name).toEqualTypeOf(); + expectTypeOf(newRecord.age).toEqualTypeOf(); + if (oldRecord) { + expectTypeOf(oldRecord.name).toEqualTypeOf(); + } }); }); - - test("validate modifier on string field receives string", () => { - const _validate = db.type("Test", { name: db.string() }).validate; - expectTypeOf>().toExtend< - Parameters[0]["name"] - >(); - }); - - test("validate modifier on optional field receives null", () => { - const _validate = db.type("Test", { - name: db.string({ optional: true }), - }).validate; - expectTypeOf>().toExtend< - Parameters[0]["name"] - >(); - }); }); describe("db.object tests", () => { @@ -1144,7 +1226,7 @@ describe("db.object tests", () => { }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; user: { name: string; age: number; @@ -1171,7 +1253,7 @@ describe("db.object tests", () => { }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; user: { name: string; age?: number | null; @@ -1191,7 +1273,7 @@ describe("db.object tests", () => { ), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; user?: { name: string; avatar?: string | null; @@ -1210,7 +1292,7 @@ describe("db.object tests", () => { ), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; users: { name: string; age: number; @@ -1227,7 +1309,7 @@ describe("db.object tests", () => { }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; user: { name: string; tags: string[]; @@ -1248,7 +1330,7 @@ describe("db.object tests", () => { ), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; optionalUsers?: | { name: string; @@ -1267,7 +1349,7 @@ describe("db.object tests", () => { }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; settings: { enabled: boolean; push?: boolean | null; @@ -1285,7 +1367,7 @@ describe("db.object tests", () => { }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; product: { name: string; price: number; @@ -1376,7 +1458,7 @@ describe("TailorDBField fluent API type preservation", () => { .string() .description("Email address") .index() - .validate(({ value }) => value.includes("@")); + .validate(({ value }) => (!value.includes("@") ? "Invalid email" : undefined)); expectTypeOf>().toEqualTypeOf(); }); @@ -1391,7 +1473,7 @@ describe("TailorDBField fluent API type preservation", () => { .uuid() .description("User reference") .relation({ type: "n-1", toward: { type: User } }); - expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); }); }); @@ -1443,8 +1525,8 @@ describe("TailorDBType files method tests", () => { }); describe("TailorDBField runtime validation tests", () => { - const user: TailorUser = { - id: "test", + const invoker: TailorPrincipal = { + id: "123e4567-e89b-12d3-a456-426614174000", type: "user", workspaceId: "workspace-test", attributes: {}, @@ -1462,41 +1544,41 @@ describe("TailorDBField runtime validation tests", () => { test("validates string field values", () => { const field = db.string(); - expectParsedValue(field.parse({ value: "hello", data, user }), "hello"); + expectParsedValue(field.parse({ value: "hello", data, invoker }), "hello"); - const bad = field.parse({ value: 123, data, user }); + const bad = field.parse({ value: 123, data, invoker }); expect(bad.issues?.[0]?.message).toBe("Expected a string: received 123"); }); test("validates enum values", () => { const field = db.enum(["active", "inactive"]); - expectParsedValue(field.parse({ value: "active", data, user }), "active"); + expectParsedValue(field.parse({ value: "active", data, invoker }), "active"); - const bad = field.parse({ value: "unknown", data, user }); + const bad = field.parse({ value: "unknown", data, invoker }); expect(bad.issues?.[0]?.message).toBe("Must be one of [active, inactive]: received unknown"); }); test("validates integer values", () => { const field = db.int(); - expectParsedValue(field.parse({ value: 42, data, user }), 42); + expectParsedValue(field.parse({ value: 42, data, invoker }), 42); - const bad = field.parse({ value: "not-a-number", data, user }); + const bad = field.parse({ value: "not-a-number", data, invoker }); expect(bad.issues?.[0]?.message).toBe("Expected an integer: received not-a-number"); }); test("validates float values", () => { const field = db.float(); - expectParsedValue(field.parse({ value: 3.14, data, user }), 3.14); + expectParsedValue(field.parse({ value: 3.14, data, invoker }), 3.14); - const bad = field.parse({ value: "not-a-number", data, user }); + const bad = field.parse({ value: "not-a-number", data, invoker }); expect(bad.issues?.[0]?.message).toBe("Expected a number: received not-a-number"); }); test("validates boolean values", () => { const field = db.bool(); - expectParsedValue(field.parse({ value: true, data, user }), true); + expectParsedValue(field.parse({ value: true, data, invoker }), true); - const bad = field.parse({ value: "true", data, user }); + const bad = field.parse({ value: "true", data, invoker }); expect(bad.issues?.[0]?.message).toBe("Expected a boolean: received true"); }); @@ -1505,57 +1587,109 @@ describe("TailorDBField runtime validation tests", () => { name: db.string(), age: db.int({ optional: true }), }); - expectParsedValue(field.parse({ value: { name: "test", age: 30 }, data, user }), { + expectParsedValue(field.parse({ value: { name: "test", age: 30 }, data, invoker }), { name: "test", age: 30, }); - const bad = field.parse({ value: { name: 123 }, data, user }); + const bad = field.parse({ value: { name: 123 }, data, invoker }); expect(bad.issues?.[0]?.path).toEqual(["name"]); expect(bad.issues?.[0]?.message).toBe("Expected a string: received 123"); }); test("validates array values", () => { const field = db.int({ array: true }); - expectParsedValue(field.parse({ value: [1, 2, 3], data, user }), [1, 2, 3]); + expectParsedValue(field.parse({ value: [1, 2, 3], data, invoker }), [1, 2, 3]); }); test("validates UUID format", () => { const field = db.uuid(); expectParsedValue( - field.parse({ value: "123e4567-e89b-12d3-a456-426614174000", data, user }), + field.parse({ value: "123e4567-e89b-12d3-a456-426614174000", data, invoker }), "123e4567-e89b-12d3-a456-426614174000", ); - const bad = field.parse({ value: "not-a-uuid", data, user }); + const bad = field.parse({ value: "not-a-uuid", data, invoker }); expect(bad.issues?.[0]?.message).toBe("Expected a valid UUID: received not-a-uuid"); }); test("validates date format", () => { const field = db.date(); - expectParsedValue(field.parse({ value: "2025-01-01", data, user }), "2025-01-01"); + expectParsedValue(field.parse({ value: "2025-01-01", data, invoker }), "2025-01-01"); - const bad = field.parse({ value: "2025/01/01", data, user }); + const bad = field.parse({ value: "2025/01/01", data, invoker }); expect(bad.issues?.[0]?.message).toBe( 'Expected to match "yyyy-MM-dd" format: received 2025/01/01', ); + + const calendarDateShape = field.parse({ value: "2025-02-30", data, invoker }); + expect(calendarDateShape.issues).toBeUndefined(); + if (calendarDateShape.issues) { + throw new Error("Unexpected issues"); + } + expect(calendarDateShape.value).toBe("2025-02-30"); + }); + + test("validates datetime format", () => { + const field = db.datetime(); + for (const value of [ + "2025-01-01T10:11:12Z", + "2025-01-01T10:11:12.123456Z", + "2025-01-01T10:11:12+09:00", + "2025-01-01t10:11:12-08:00", + "2025-02-30T10:11:12Z", + ]) { + const ok = field.parse({ value, data, invoker }); + expect(ok.issues).toBeUndefined(); + if (ok.issues) { + throw new Error("Unexpected issues"); + } + expect(ok.value).toBe(value); + } + + const bad = field.parse({ + value: "2025-01-01T10:11:12+0900", + data, + invoker, + }); + expect(bad.issues?.[0]?.message).toBe( + "Expected to match ISO format: received 2025-01-01T10:11:12+0900", + ); + + const invalidTime = field.parse({ + value: "2025-01-01T25:11:12Z", + data, + invoker, + }); + expect(invalidTime.issues?.[0]?.message).toBe( + "Expected to match ISO format: received 2025-01-01T25:11:12Z", + ); + + const invalidOffset = field.parse({ + value: "2025-01-01T10:11:12+24:00", + data, + invoker, + }); + expect(invalidOffset.issues?.[0]?.message).toBe( + "Expected to match ISO format: received 2025-01-01T10:11:12+24:00", + ); }); test("validates time format", () => { const field = db.time(); - expectParsedValue(field.parse({ value: "10:11", data, user }), "10:11"); + expectParsedValue(field.parse({ value: "10:11", data, invoker }), "10:11"); - const bad = field.parse({ value: "10:11:12", data, user }); + const bad = field.parse({ value: "10:11:12", data, invoker }); expect(bad.issues?.[0]?.message).toBe('Expected to match "HH:mm" format: received 10:11:12'); }); test("validates required and optional handling", () => { const requiredField = db.string(); - const requiredMissing = requiredField.parse({ value: undefined, data, user }); + const requiredMissing = requiredField.parse({ value: undefined, data, invoker }); expect(requiredMissing.issues?.[0]?.message).toBe("Required field is missing"); const optionalField = db.string({ optional: true }); - expectParsedValue(optionalField.parse({ value: undefined, data, user }), null); + expectParsedValue(optionalField.parse({ value: undefined, data, invoker }), null); }); }); @@ -1672,7 +1806,9 @@ describe("TailorDBField immutability", () => { test("field.validate() returns a new field without mutating the original", () => { const original = db.string(); - const withValidate = original.validate(({ value }) => value.length > 0); + const withValidate = original.validate(({ value }) => + value.length <= 0 ? "Must not be empty" : undefined, + ); expect(withValidate).not.toBe(original); expect(original.metadata.validate).toBeUndefined(); @@ -1748,23 +1884,25 @@ describe("TailorDBField immutability", () => { }); describe("TailorDBType does not mutate shared fields", () => { - test("type.hooks() does not mutate the shared field", () => { + test("type.hooks() does not affect other types sharing the same field", () => { const sharedField = db.string(); - const typeA = db.type("TypeA", { name: sharedField }).hooks({ name: { create: () => "A" } }); + const typeA = db.type("TypeA", { name: sharedField }).hooks({ create: () => ({ name: "A" }) }); const typeB = db.type("TypeB", { name: sharedField }); - expect(typeA.fields.name.metadata.hooks).toBeDefined(); - expect(typeB.fields.name.metadata.hooks).toBeUndefined(); + expect(typeA.metadata.typeHook?.create).toBeDefined(); + expect(typeB.metadata.typeHook).toBeUndefined(); expect(sharedField.metadata.hooks).toBeUndefined(); }); test("type.validate() does not mutate the shared field", () => { const sharedField = db.string(); - const typeA = db - .type("TypeA", { email: sharedField }) - .validate({ email: ({ value }) => value.includes("@") }); + const typeA = db.type("TypeA", { + email: sharedField.validate(({ value }) => + !value.includes("@") ? "Invalid email" : undefined, + ), + }); const typeB = db.type("TypeB", { email: sharedField }); expect(typeA.fields.email.metadata.validate).toBeDefined(); @@ -1776,9 +1914,8 @@ describe("TailorDBType does not mutate shared fields", () => { const nameField = db.string(); const fields = { name: nameField }; - db.type("TypeA", fields).hooks({ name: { create: () => "hooked" } }); + db.type("TypeA", fields).hooks({ create: () => ({ name: "hooked" }) }); - // The fields record should still reference the original field instance expect(fields.name).toBe(nameField); }); @@ -1786,9 +1923,12 @@ describe("TailorDBType does not mutate shared fields", () => { const emailField = db.string(); const fields = { email: emailField }; - db.type("TypeA", fields).validate({ email: ({ value }) => value.includes("@") }); + db.type("TypeA", { + email: emailField.validate(({ value }) => + !value.includes("@") ? "Invalid email" : undefined, + ), + }); - // The fields record should still reference the original field instance expect(fields.email).toBe(emailField); }); }); @@ -1881,7 +2021,8 @@ describe("TailorDBField clone tests", () => { }); test("clones validate correctly", () => { - const validator = ({ value }: { value: string }) => value.length > 0; + const validator = ({ value }: { value: string }) => + value.length <= 0 ? "Must not be empty" : undefined; const original = db.string().validate(validator); const cloned = original.clone(); @@ -1892,20 +2033,6 @@ describe("TailorDBField clone tests", () => { expect(cloned.metadata.validate).not.toBe(original.metadata.validate); }); - test("clones validate with tuple format correctly", () => { - const validator = ({ value }: { value: string }) => value.length > 0; - const original = db.string().validate([validator, "Value must not be empty"]); - const cloned = original.clone(); - - expect(cloned.metadata.validate).toBeDefined(); - expect(cloned.metadata.validate).toHaveLength(1); - expect(cloned.metadata.validate?.[0]).toEqual([validator, "Value must not be empty"]); - - // Verify deep copy (different reference for array and tuple) - expect(cloned.metadata.validate).not.toBe(original.metadata.validate); - expect(cloned.metadata.validate?.[0]).not.toBe(original.metadata.validate?.[0]); - }); - test("clones serial config correctly", () => { const original = db.int().serial({ start: 100 }); const cloned = original.clone(); @@ -1953,26 +2080,83 @@ describe("TailorDBField clone tests", () => { expect(cloned.fields.name).not.toBe(original.fields.name); expect(cloned.fields.age).not.toBe(original.fields.age); }); + + test("clone recomputes output from the base field type", () => { + const clonedArray = db.string({ array: true }).clone({ array: true }); + const clonedScalar = db.string({ array: true }).clone({ array: false }); + const clonedRequired = db.string({ optional: true }).clone({ optional: false }); + const clonedUnchanged = db.string({ optional: true, array: true }).clone(); + const clonedOptionalArray = db.string({ optional: true }).clone({ array: true }); + const clonedRequiredArray = db + .string({ optional: true, array: true }) + .clone({ optional: false }); + const clonedEnumArray = db.enum(["active", "inactive"], { array: true }).clone(); + const clonedEnumScalar = db.enum(["active", "inactive"], { array: true }).clone({ + array: false, + }); + const clonedObjectArray = db.object({ name: db.string() }, { array: true }).clone(); + const clonedObjectScalar = db.object({ name: db.string() }, { array: true }).clone({ + array: false, + }); + + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<("active" | "inactive")[]>(); + expectTypeOf>().toEqualTypeOf<"active" | "inactive">(); + expectTypeOf>().toEqualTypeOf<{ name: string }[]>(); + expectTypeOf>().toEqualTypeOf<{ name: string }>(); + + const _indexed = clonedScalar.index(); + }); + + test("clone preserves array guards for dynamic array overrides", () => { + const maybeArray = true as boolean; + const clonedExistingArray = db.string({ array: true }).clone({ array: maybeArray }); + const clonedExistingScalar = db.string().clone({ array: maybeArray }); + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf(clonedExistingArray.index).toEqualTypeOf< + TypeLevelError<"index cannot be set on array fields"> + >(); + expectTypeOf(clonedExistingArray.unique).toEqualTypeOf< + TypeLevelError<"unique cannot be set on array fields"> + >(); + expectTypeOf>().toEqualTypeOf(); + }); }); describe("TailorDBField decimal type tests", () => { - test("decimal field outputs string type correctly", () => { + test("decimal field outputs decimal string type correctly", () => { const _decimalType = db.type("Test", { price: db.decimal(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; - price: string; + id: UUIDString; + price: DecimalString; }>(); }); - test("optional decimal field outputs string | null type correctly", () => { + test("optional decimal field outputs decimal string | null type correctly", () => { const _decimalType = db.type("Test", { discount: db.decimal({ optional: true }), }); expectTypeOf>().toEqualTypeOf<{ - id: string; - discount?: string | null; + id: UUIDString; + discount?: DecimalString | null; }>(); }); @@ -2010,16 +2194,28 @@ describe("TailorDBField decimal type tests", () => { "-1.5e10", ])("decimal parse validates valid decimal string %s", (value) => { const field = db.decimal(); - const user = { id: "test", _loggedIn: true } as unknown as TailorUser; - expect(field.parse({ value, data: {}, user })).toEqual({ value }); + const invoker: TailorPrincipal = { + id: "123e4567-e89b-12d3-a456-426614174000", + type: "user", + workspaceId: "workspace-test", + attributes: {}, + attributeList: [], + }; + expect(field.parse({ value, data: {}, invoker })).toEqual({ value }); }); test.each(["abc", 123, "", "1_000_000", "0b1.1p-5", "1e", "e5", "."])( "decimal parse rejects invalid decimal string %s", (value) => { const field = db.decimal(); - const user = { id: "test", _loggedIn: true } as unknown as TailorUser; - expect(field.parse({ value, data: {}, user })).toHaveProperty("issues"); + const invoker: TailorPrincipal = { + id: "123e4567-e89b-12d3-a456-426614174000", + type: "user", + workspaceId: "workspace-test", + attributes: {}, + attributeList: [], + }; + expect(field.parse({ value, data: {}, invoker })).toHaveProperty("issues"); }, ); }); diff --git a/packages/sdk/src/configure/services/tailordb/schema.ts b/packages/sdk/src/configure/services/tailordb/schema.ts index 9b8acf23e5..1141ac59ef 100644 --- a/packages/sdk/src/configure/services/tailordb/schema.ts +++ b/packages/sdk/src/configure/services/tailordb/schema.ts @@ -27,15 +27,14 @@ import type { TailorFieldType, TailorToTs, FieldValidateInput, - ValidateConfig, - Validators, } from "#/configure/types/field.types"; +import type { UUIDString } from "#/configure/types/scalar.types"; import type { PluginAttachment, PluginConfigs } from "#/plugin/types"; -import type { InferredAttributeMap } from "#/runtime/types"; +import type { InferredAttributes } from "#/runtime/types"; import type { output, InferFieldsOutput, TypeLevelError } from "#/types/helpers"; import type { RawPermissions } from "#/types/tailordb.generated"; import type { TailorTypeGqlPermission, TailorTypePermission } from "./permission"; -import type { Hook, Hooks, ExcludeNestedDBFields, TypeFeatures } from "./types"; +import type { Hook, TypeHook, ExcludeNestedDBFields, TypeFeatures, TypeValidateFn } from "./types"; import type { StandardSchemaV1 } from "@standard-schema/spec"; // Erased DB fields stay assignable across builder method-state changes. @@ -56,6 +55,7 @@ export type TailorAnyDBField = Omit< index: AnyBuilderMethod; unique: AnyBuilderMethod; vector: AnyBuilderMethod; + default: AnyBuilderMethod; hooks: AnyBuilderMethod; validate: AnyBuilderMethod; serial: AnyBuilderMethod; @@ -90,6 +90,7 @@ type WithDBFieldHooks = Defined & { }; serial: false; }; +type WithDBFieldDefault = Defined & { default: true }; type WithDBFieldValidate = Defined & { validate: true }; type WithDBFieldSerial = Defined & { serial: true; @@ -99,8 +100,34 @@ type WithDBFieldCloneOptions< Defined extends DefinedDBFieldMetadata, NewOpt extends FieldOptions, > = Omit & { - array: NewOpt extends { array: true } ? true : Defined["array"]; + array: DBFieldCloneArrayOption; }; +type DBFieldCloneArrayOption< + Defined extends DefinedDBFieldMetadata, + NewOpt extends FieldOptions, +> = NewOpt extends { array: false } + ? false + : NewOpt extends { array: true } + ? true + : Defined["array"]; +type DBFieldCloneFieldOptions< + Defined extends DefinedDBFieldMetadata, + Output, + NewOpt extends FieldOptions, +> = { + optional: NewOpt extends { optional: infer NewOptional extends boolean } + ? NewOptional + : null extends Output + ? true + : false; + array: DBFieldCloneArrayOption; +}; +type DBFieldCloneOutput< + Defined extends DefinedDBFieldMetadata, + Output, + OutputBase, + NewOpt extends FieldOptions, +> = FieldOutput>; type FileKeyConflictError< Fields extends Record, User extends object, @@ -110,77 +137,82 @@ type FileKeyConflictError< TypeLevelError<"file keys cannot use existing field names"> > >; -type DBFieldDescriptionFn = ( +type DBFieldDescriptionFn = ( description: string, -) => TailorDBField, Output>; -type DBFieldRelationFn = { +) => TailorDBField, Output, OutputBase>; +type DBFieldRelationFn = { ( config: RelationConfig, - ): TailorDBField, Output>; - (config: S): TailorDBField, Output>; + ): TailorDBField, Output, OutputBase>; + ( + config: S, + ): TailorDBField, Output, OutputBase>; }; -type DBFieldIndexFn = () => TailorDBField< - WithDBFieldIndex, - Output ->; -type DBFieldUniqueFn = () => TailorDBField< - WithDBFieldUnique, - Output ->; -type DBFieldVectorFn = () => TailorDBField< - WithDBFieldVector, - Output ->; -type DBFieldHooksFn = < - const H extends Hook, +type DBFieldIndexFn< + Defined extends DefinedDBFieldMetadata, + Output, + OutputBase, +> = () => TailorDBField, Output, OutputBase>; +type DBFieldUniqueFn< + Defined extends DefinedDBFieldMetadata, + Output, + OutputBase, +> = () => TailorDBField, Output, OutputBase>; +type DBFieldVectorFn< + Defined extends DefinedDBFieldMetadata, + Output, + OutputBase, +> = () => TailorDBField, Output, OutputBase>; +type DBFieldHooksFn = < + const H extends Hook, >( hooks: H, -) => TailorDBField, Output>; -type DBFieldValidateFn = ( +) => TailorDBField, Output, OutputBase>; +type DBFieldValidateFn = ( ...validate: FieldValidateInput[] -) => TailorDBField, Output>; -type DBFieldSerialFn = ( +) => TailorDBField, Output, OutputBase>; +type DBFieldSerialFn = ( config: SerialConfig, -) => TailorDBField, Output>; -type DBFieldDescriptionMethod = +) => TailorDBField, Output, OutputBase>; +type DBFieldDescriptionMethod = IsAny extends true - ? DBFieldDescriptionFn + ? DBFieldDescriptionFn : Defined extends { description: unknown } ? TypeLevelError<".description() has already been set"> - : DBFieldDescriptionFn; -type DBFieldRelationMethod = + : DBFieldDescriptionFn; +type DBFieldRelationMethod = IsAny extends true - ? DBFieldRelationFn + ? DBFieldRelationFn : Defined extends { relation: unknown } ? TypeLevelError<".relation() has already been set"> - : DBFieldRelationFn; -type DBFieldIndexMethod = + : DBFieldRelationFn; +type DBFieldIndexMethod = IsAny extends true - ? DBFieldIndexFn + ? DBFieldIndexFn : Defined extends { index: unknown } ? TypeLevelError<".index() has already been set"> : Defined extends { array: true } ? TypeLevelError<"index cannot be set on array fields"> - : DBFieldIndexFn; -type DBFieldUniqueMethod = + : DBFieldIndexFn; +type DBFieldUniqueMethod = IsAny extends true - ? DBFieldUniqueFn + ? DBFieldUniqueFn : Defined extends { unique: unknown } ? TypeLevelError<".unique() has already been set"> : Defined extends { array: true } ? TypeLevelError<"unique cannot be set on array fields"> - : DBFieldUniqueFn; -type DBFieldVectorMethod = + : DBFieldUniqueFn; +type DBFieldVectorMethod = IsAny extends true - ? DBFieldVectorFn + ? DBFieldVectorFn : Defined extends { vector: unknown } ? TypeLevelError<".vector() has already been set"> : Defined extends { type: "string"; array: false } - ? DBFieldVectorFn + ? DBFieldVectorFn : TypeLevelError<"vector can only be set on non-array string fields">; -type DBFieldHooksMethod = +type DBFieldHooksMethod = IsAny extends true - ? DBFieldHooksFn + ? DBFieldHooksFn : Defined extends { serial: true; hooks: { create: false; update: false }; @@ -192,29 +224,44 @@ type DBFieldHooksMethod = ? TypeLevelError<".hooks() has already been set"> : Defined extends { type: "nested" } ? TypeLevelError<"hooks cannot be set on nested type fields"> - : DBFieldHooksFn; -type DBFieldValidateMethod = + : DBFieldHooksFn; +type DBFieldValidateMethod = IsAny extends true - ? DBFieldValidateFn + ? DBFieldValidateFn : Defined extends { validate: unknown } ? TypeLevelError<".validate() has already been set"> - : DBFieldValidateFn; -type DBFieldSerialMethod = + : DBFieldValidateFn; +type DBFieldSerialMethod = IsAny extends true - ? DBFieldSerialFn + ? DBFieldSerialFn : Defined extends { serial: true } ? TypeLevelError<".serial() has already been set"> : Defined extends { serial: false } ? TypeLevelError<"serial cannot be set after hooks"> : IsAny extends true ? Defined extends { type: "integer" | "string"; array: false } - ? DBFieldSerialFn + ? DBFieldSerialFn : TypeLevelError<"serial can only be set on non-array integer or string fields"> : null extends Output ? TypeLevelError<"serial can only be set on non-array integer or string fields"> : Defined extends { type: "integer" | "string"; array: false } - ? DBFieldSerialFn + ? DBFieldSerialFn : TypeLevelError<"serial can only be set on non-array integer or string fields">; +type DBFieldDefaultFn = ( + value: Output extends null ? NonNullable : Output, +) => TailorDBField, Output>; +type DBFieldDefaultMethod = + IsAny extends true + ? DBFieldDefaultFn + : Defined extends { default: unknown } + ? TypeLevelError<".default() has already been set"> + : Defined extends { type: "nested" } + ? TypeLevelError<"default cannot be set on nested type fields"> + : Defined extends { serial: true } + ? TypeLevelError<"default cannot be set on serial fields"> + : null extends Output + ? TypeLevelError<"default cannot be set on optional fields"> + : DBFieldDefaultFn; /** * Full TailorDBField interface with builder methods. @@ -224,6 +271,7 @@ export interface TailorDBField< Defined extends DefinedDBFieldMetadata = DefinedDBFieldMetadata, // oxlint-disable-next-line no-explicit-any Output = any, + OutputBase = Output, > extends Omit, "fields"> { readonly fields: Record; _metadata: DBFieldMetadata; @@ -248,51 +296,61 @@ export interface TailorDBField< /** * Set a description for the field */ - description: DBFieldDescriptionMethod; + description: DBFieldDescriptionMethod; /** * Define a relation to another type. */ - relation: DBFieldRelationMethod; + relation: DBFieldRelationMethod; /** * Add an index to the field */ - index: DBFieldIndexMethod; + index: DBFieldIndexMethod; /** * Make the field unique (also adds an index) */ - unique: DBFieldUniqueMethod; + unique: DBFieldUniqueMethod; /** * Enable vector search on the field (string type only) */ - vector: DBFieldVectorMethod; + vector: DBFieldVectorMethod; + + /** + * Set a default value for the field on create. When the field is required, + * this makes it optional in the Create input — the default fills in when + * no value (or a nullish hook result) is provided. + * + * For datetime/date/time fields, pass `"now"` to use the operation timestamp. + */ + default: DBFieldDefaultMethod; /** * Add hooks for create/update operations on this field. */ - hooks: DBFieldHooksMethod; + hooks: DBFieldHooksMethod; /** * Add validation functions to the field. */ - validate: DBFieldValidateMethod; + validate: DBFieldValidateMethod; /** * Configure serial/auto-increment behavior */ - serial: DBFieldSerialMethod; + serial: DBFieldSerialMethod; /** * Clone the field with optional overrides for field options */ - clone( + clone>( options?: NewOpt, ): TailorDBField< WithDBFieldCloneOptions, - FieldOutput + DBFieldCloneOutput, + OutputBase >; } @@ -303,12 +361,12 @@ export interface TailorDBField< export interface TailorDBType< // oxlint-disable-next-line no-explicit-any Fields extends Record = any, - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, > extends TailorDBTypeBase { _description?: string; - hooks(hooks: Hooks): TailorDBType; - validate(validators: Validators): TailorDBType; + hooks(hook: TypeHook): TailorDBType; + validate(fn: TypeValidateFn): TailorDBType; features(features: Omit): TailorDBType; indexes(...indexes: IndexDef>[]): TailorDBType; files( @@ -335,13 +393,8 @@ export interface TailorDBType< keys: K[], options: Opt, ): { - [P in K]: Fields[P] extends TailorDBField - ? TailorDBField< - Omit & { - array: Opt extends { array: true } ? true : D["array"]; - }, - FieldOutput - > + [P in K]: Fields[P] extends TailorDBField + ? TailorDBField, DBFieldCloneOutput, OBase> : never; }; omitFields(keys: K[]): Omit; @@ -353,7 +406,7 @@ export interface TailorDBType< export type TailorDBInstance< // oxlint-disable-next-line no-explicit-any Fields extends Record = any, - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, > = TailorDBType; interface RelationConfig { @@ -396,7 +449,7 @@ type TailorDBFieldInstance< T extends TailorFieldType, Opt extends FieldOptions, OutputBase = TailorToTs[T], -> = TailorDBField, DBFieldOutput>; +> = TailorDBField, DBFieldOutput, OutputBase>; type TailorDBFieldRuntimeInstance< T extends TailorFieldType, Opt extends FieldOptions, @@ -415,7 +468,8 @@ type TailorDBFieldRuntime = Omit index(): object; unique(): object; vector(): object; - hooks(hooks: Hook): object; + default(value: unknown): object; + hooks(hooks: Hook): object; serial(config: SerialConfig): object; clone(options?: FieldOptions): TailorDBFieldRuntime; parse(args: FieldParseArgs): StandardSchemaV1.Result; @@ -522,7 +576,7 @@ function createTailorDBFieldRuntime< return parseInternal({ value: args.value, data: args.data, - user: args.user, + invoker: args.invoker, pathArray: [], }); }, @@ -557,7 +611,13 @@ function createTailorDBFieldRuntime< return cloneWith({ vector: true }); }, - hooks(hooks: Hook) { + // oxlint-disable-next-line no-explicit-any + default(value: any) { + // oxlint-disable-next-line no-explicit-any + return cloneWith({ default: value }) as any; + }, + + hooks(hooks: Hook) { return cloneWith({ hooks }); }, @@ -704,7 +764,7 @@ function date(options?: Opt) { /** * Create a datetime field (date and time). - * Format: ISO 8601 "yyyy-MM-ddTHH:mm:ssZ" + * Format: ISO 8601 "yyyy-MM-ddTHH:mm:ssZ" or "yyyy-MM-ddTHH:mm:ss+09:00" * @param options - Field configuration options * @returns A datetime field * @example db.datetime() @@ -737,7 +797,8 @@ function _enum( options?: Opt, ): TailorDBField< { type: "enum"; array: Opt extends { array: true } ? true : false }, - FieldOutput, Opt> + FieldOutput, Opt>, + AllowedValuesOutput > { return createField<"enum", Opt, AllowedValuesOutput>("enum", options, undefined, values); } @@ -756,7 +817,8 @@ function object< >(fields: F, options?: Opt) { return createField("nested", options, fields) as unknown as TailorDBField< { type: "nested"; array: Opt extends { array: true } ? true : false }, - FieldOutput, Opt> + FieldOutput, Opt>, + InferFieldsOutput >; } @@ -772,7 +834,7 @@ function object< function createTailorDBType< // oxlint-disable-next-line no-explicit-any const Fields extends Record = any, - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, >( name: string, fields: Fields, @@ -784,6 +846,10 @@ function createTailorDBType< const _permissions: RawPermissions = {}; let _files: Record = {}; const _plugins: PluginAttachment[] = []; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + let _typeHook: { create?: Function; update?: Function } | undefined; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + let _typeValidate: Function | undefined; if (options.pluralForm) { if (name === options.pluralForm) { @@ -819,46 +885,18 @@ function createTailorDBType< permissions: _permissions, files: _files, ...(Object.keys(indexes).length > 0 && { indexes }), + ...(_typeHook && { typeHook: _typeHook }), + ...(_typeValidate && { typeValidate: _typeValidate }), }; }, - hooks(hooks: Hooks) { - // `Hooks` is strongly typed, but `Object.entries()` loses that information. - // oxlint-disable-next-line no-explicit-any - Object.entries(hooks).forEach(([fieldName, fieldHooks]: [string, any]) => { - const field = this.fields[fieldName]; - if (field === undefined) throw new Error(`field not found: ${fieldName}`); - (this.fields as Record)[fieldName] = ( - field as TailorAnyDBField - ).hooks(fieldHooks); - }); + hooks(hook: TypeHook) { + _typeHook = hook; return this; }, - validate(validators: Validators) { - Object.entries(validators).forEach(([fieldName, fieldValidators]) => { - const field = this.fields[fieldName] as TailorAnyDBField; - - const validators = fieldValidators as - | FieldValidateInput - | FieldValidateInput[]; - - const isValidateConfig = (v: unknown): v is ValidateConfig => { - return Array.isArray(v) && v.length === 2 && typeof v[1] === "string"; - }; - - let updatedField: TailorAnyDBField; - if (Array.isArray(validators)) { - if (isValidateConfig(validators)) { - updatedField = field.validate(validators); - } else { - updatedField = field.validate(...validators); - } - } else { - updatedField = field.validate(validators); - } - (this.fields as Record)[fieldName] = updatedField; - }); + validate(fn: TypeValidateFn) { + _typeValidate = fn; return this; }, @@ -949,7 +987,7 @@ function createTailorDBType< return brandValue(dbType, "tailordb-type"); } -const idField = uuid(); +const idField = createField<"uuid", Record, UUIDString>("uuid"); type idField = typeof idField; type DBType> = TailorDBInstance< { id: idField } & F @@ -1032,9 +1070,9 @@ export const db = { fields: { /** * Creates standard timestamp fields (createdAt, updatedAt) with auto-hooks. - * createdAt is set on create, updatedAt is set on update. - * A user-specified createdAt is respected when provided (e.g. seeding historical - * records); the current time is used only when the value is omitted. + * createdAt and updatedAt are set on create. + * User-specified timestamp values are respected when provided (e.g. seeding + * historical records); the current time is used only when the value is omitted. * @returns An object with createdAt and updatedAt fields * @example * const model = db.type("Model", { @@ -1044,11 +1082,14 @@ export const db = { */ timestamps: () => ({ createdAt: datetime() - .hooks({ create: ({ value }) => value ?? new Date() }) + .hooks({ create: ({ value, now }) => value ?? now }) .description("Record creation timestamp"), - updatedAt: datetime({ optional: true }) - .hooks({ update: () => new Date() }) - .description("Record last update timestamp"), + updatedAt: datetime() + .hooks({ + create: ({ value, now }) => value ?? now, + update: ({ now }) => now, + }) + .description("Record update timestamp"), }), }, }; diff --git a/packages/sdk/src/configure/services/tailordb/types.ts b/packages/sdk/src/configure/services/tailordb/types.ts index 6cc086c4eb..f7a92a269e 100644 --- a/packages/sdk/src/configure/services/tailordb/types.ts +++ b/packages/sdk/src/configure/services/tailordb/types.ts @@ -7,7 +7,7 @@ import type { FieldMetadata, TailorField, } from "#/configure/types/field.types"; -import type { InferredAttributeMap, TailorUser } from "#/runtime/types"; +import type { InferredAttributes, TailorPrincipal } from "#/runtime/types"; import type { InferFieldsOutput, output, Prettify } from "#/types/helpers"; import type { DBFieldMetadata as DBFieldMetadataGenerated, @@ -15,7 +15,6 @@ import type { RawPermissions, TailorDBServiceConfigInput, } from "#/types/tailordb.generated"; -import type { NonEmptyObject } from "type-fest"; export type SerialConfig = Prettify< { @@ -40,6 +39,7 @@ export interface DBFieldMetadata extends FieldMetadata { serial?: SerialConfig; relation?: boolean; scale?: number; + default?: unknown; } export interface DefinedDBFieldMetadata extends DefinedFieldMetadata { @@ -55,6 +55,7 @@ export interface DefinedDBFieldMetadata extends DefinedFieldMetadata { }; serial?: boolean; relation?: boolean; + default?: boolean; } export type GqlOperationsConfig = GqlOperationsInput; @@ -88,6 +89,10 @@ export interface TailorDBTypeMetadata { unique?: boolean; } >; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + typeHook?: { create?: Function; update?: Function }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + typeValidate?: Function; } /** @@ -121,7 +126,7 @@ export interface TailorDBType< Fields extends Record = any, // Generic parameter kept for compatibility with full TailorDBType in configure/ // oxlint-disable-next-line no-unused-vars - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, > { readonly name: string; readonly fields: Fields; @@ -138,36 +143,61 @@ export type TailorDBInstance< // Default kept loose for convenience; callers still get fully inferred types from `db.type()`. // oxlint-disable-next-line no-explicit-any Fields extends Record = any, - User extends object = InferredAttributeMap, + User extends object = InferredAttributes, > = TailorDBType; // --- Hook types (UX-focused, for configure layer) --- -type HookFn = (args: { - value: TValue; - data: TData extends Record +type HookArgs = + TData extends Record ? { readonly [K in keyof TData]?: TData[K] | null | undefined } : unknown; - user: TailorUser; + +type HookFn = (args: { + value: TValue; + oldValue: TValue | null; + invoker: TailorPrincipal | null; + now: Date; }) => TReturn; -export type Hook = { - create?: HookFn; - update?: HookFn; +export type Hook = { + create?: HookFn; + update?: HookFn; }; -export type Hooks< +type DottedPaths = + T extends Record + ? { + [K in keyof T & string]: `${Prefix}${K}` | DottedPaths, `${Prefix}${K}.`>; + }[keyof T & string] + : never; + +export type TypeValidateFn< F extends Record, TData = { [K in keyof F]: output }, -> = NonEmptyObject<{ - [K in Exclude as F[K]["_defined"] extends { - hooks: unknown; - } - ? never - : F[K]["_defined"] extends { type: "nested" } - ? never - : K]?: Hook>; -}>; +> = ( + args: { + newRecord: HookArgs; + oldRecord: HookArgs | null; + invoker: TailorPrincipal | null; + }, + issues: (field: DottedPaths>, message: string) => void, +) => void; + +export type TypeHookFn< + F extends Record, + TData = { [K in keyof F]: output }, +> = (args: { + input: HookArgs; + oldRecord: HookArgs | null; + invoker: TailorPrincipal | null; + now: Date; +}) => { [K in Exclude]?: TData[K] | null | undefined }; + +export type TypeHook> = { + create?: TypeHookFn; + update?: TypeHookFn; +}; // --- Field helper types --- diff --git a/packages/sdk/src/configure/services/workflow/job.test.ts b/packages/sdk/src/configure/services/workflow/job.test.ts index c3d4df2095..7f1aebccf6 100644 --- a/packages/sdk/src/configure/services/workflow/job.test.ts +++ b/packages/sdk/src/configure/services/workflow/job.test.ts @@ -1,21 +1,59 @@ // oxlint-disable vitest/expect-expect -- Type-only assertions are checked by TypeScript. import { describe, expect, test, expectTypeOf } from "vitest"; +import { platformSerialize } from "#/utils/test/platform-serialize"; import { createWorkflowJob, type WorkflowJob } from "./job"; +import { getRegisteredJob } from "./registry"; +import { buildJobContext } from "./test-env-key"; import { createWorkflow } from "./workflow"; -import type { TailorInvoker } from "#/runtime/types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { TypeLevelError } from "#/types/helpers"; type WorkflowJobConfig = Parameters< typeof createWorkflowJob >[0]; +async function withRegisteredJobRuntime(run: () => Promise): Promise { + const root = globalThis as { + tailor?: { + workflow?: { + triggerJobFunction: (name: string, args?: unknown) => unknown; + }; + }; + }; + const previousTailor = root.tailor; + + root.tailor = { + ...previousTailor, + workflow: { + triggerJobFunction: (name, args) => { + const body = getRegisteredJob(name); + if (!body) return null; + const out = body(platformSerialize(args), buildJobContext()); + return out instanceof Promise + ? out.then((value) => platformSerialize(value)) + : platformSerialize(out); + }, + }, + }; + + try { + return await run(); + } finally { + if (previousTailor) { + root.tailor = previousTailor; + } else { + delete root.tailor; + } + } +} + describe("WorkflowJob type inference", () => { test("preserves literal types in output when using as const", () => { const _job = createWorkflowJob({ name: "test", body: () => ({ status: "ok" as const, count: 42 }), }); - type Output = Awaited>; + type Output = ReturnType; expectTypeOf().toEqualTypeOf<{ status: "ok"; count: number }>(); }); @@ -50,7 +88,7 @@ describe("WorkflowJob type inference", () => { name: "test", body: (): UserOutput => ({ id: "123", created: true }), }); - type Output = Awaited>; + type Output = ReturnType; expectTypeOf().toEqualTypeOf(); }); @@ -60,9 +98,147 @@ describe("WorkflowJob type inference", () => { body: (_input: undefined, context) => { expectTypeOf(context).toHaveProperty("env"); expectTypeOf(context).toHaveProperty("invoker"); - expectTypeOf(context.invoker).toEqualTypeOf(); + expectTypeOf(context.invoker).toEqualTypeOf(); + }, + }); + }); + + test("direct body calls work when process.getBuiltinModule is unavailable", async () => { + const descriptor = Object.getOwnPropertyDescriptor(process, "getBuiltinModule"); + Object.defineProperty(process, "getBuiltinModule", { + configurable: true, + value: undefined, + }); + try { + const invoker: TailorPrincipal = { + id: "11111111-1111-4111-8111-111111111111", + type: "user", + workspaceId: "workspace-1", + attributes: {}, + attributeList: [], + }; + const child = createWorkflowJob({ + name: "capture-child-invoker-without-get-builtin-module", + body: (_input: undefined, context) => context.invoker?.id ?? "anonymous", + }); + const parent = createWorkflowJob({ + name: "propagate-parent-invoker-without-get-builtin-module", + body: async () => await child.trigger(), + }); + + await withRegisteredJobRuntime(async () => { + await expect(parent.body(undefined, { env: {}, invoker })).resolves.toBe( + "11111111-1111-4111-8111-111111111111", + ); + }); + } finally { + if (descriptor) { + Object.defineProperty(process, "getBuiltinModule", descriptor); + } else { + delete (process as { getBuiltinModule?: unknown }).getBuiltinModule; + } + } + }); + + test("direct body calls propagate invoker to triggered child jobs", async () => { + const invoker: TailorPrincipal = { + id: "11111111-1111-4111-8111-111111111111", + type: "user", + workspaceId: "workspace-1", + attributes: { role: "ADMIN" }, + attributeList: [], + }; + const child = createWorkflowJob({ + name: "capture-child-invoker", + body: (_input: undefined, context) => context.invoker?.id ?? "anonymous", + }); + const parent = createWorkflowJob({ + name: "propagate-parent-invoker", + body: async () => await child.trigger(), + }); + + await withRegisteredJobRuntime(async () => { + await expect(parent.body(undefined, { env: {}, invoker })).resolves.toBe( + "11111111-1111-4111-8111-111111111111", + ); + }); + }); + + test("concurrent direct body calls isolate invokers for child triggers", async () => { + const firstInvoker: TailorPrincipal = { + id: "11111111-1111-4111-8111-111111111111", + type: "user", + workspaceId: "workspace-1", + attributes: {}, + attributeList: [], + }; + const secondInvoker: TailorPrincipal = { + id: "22222222-2222-4222-8222-222222222222", + type: "machine_user", + workspaceId: "workspace-1", + attributes: {}, + attributeList: [], + }; + let releaseFirst: () => void = () => {}; + let releaseSecond: () => void = () => {}; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const secondGate = new Promise((resolve) => { + releaseSecond = resolve; + }); + const gates = { + first: firstGate, + second: secondGate, + }; + const child = createWorkflowJob({ + name: "capture-concurrent-child-invoker", + body: (_input: undefined, context) => context.invoker?.id ?? "anonymous", + }); + const parent = createWorkflowJob({ + name: "capture-concurrent-parent-invoker", + body: async (input: { gate: "first" | "second" }) => { + await gates[input.gate]; + return await child.trigger(); }, }); + + await withRegisteredJobRuntime(async () => { + const first = parent.body({ gate: "first" }, { env: {}, invoker: firstInvoker }); + const second = parent.body({ gate: "second" }, { env: {}, invoker: secondInvoker }); + + releaseFirst(); + await expect(first).resolves.toBe("11111111-1111-4111-8111-111111111111"); + releaseSecond(); + await expect(second).resolves.toBe("22222222-2222-4222-8222-222222222222"); + }); + }); + + test("trigger reads the runtime invoker when no body context is active", async () => { + const previousTailor = (globalThis as { tailor?: unknown }).tailor; + (globalThis as { tailor?: unknown }).tailor = { + context: { + getInvoker: () => ({ + id: "runtime-principal", + type: "machine_user", + workspaceId: "workspace-1", + attributes: ["role"], + attributeMap: { role: "SYSTEM" }, + }), + }, + }; + try { + const job = createWorkflowJob({ + name: "capture-runtime-invoker", + body: (_input: undefined, context) => context.invoker?.id ?? "anonymous", + }); + + await withRegisteredJobRuntime(async () => { + expect(job.trigger()).toBe("runtime-principal"); + }); + } finally { + (globalThis as { tailor?: unknown }).tailor = previousTailor; + } }); }); @@ -258,7 +434,7 @@ describe("WorkflowJob type constraints", () => { name: "test", body: () => ({ result: "ok", count: 42, active: true as boolean }), }); - expectTypeOf(job.trigger).returns.resolves.toEqualTypeOf<{ + expectTypeOf(job.trigger).returns.toEqualTypeOf<{ result: string; count: number; active: boolean; @@ -275,7 +451,7 @@ describe("WorkflowJob type constraints", () => { }, }), }); - expectTypeOf(job.trigger).returns.resolves.toEqualTypeOf<{ + expectTypeOf(job.trigger).returns.toEqualTypeOf<{ data: { id: string; tags: string[]; @@ -288,7 +464,7 @@ describe("WorkflowJob type constraints", () => { name: "test", body: () => undefined, }); - expectTypeOf(job.trigger).returns.resolves.toEqualTypeOf(); + expectTypeOf(job.trigger).returns.toEqualTypeOf(); }); test("returns T | undefined for T | undefined output", () => { @@ -298,7 +474,7 @@ describe("WorkflowJob type constraints", () => { return Math.random() > 0.5 ? { value: 1 } : undefined; }, }); - expectTypeOf(job.trigger).returns.resolves.toEqualTypeOf<{ value: number } | undefined>(); + expectTypeOf(job.trigger).returns.toEqualTypeOf<{ value: number } | undefined>(); }); }); @@ -308,7 +484,7 @@ describe("WorkflowJob type constraints", () => { name: "test", body: () => ({ result: "ok" }), }); - const _trigger: () => Promise<{ result: string }> = job.trigger; + const _trigger: () => { result: string } = job.trigger; expectTypeOf(_trigger).toBeFunction(); }); @@ -317,7 +493,7 @@ describe("WorkflowJob type constraints", () => { name: "test", body: (input: { id: string }) => ({ result: input.id }), }); - const _trigger: (input: { id: string }) => Promise<{ result: string }> = job.trigger; + const _trigger: (input: { id: string }) => { result: string } = job.trigger; expectTypeOf(_trigger).toBeFunction(); }); }); @@ -334,7 +510,7 @@ describe("WorkflowJob type constraints", () => { test("trigger return preserves Output as-is", () => { type Job = WorkflowJob<"test", undefined, { id: string; result: string }>; - expectTypeOf>().resolves.toEqualTypeOf<{ + expectTypeOf>().toEqualTypeOf<{ id: string; result: string; }>(); @@ -409,41 +585,24 @@ describe("WorkflowJob type constraints", () => { }); // Plain `node` environment (no `tailor-runtime`, no `mockWorkflow()`), so -// `.trigger()` exercises the no-shim fallback. -describe("trigger fallback without tailor.workflow", () => { - test("runs the registered job body locally", async () => { +// `.trigger()` should not execute job bodies locally. +describe("trigger without tailor.workflow", () => { + test("job trigger throws instead of running the registered body", () => { const double = createWorkflowJob({ name: "fallback-double", body: (input: { n: number }) => ({ doubled: input.n * 2 }), }); - expect(await double.trigger({ n: 21 })).toEqual({ doubled: 42 }); + expect(() => double.trigger({ n: 21 })).toThrow(/tailor\.workflow is not available/); }); - test("runs the whole chain via workflow.mainJob.trigger()", async () => { - const inner = createWorkflowJob({ - name: "fallback-inner", - body: (input: { n: number }) => ({ n: input.n + 1 }), - }); + test("workflow trigger rejects instead of running the main job", async () => { const main = createWorkflowJob({ name: "fallback-main", - body: async (input: { n: number }) => { - const a = await inner.trigger({ n: input.n }); - const b = await inner.trigger({ n: a.n }); - return { total: b.n }; - }, + body: (input: { n: number }) => ({ total: input.n + 1 }), }); const workflow = createWorkflow({ name: "fallback-wf", mainJob: main }); - expect(await workflow.mainJob.trigger({ n: 0 })).toEqual({ total: 2 }); - }); - - test("enforces the JSON boundary on the fallback path", async () => { - const bad = createWorkflowJob({ - name: "fallback-bad", - body: () => ({ when: new Date() }) as never, - }); - - await expect(bad.trigger()).rejects.toThrow(/Date instance/); + await expect(workflow.trigger({ n: 0 })).rejects.toThrow(/tailor\.workflow is not available/); }); }); diff --git a/packages/sdk/src/configure/services/workflow/job.ts b/packages/sdk/src/configure/services/workflow/job.ts index 6ce9b3967a..33c69da677 100644 --- a/packages/sdk/src/configure/services/workflow/job.ts +++ b/packages/sdk/src/configure/services/workflow/job.ts @@ -1,6 +1,7 @@ import { brandValue } from "#/utils/brand"; import { dispatchTriggerJob, registerJob, type RegisteredJobBody } from "./registry"; -import type { TailorEnv, TailorInvoker } from "#/runtime/types"; +import { withWorkflowTestInvoker } from "./test-env-key"; +import type { TailorEnv, TailorPrincipal } from "#/runtime/types"; import type { JsonCompatible, TypeLevelError } from "#/types/helpers"; /** @@ -8,7 +9,7 @@ import type { JsonCompatible, TypeLevelError } from "#/types/helpers"; */ export type WorkflowJobContext = { env: TailorEnv; - invoker?: TailorInvoker; + invoker: TailorPrincipal | null; }; /** @@ -36,28 +37,23 @@ type JobBody = [null] extends [I] * Type constraints: * - Input: Must be JsonValue-compatible (plain objects/arrays; no class instances or functions) or undefined. * - Output: Must be JsonValue-compatible (plain objects/arrays; no class instances or functions), undefined, or void. - * - Trigger returns `Awaited` as-is (no Jsonify transformation). + * - Trigger returns `Awaited` as-is (no Promise or Jsonify transformation). */ export interface WorkflowJob { name: Name; /** - * Trigger this job with the given input. Returns a Promise that resolves - * to the job's output value. + * Trigger this job with the given input and return the job's output value. * @example * body: async (input) => { - * const a = await jobA.trigger({ id: input.id }); - * const b = await jobB.trigger({ id: input.id }); + * const a = jobA.trigger({ id: input.id }); + * const b = jobB.trigger({ id: input.id }); * return { a, b }; * } */ - trigger: [Input] extends [undefined] - ? () => Promise> - : (input: Input) => Promise>; + trigger: [Input] extends [undefined] ? () => Awaited : (input: Input) => Awaited; body: (input: Input, context: WorkflowJobContext) => Output | Promise; } -export { WORKFLOW_TEST_ENV_KEY } from "./test-env-key"; - interface CreateWorkflowJobConfig { readonly name: Name; readonly body: JobBody; @@ -74,7 +70,7 @@ interface CreateWorkflowJobConfig { * class instances exposing methods are rejected via the property walk. * @param config - Job configuration with name and body function. * @param config.name - Unique job name across the project. - * @param config.body - Async function that processes the job input. + * @param config.body - Function that processes the job input. * @returns A WorkflowJob that can be triggered from other jobs. * @example * // Simple job with async body: @@ -89,9 +85,9 @@ interface CreateWorkflowJobConfig { * // Orchestrator job that fans out to other jobs. * export const orchestrate = createWorkflowJob({ * name: "orchestrate", - * body: async (input: { orderId: string }) => { - * const inventory = await checkInventory.trigger({ orderId: input.orderId }); - * const payment = await processPayment.trigger({ orderId: input.orderId }); + * body: (input: { orderId: string }) => { + * const inventory = checkInventory.trigger({ orderId: input.orderId }); + * const payment = processPayment.trigger({ orderId: input.orderId }); * return { inventory, payment }; * }, * }); @@ -99,20 +95,24 @@ interface CreateWorkflowJobConfig { export function createWorkflowJob( config: CreateWorkflowJobConfig, ): WorkflowJob> { - const body = config.body as (input: I, context: WorkflowJobContext) => O | Promise; + const userBody = config.body as (input: I, context: WorkflowJobContext) => O | Promise; + const body = process.env.__TAILOR_PLATFORM_BUNDLE + ? userBody + : (input: I, context: WorkflowJobContext): O | Promise => + withWorkflowTestInvoker(context.invoker, () => userBody(input, context)); - // Test-only registry/trigger shim; the platform bundle sets the flag so it is DCE'd. - if (!process.env.TAILOR_PLATFORM_BUNDLE) { + // Test-only local runner registry; the platform bundle sets the flag so it is DCE'd. + if (!process.env.__TAILOR_PLATFORM_BUNDLE) { registerJob(config.name, body as RegisteredJobBody); } - const trigger = process.env.TAILOR_PLATFORM_BUNDLE - ? async () => { + const trigger = process.env.__TAILOR_PLATFORM_BUNDLE + ? () => { throw new Error( "This workflow job's .trigger() is rewritten at build time and is unavailable in the bundle", ); } - : async (args?: unknown) => (await dispatchTriggerJob(config.name, args)) as Awaited; + : (args?: unknown) => dispatchTriggerJob(config.name, args) as Awaited; return brandValue( { name: config.name, trigger, body } as WorkflowJob>, diff --git a/packages/sdk/src/configure/services/workflow/registry.ts b/packages/sdk/src/configure/services/workflow/registry.ts index a221c71bfc..da99474644 100644 --- a/packages/sdk/src/configure/services/workflow/registry.ts +++ b/packages/sdk/src/configure/services/workflow/registry.ts @@ -1,6 +1,4 @@ -import { platformSerialize } from "#/utils/test/platform-serialize"; -import { buildJobContext } from "./test-env-key"; -import type { TailorEnv, TailorInvoker } from "#/runtime/types"; +import type { TailorEnv, TailorPrincipal } from "#/runtime/types"; /** * Body signature shared by workflow jobs at registry-write time. @@ -9,15 +7,10 @@ import type { TailorEnv, TailorInvoker } from "#/runtime/types"; */ export type RegisteredJobBody = ( args: unknown, - context: { env: TailorEnv; invoker?: TailorInvoker }, + context: { env: TailorEnv; invoker: TailorPrincipal | null }, ) => unknown | Promise; -export interface RegisteredWorkflow { - mainJobName: string; -} - const JOB_REGISTRY_KEY: unique symbol = Symbol.for("tailor-platform/sdk:job-registry"); -const WORKFLOW_REGISTRY_KEY: unique symbol = Symbol.for("tailor-platform/sdk:workflow-registry"); type PlatformWorkflow = { triggerWorkflow: (name: string, args?: unknown, options?: unknown) => Promise; @@ -26,7 +19,6 @@ type PlatformWorkflow = { type GlobalWithRegistry = typeof globalThis & { [JOB_REGISTRY_KEY]?: Map; - [WORKFLOW_REGISTRY_KEY]?: Map; tailor?: { workflow?: PlatformWorkflow }; }; @@ -40,20 +32,10 @@ function jobs(): Map { return map; } -function workflows(): Map { - const g = globalThis as GlobalWithRegistry; - let map = g[WORKFLOW_REGISTRY_KEY]; - if (!map) { - map = new Map(); - g[WORKFLOW_REGISTRY_KEY] = map; - } - return map; -} - /** * Register a job body keyed by job name. Called as a side effect by - * `createWorkflowJob` so the vitest mock can execute the body when - * `globalThis.tailor.workflow.triggerJobFunction(name, args)` is invoked. + * `createWorkflowJob` so `runWorkflowLocally()` can execute dependent job + * bodies when `globalThis.tailor.workflow.triggerJobFunction(name, args)` is invoked. * * In production builds the bundler rewrites `.trigger()` calls so this registry * is never read; the gated write is dropped as dead code. @@ -73,66 +55,40 @@ export function getRegisteredJob(name: string): RegisteredJobBody | undefined { return jobs().get(name); } -/** - * Register a workflow's main job name so the mock can run the workflow locally. - * @param name - Workflow name - * @param mainJobName - Name of the workflow's main job - */ -export function registerWorkflow(name: string, mainJobName: string): void { - workflows().set(name, { mainJobName }); -} - -/** - * Look up a registered workflow by name. - * @param name - Workflow name - * @returns The registered workflow, or undefined - */ -export function getRegisteredWorkflow(name: string): RegisteredWorkflow | undefined { - return workflows().get(name); -} - function currentPlatformWorkflow(): PlatformWorkflow | undefined { // globalThis may not have the tailor property at runtime // oxlint-disable-next-line typescript/no-unnecessary-condition return (globalThis as GlobalWithRegistry).tailor?.workflow; } +function requirePlatformWorkflow(): PlatformWorkflow { + const workflow = currentPlatformWorkflow(); + if (!workflow) { + throw new Error( + "tailor.workflow is not available. Run tests in the `tailor-runtime` Vitest environment and use mockWorkflow(), or use runWorkflowLocally() from @tailor-platform/sdk/vitest for local workflow execution.", + ); + } + return workflow; +} + // A valid placeholder UUID, so callers that validate the execution id behave the // same locally as against the platform. export const TRIGGER_DEFAULT = "00000000-0000-4000-8000-000000000000"; -function serializeReturn(out: unknown): unknown { - return out instanceof Promise ? out.then((v) => platformSerialize(v)) : platformSerialize(out); -} - -// Runs the registered body across the platform JSON boundary. Shared by the -// `tailor-runtime` default runner and the no-shim `.trigger()` fallback below. -export function runRegisteredJob(name: string, args?: unknown): unknown { - const body = getRegisteredJob(name); - const out = body ? body(platformSerialize(args), buildJobContext()) : null; - return serializeReturn(out); -} - -export async function runRegisteredWorkflow(name: string, args?: unknown): Promise { - const workflow = getRegisteredWorkflow(name); - if (workflow) await runRegisteredJob(workflow.mainJobName, args); - return TRIGGER_DEFAULT; -} - -// `.trigger()` routes through the installed `tailor.workflow` shim, falling back -// to running the registered body/workflow locally when none is installed. +// `.trigger()` routes through the installed `tailor.workflow` shim. Local body +// execution is intentionally available only through `runWorkflowLocally()`. export function dispatchTriggerJob(name: string, args?: unknown): unknown { - const workflow = currentPlatformWorkflow(); - return workflow ? workflow.triggerJobFunction(name, args) : runRegisteredJob(name, args); + return requirePlatformWorkflow().triggerJobFunction(name, args); } export function dispatchTriggerWorkflow( name: string, args?: unknown, - options?: unknown, + options?: { invoker?: unknown }, ): Promise { - const workflow = currentPlatformWorkflow(); - return workflow - ? workflow.triggerWorkflow(name, args, options) - : runRegisteredWorkflow(name, args); + const workflow = requirePlatformWorkflow(); + if (options?.invoker === undefined) { + return workflow.triggerWorkflow(name, args); + } + return workflow.triggerWorkflow(name, args, { authInvoker: options.invoker }); } diff --git a/packages/sdk/src/configure/services/workflow/test-env-key.ts b/packages/sdk/src/configure/services/workflow/test-env-key.ts index 1dec2ccca5..5ce5887024 100644 --- a/packages/sdk/src/configure/services/workflow/test-env-key.ts +++ b/packages/sdk/src/configure/services/workflow/test-env-key.ts @@ -1,7 +1,7 @@ /** * Typed accessors for the test-time globalThis slot used to pass `env` from * `mockWorkflow().setEnv()` (in `@tailor-platform/sdk/vitest`) to - * `createWorkflowJob().trigger()` bodies. The slot key is private to this + * `runWorkflowLocally()` job bodies. The slot key is private to this * module; callers go through the get/set/clear functions below so both sides * share the same access path. * @@ -9,10 +9,25 @@ * it from nested Vitest configs that do not resolve `@/` aliases. * @internal */ -import type { TailorEnv, TailorInvoker } from "../../../runtime/types"; +import { AsyncLocalStorage } from "node:async_hooks"; +import type { TailorEnv, TailorPrincipal } from "../../../runtime/types"; const SLOT_KEY = "__tailorWorkflowTestEnv"; +type AsyncLocalStorageLike = { + getStore(): T | undefined; + run(store: T, callback: () => R): R; +}; + +let invokerStorage: AsyncLocalStorageLike | undefined; + +function workflowInvokerStorage(): AsyncLocalStorageLike { + if (!invokerStorage) { + invokerStorage = new AsyncLocalStorage(); + } + return invokerStorage; +} + /** * Read the test-time env slot. * @returns Current env, or `undefined` when unset. @@ -24,7 +39,7 @@ export function readWorkflowTestEnv(): TailorEnv | undefined { /** * Write the test-time env slot. - * @param env - Env value to expose to `.trigger()` bodies. + * @param env - Env value to expose to `runWorkflowLocally()` job bodies. * @internal */ export function writeWorkflowTestEnv(env: TailorEnv): void { @@ -39,33 +54,42 @@ export function clearWorkflowTestEnv(): void { delete (globalThis as unknown as Record)[SLOT_KEY]; } -/** - * Env-var fallback read by `.trigger()` when `mockWorkflow().setEnv()` is unset. - * @deprecated Use `mockWorkflow().setEnv()` from `@tailor-platform/sdk/vitest`. - * @internal - */ -export const WORKFLOW_TEST_ENV_KEY = "TAILOR_TEST_WORKFLOW_ENV"; +export function withWorkflowTestInvoker(invoker: TailorPrincipal | null, run: () => T): T { + return workflowInvokerStorage().run(invoker, run); +} -// env from `mockWorkflow().setEnv()`, else the deprecated env-var. Shallow-copied -// to isolate against cross-trigger mutation. -export function buildJobContext(): { env: TailorEnv; invoker?: TailorInvoker } { +type RuntimeInvoker = { + id: TailorPrincipal["id"]; + type: "user" | "machine_user"; + workspaceId: string; + attributes?: string[] | TailorPrincipal["attributes"]; + attributeMap?: TailorPrincipal["attributes"]; + attributeList?: TailorPrincipal["attributeList"]; +}; + +function readRuntimeInvoker(): TailorPrincipal | null { + const runtime = ( + globalThis as unknown as { + tailor?: { context?: { getInvoker?: () => RuntimeInvoker | null } }; + } + ).tailor?.context?.getInvoker; + const raw = runtime?.(); + if (!raw) return null; + return { + id: raw.id, + type: raw.type, + workspaceId: raw.workspaceId, + attributes: raw.attributeMap ?? (Array.isArray(raw.attributes) ? {} : (raw.attributes ?? {})), + attributeList: (raw.attributeList ?? + (Array.isArray(raw.attributes) ? raw.attributes : [])) as TailorPrincipal["attributeList"], + }; +} + +// Shallow-copied to isolate against cross-trigger mutation. +export function buildJobContext(): { env: TailorEnv; invoker: TailorPrincipal | null } { + const storedInvoker = invokerStorage?.getStore(); + const invoker = storedInvoker === undefined ? readRuntimeInvoker() : storedInvoker; const fromGlobal = readWorkflowTestEnv(); - if (fromGlobal !== undefined) return { env: { ...fromGlobal } }; - const raw = process.env[WORKFLOW_TEST_ENV_KEY]; - if (!raw) return { env: {} as TailorEnv }; - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (cause) { - throw new Error( - `Invalid JSON in ${WORKFLOW_TEST_ENV_KEY}; provide valid JSON or use mockWorkflow().setEnv().`, - { cause }, - ); - } - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error( - `${WORKFLOW_TEST_ENV_KEY} must be a JSON object; provide a record or use mockWorkflow().setEnv().`, - ); - } - return { env: { ...(parsed as TailorEnv) } }; + if (fromGlobal !== undefined) return { env: { ...fromGlobal }, invoker }; + return { env: {} as TailorEnv, invoker }; } diff --git a/packages/sdk/src/configure/services/workflow/wait-point.test.ts b/packages/sdk/src/configure/services/workflow/wait-point.test.ts index 9097f7582b..24ec1559f6 100644 --- a/packages/sdk/src/configure/services/workflow/wait-point.test.ts +++ b/packages/sdk/src/configure/services/workflow/wait-point.test.ts @@ -1,55 +1,55 @@ // oxlint-disable vitest/expect-expect -- Type-only assertions are checked by TypeScript. import { afterEach, describe, expect, test, expectTypeOf } from "vitest"; import { setupWaitPointMock, setupWorkflowMock } from "#/utils/test/mock"; -import { defineWaitPoint, defineWaitPoints } from "./wait-point"; +import { createWaitPoint, createWaitPoints } from "./wait-point"; import type { TailorRuntime } from "#/runtime/index"; import type { TypeLevelError } from "#/types/helpers"; const TailorGlobal = globalThis as { tailor?: TailorRuntime }; -describe("defineWaitPoints", () => { +describe("createWaitPoints", () => { afterEach(() => { delete TailorGlobal.tailor; }); test("invalid definitions expose TypeLevelError messages", () => { - expectTypeOf>>().toEqualTypeOf< + expectTypeOf>>().toEqualTypeOf< TypeLevelError<"Payload cannot be null at the top level"> >(); expectTypeOf< - ReturnType> + ReturnType> >().toEqualTypeOf>(); - expectTypeOf>>().toEqualTypeOf< + expectTypeOf>>().toEqualTypeOf< TypeLevelError<"Result cannot be (or include) undefined (resolve callback must return a value)"> >(); expectTypeOf< - ReturnType> + ReturnType> >().toEqualTypeOf< TypeLevelError<"Result cannot be (or include) undefined (resolve callback must return a value)"> >(); expectTypeOf< - ReturnType> + ReturnType> >().toEqualTypeOf< TypeLevelError<"Result must be JsonValue-compatible (plain objects/arrays; no class instances or functions)"> >(); expectTypeOf< - ReturnType> + ReturnType> >().toEqualTypeOf>(); expectTypeOf< - ReturnType> + ReturnType> >().toEqualTypeOf< TypeLevelError<"Payload must be JsonValue-compatible (plain objects/arrays; no class instances or functions)"> >(); }); test("creates instances with typed wait/resolve", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ approval: define<{ message: string }, { approved: boolean }>(), })); expectTypeOf(wps.approval.wait).toBeFunction(); @@ -57,53 +57,53 @@ describe("defineWaitPoints", () => { }); test("rejects Date in Payload / Result (pure JSON only)", () => { - defineWaitPoints((define) => ({ + createWaitPoints((define) => ({ // @ts-expect-error - Date is not JsonValue-compatible (Result) check: define(), })); - defineWaitPoints((define) => ({ + createWaitPoints((define) => ({ // @ts-expect-error - Date is not JsonValue-compatible (Payload) check: define<{ when: Date }, { ok: boolean }>(), })); }); test("rejects top-level null in Payload", () => { - defineWaitPoints((define) => ({ + createWaitPoints((define) => ({ // @ts-expect-error - null is not allowed at top level (even in union) check: define<{ id: string } | null, { ok: boolean }>(), })); }); test("rejects top-level undefined in Payload union", () => { - defineWaitPoints((define) => ({ + createWaitPoints((define) => ({ // @ts-expect-error - undefined is not allowed at top level (except when Payload = undefined alone) check: define<{ id: string } | undefined, { ok: boolean }>(), })); }); test("allows Payload = undefined (no-payload convention)", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ check: define(), })); expectTypeOf(wps.check.wait).toBeFunction(); }); test("allows nested null in Payload", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ check: define<{ data: string | null }, { ok: boolean }>(), })); expectTypeOf(wps.check.wait).toBeFunction(); }); test("allows top-level null in Result", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ check: define<{ id: string }, { data: string } | null>(), })); expectTypeOf(wps.check.wait).toBeFunction(); }); test("wait return type is Result as-is (no Jsonify transformation)", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ check: define(), })); type WaitReturn = Awaited>; @@ -111,7 +111,7 @@ describe("defineWaitPoints", () => { }); test("wait omits payload when Payload is undefined", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ signal: define(), })); type Params = Parameters; @@ -119,7 +119,7 @@ describe("defineWaitPoints", () => { }); test("throws without platform API or mock", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ approval: define(), })); expect(() => wps.approval.wait()).toThrow("mockWorkflow"); @@ -127,28 +127,28 @@ describe("defineWaitPoints", () => { test("throws a helpful error when only setupWorkflowMock is active (wait/resolve auto-stubbed)", () => { setupWorkflowMock(() => undefined); - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ approval: define(), })); expect(() => wps.approval.wait()).toThrow("mockWorkflow"); }); test("rejects Result = undefined (callback must return a value)", () => { - defineWaitPoints((define) => ({ + createWaitPoints((define) => ({ // @ts-expect-error - Result cannot be undefined; platform throws if callback returns undefined check: define(), })); }); test("rejects top-level undefined in Result union", () => { - defineWaitPoints((define) => ({ + createWaitPoints((define) => ({ // @ts-expect-error - Result cannot include top-level undefined check: define(), })); }); test("allows nested undefined (optional fields) in Result", () => { - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ check: define(), })); expectTypeOf(wps.check.wait).toBeFunction(); @@ -159,7 +159,7 @@ describe("defineWaitPoints", () => { onWait: (_key, _payload) => ({ approved: true }), }); - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ approval: define<{ msg: string }, { approved: boolean }>(), })); @@ -176,7 +176,7 @@ describe("defineWaitPoints", () => { }, }); - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ approval: define<{ msg: string }, { ok: boolean }>(), })); @@ -193,7 +193,7 @@ describe("defineWaitPoints", () => { onWait: () => "ok", }); - const wps = defineWaitPoints((define) => ({ + const wps = createWaitPoints((define) => ({ step: define(), })); @@ -202,24 +202,24 @@ describe("defineWaitPoints", () => { }); }); -describe("defineWaitPoint", () => { +describe("createWaitPoint", () => { afterEach(() => { delete TailorGlobal.tailor; }); test("creates a typed instance with the given key", () => { - const wp = defineWaitPoint<{ msg: string }, { ok: boolean }>("my-key"); + const wp = createWaitPoint<{ msg: string }, { ok: boolean }>("my-key"); expectTypeOf(wp.wait).toBeFunction(); expectTypeOf(wp.resolve).toBeFunction(); }); test("throws without platform API or mock", () => { - const wp = defineWaitPoint("my-step"); + const wp = createWaitPoint("my-step"); expect(() => wp.wait()).toThrow("mockWorkflow"); }); test("rejects Result = undefined (callback must return a value)", () => { - const wp = defineWaitPoint("my-step"); + const wp = createWaitPoint("my-step"); // @ts-expect-error - wp resolves to an error string, not WaitPointInstance expectTypeOf(wp.wait).toBeFunction(); }); @@ -229,7 +229,7 @@ describe("defineWaitPoint", () => { onWait: (_key, _payload) => ({ ok: true }), }); - const wp = defineWaitPoint<{ msg: string }, { ok: boolean }>("approval"); + const wp = createWaitPoint<{ msg: string }, { ok: boolean }>("approval"); await wp.wait({ msg: "please" }); expect(waitCalls[0]).toEqual({ key: "approval", payload: { msg: "please" } }); }); @@ -241,7 +241,7 @@ describe("defineWaitPoint", () => { }, }); - const wp = defineWaitPoint("my-step"); + const wp = createWaitPoint("my-step"); await wp.resolve("exec-1", () => ({ ok: true })); expect(resolveCalls[0]).toEqual({ executionId: "exec-1", key: "my-step" }); }); diff --git a/packages/sdk/src/configure/services/workflow/wait-point.ts b/packages/sdk/src/configure/services/workflow/wait-point.ts index feaf814d7b..e3b7cf8589 100644 --- a/packages/sdk/src/configure/services/workflow/wait-point.ts +++ b/packages/sdk/src/configure/services/workflow/wait-point.ts @@ -79,7 +79,7 @@ function createWaitPointInstance(initialKey: string): WaitPointWithSetter { } /** - * The type produced by `define()` / `defineWaitPoint(key)`. + * The type produced by `define()` / `createWaitPoint(key)`. * Resolves to `WaitPointInstance` when both types are JsonValue-compatible, * or to a type-level error that surfaces at the call site. */ @@ -100,7 +100,7 @@ type WaitPointDef = [null] extends [Payload] : TypeLevelError<"Payload must be JsonValue-compatible (plain objects/arrays; no class instances or functions)">; /** - * The `define` function passed to the `defineWaitPoints` builder callback. + * The `define` function passed to the `createWaitPoints` builder callback. * Returns an actual WaitPointInstance (not a phantom marker) so that the * builder's return type can flow through as-is, preserving JSDoc comments * on each property for IDE autocompletion. @@ -112,7 +112,7 @@ type WaitPointDef = [null] extends [Payload] type DefineFn = () => WaitPointDef; /** - * Define a single typed wait point with an explicit key. + * Create a single typed wait point with an explicit key. * * `Payload` and `Result` must be JsonValue-compatible. * Functions and objects with a `toJSON` method are rejected at the type level; @@ -120,19 +120,19 @@ type DefineFn = () => WaitPointDef("approval"); + * export const approval = createWaitPoint<{ message: string }, { approved: boolean }>("approval"); * * await approval.wait({ message: "Please approve" }); */ /* @__NO_SIDE_EFFECTS__ */ -export function defineWaitPoint( +export function createWaitPoint( key: string, ): WaitPointDef { return createWaitPointInstance(key).instance as unknown as WaitPointDef; } /** - * Define a group of typed wait points for human-in-the-loop workflows. + * Create a group of typed wait points for human-in-the-loop workflows. * Property names become the wait point keys. * * The return type is the same as the builder's return type, so JSDoc on each @@ -144,7 +144,7 @@ export function defineWaitPoint( * @param builder - Callback that receives a `define` factory and returns an object of wait points * @returns The same object returned by the builder (with correct keys set on each instance) * @example - * export const waitPoints = defineWaitPoints(define => ({ + * export const waitPoints = createWaitPoints(define => ({ * // Preceding JSDoc on this property is shown in IDE autocompletion * approval: define<{ message: string }, { approved: boolean }>(), * })); @@ -156,7 +156,7 @@ export function defineWaitPoint( */ /* @__NO_SIDE_EFFECTS__ */ // oxlint-disable-next-line no-explicit-any -export function defineWaitPoints>>( +export function createWaitPoints>>( builder: (define: DefineFn) => T, ): T { const setters = new Map void>(); diff --git a/packages/sdk/src/configure/services/workflow/workflow.ts b/packages/sdk/src/configure/services/workflow/workflow.ts index d9886c0f49..e0917fe118 100644 --- a/packages/sdk/src/configure/services/workflow/workflow.ts +++ b/packages/sdk/src/configure/services/workflow/workflow.ts @@ -1,9 +1,8 @@ /* oxlint-disable typescript/no-explicit-any */ import { brandValue } from "#/utils/brand"; -import { dispatchTriggerWorkflow, registerWorkflow } from "./registry"; +import { dispatchTriggerWorkflow } from "./registry"; import type { MachineUserName } from "#/configure/types/machine-user"; import type { ConcurrencyPolicy, RetryPolicy } from "#/types/workflow.generated"; -import type { AuthInvoker } from "../auth"; import type { WorkflowJob } from "./job"; export type { ConcurrencyPolicy, RetryPolicy }; @@ -24,7 +23,7 @@ export interface Workflow = WorkflowJob[0], - options?: { authInvoker: AuthInvoker | MachineUserName }, + options?: { invoker: MachineUserName }, ) => Promise; } @@ -48,8 +47,8 @@ interface WorkflowDefinition> { * export const fetchData = createWorkflowJob({ name: "fetch-data", body: async (input: { id: string }) => ({ id: input.id }) }); * export const processData = createWorkflowJob({ * name: "process-data", - * body: async (input: { id: string }) => { - * const data = await fetchData.trigger({ id: input.id }); + * body: (input: { id: string }) => { + * const data = fetchData.trigger({ id: input.id }); * return { data }; * }, * }); @@ -63,15 +62,10 @@ interface WorkflowDefinition> { export function createWorkflow>( config: WorkflowDefinition, ): Workflow { - // Test-only registry/trigger shim; the platform bundle sets the flag so it is DCE'd. - if (!process.env.TAILOR_PLATFORM_BUNDLE) { - registerWorkflow(config.name, config.mainJob.name); - } - return brandValue( { ...config, - trigger: process.env.TAILOR_PLATFORM_BUNDLE + trigger: process.env.__TAILOR_PLATFORM_BUNDLE ? async () => { throw new Error( "workflow.trigger() is rewritten at build time and unavailable in the bundle", diff --git a/packages/sdk/src/configure/types/aigateway-name.ts b/packages/sdk/src/configure/types/aigateway-name.ts index ce1797b86a..da06445082 100644 --- a/packages/sdk/src/configure/types/aigateway-name.ts +++ b/packages/sdk/src/configure/types/aigateway-name.ts @@ -6,7 +6,7 @@ export interface AIGatewayNameRegistry {} /** * AI Gateway name. * - * When `tailor.d.ts` is generated (via `tailor-sdk deploy`/`generate`), this is narrowed + * When `tailor.d.ts` is generated (via `tailor deploy`/`generate`), this is narrowed * to the union of AI Gateway names defined via `defineAIGateway()`. Falls back to * `string` before the first generate run. */ diff --git a/packages/sdk/src/configure/types/connection-name.ts b/packages/sdk/src/configure/types/connection-name.ts index 710342fb68..5eb670a544 100644 --- a/packages/sdk/src/configure/types/connection-name.ts +++ b/packages/sdk/src/configure/types/connection-name.ts @@ -6,7 +6,7 @@ export interface ConnectionNameRegistry {} /** * Auth connection name. * - * When `tailor.d.ts` is generated (via `tailor-sdk deploy`/`generate`), this is narrowed + * When `tailor.d.ts` is generated (via `tailor deploy`/`generate`), this is narrowed * to the union of connection names defined in `defineAuth()`'s `connections`. Falls back * to `string` before the first generate run. */ diff --git a/packages/sdk/src/configure/types/field-format.ts b/packages/sdk/src/configure/types/field-format.ts new file mode 100644 index 0000000000..05b571cfca --- /dev/null +++ b/packages/sdk/src/configure/types/field-format.ts @@ -0,0 +1,28 @@ +const regex = { + uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + date: /^\d{4}-\d{2}-\d{2}$/, + datetime: + /^\d{4}-\d{2}-\d{2}[Tt](?[01]\d|2[0-3]):(?[0-5]\d):(?[0-5]\d|60)(\.(?\d+))?(?[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/, + time: /^(?[01]\d|2[0-3]):(?[0-5]\d)$/, + decimal: /^-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/, +} as const; + +export function isValidUUIDString(value: string): boolean { + return regex.uuid.test(value); +} + +export function isValidDateString(value: string): boolean { + return regex.date.test(value); +} + +export function isValidDateTimeString(value: string): boolean { + return regex.datetime.test(value); +} + +export function isValidTimeString(value: string): boolean { + return regex.time.test(value); +} + +export function isValidDecimalString(value: string): boolean { + return regex.decimal.test(value); +} diff --git a/packages/sdk/src/configure/types/field-runtime.ts b/packages/sdk/src/configure/types/field-runtime.ts index c326f94d94..c2de9c4a49 100644 --- a/packages/sdk/src/configure/types/field-runtime.ts +++ b/packages/sdk/src/configure/types/field-runtime.ts @@ -1,26 +1,24 @@ +import { + isValidDateString, + isValidDateTimeString, + isValidDecimalString, + isValidTimeString, + isValidUUIDString, +} from "#/configure/types/field-format"; import type { FieldMetadata, TailorFieldType } from "#/configure/types/field.types"; -import type { TailorUser } from "#/runtime/types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { StandardSchemaV1 } from "@standard-schema/spec"; -const regex = { - uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, - date: /^(?\d{4})-(?\d{2})-(?\d{2})$/, - time: /^(?\d{2}):(?\d{2})$/, - datetime: - /^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(.(?\d{3}))?Z$/, - decimal: /^-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/, -} as const; - export type FieldParseArgs = { value: unknown; data: unknown; - user: TailorUser; + invoker: TailorPrincipal | null; }; export type FieldParseInternalArgs = { value: unknown; data: unknown; - user: TailorUser; + invoker: TailorPrincipal | null; pathArray: string[]; }; @@ -35,7 +33,7 @@ type FieldValidateValueArgs = { field: FieldRuntime; value: unknown; data: unknown; - user: TailorUser; + invoker: TailorPrincipal | null; pathArray: string[]; }; @@ -46,7 +44,7 @@ type FieldParseRuntimeArgs = FieldParseInternalArgs & function validateValue( args: FieldValidateValueArgs, ): StandardSchemaV1.Issue[] { - const { field, value, data, user, pathArray } = args; + const { field, value, data, invoker, pathArray } = args; const issues: StandardSchemaV1.Issue[] = []; const path = pathArray.length > 0 ? pathArray : undefined; @@ -88,7 +86,7 @@ function validateValue( break; case "uuid": - if (typeof value !== "string" || !regex.uuid.test(value)) { + if (typeof value !== "string" || !isValidUUIDString(value)) { issues.push({ message: `Expected a valid UUID: received ${String(value)}`, path, @@ -96,7 +94,7 @@ function validateValue( } break; case "date": - if (typeof value !== "string" || !regex.date.test(value)) { + if (typeof value !== "string" || !isValidDateString(value)) { issues.push({ message: `Expected to match "yyyy-MM-dd" format: received ${String(value)}`, path, @@ -104,7 +102,7 @@ function validateValue( } break; case "datetime": - if (typeof value !== "string" || !regex.datetime.test(value)) { + if (typeof value !== "string" || !isValidDateTimeString(value)) { issues.push({ message: `Expected to match ISO format: received ${String(value)}`, path, @@ -112,7 +110,7 @@ function validateValue( } break; case "time": - if (typeof value !== "string" || !regex.time.test(value)) { + if (typeof value !== "string" || !isValidTimeString(value)) { issues.push({ message: `Expected to match "HH:mm" format: received ${String(value)}`, path, @@ -120,7 +118,7 @@ function validateValue( } break; case "decimal": - if (typeof value !== "string" || !regex.decimal.test(value)) { + if (typeof value !== "string" || !isValidDecimalString(value)) { issues.push({ message: `Expected a decimal string: received ${String(value)}`, path, @@ -160,7 +158,7 @@ function validateValue( const result = nestedField._parseInternal({ value: fieldValue, data, - user, + invoker, pathArray: pathArray.concat(fieldName), }); if (result.issues) { @@ -173,15 +171,11 @@ function validateValue( const validateFns = field._metadata.validate; if (validateFns && validateFns.length > 0) { - for (const validateInput of validateFns) { - const { fn, message } = - typeof validateInput === "function" - ? { fn: validateInput, message: "Validation failed" } - : { fn: validateInput[0], message: validateInput[1] }; - - if (!fn({ value, data, user })) { + for (const fn of validateFns) { + const result = fn({ value }); + if (typeof result === "string") { issues.push({ - message, + message: result, path, }); } @@ -194,7 +188,7 @@ function validateValue( export function parseInternal( args: FieldParseRuntimeArgs, ): StandardSchemaV1.Result { - const { field, value, data, user, pathArray } = args; + const { field, value, data, invoker, pathArray } = args; const issues: StandardSchemaV1.Issue[] = []; const path = pathArray.length > 0 ? pathArray : undefined; @@ -228,7 +222,7 @@ export function parseInternal( field, value: elementValue, data, - user, + invoker, pathArray: elementPath, }); if (elementIssues.length > 0) { @@ -242,7 +236,7 @@ export function parseInternal( return { value: value as Output }; } - const valueIssues = validateValue({ field, value, data, user, pathArray }); + const valueIssues = validateValue({ field, value, data, invoker, pathArray }); issues.push(...valueIssues); if (issues.length > 0) { diff --git a/packages/sdk/src/configure/types/field.types.ts b/packages/sdk/src/configure/types/field.types.ts index 7f9dcff4ec..5b0a62cdb0 100644 --- a/packages/sdk/src/configure/types/field.types.ts +++ b/packages/sdk/src/configure/types/field.types.ts @@ -3,6 +3,14 @@ // This is a pure type module: type declarations only, no zod/schema // references, importable type-only from any layer. +import type { + DateString, + DateTimeString, + DecimalString, + TimeString, + UUIDString, +} from "./scalar.types"; + export interface EnumValue { value: string; description?: string; @@ -21,16 +29,25 @@ export type TailorFieldType = | "time" | "nested"; +export type { + DateString, + DateTimeString, + DecimalString, + TimeString, + TimeZoneOffsetString, + UUIDString, +} from "./scalar.types"; + export type TailorToTs = { string: string; integer: number; float: number; - decimal: string; + decimal: DecimalString; boolean: boolean; - uuid: string; - date: string; - datetime: string | Date; - time: string; + uuid: UUIDString; + date: DateString; + datetime: DateTimeString | Date; + time: TimeString; enum: string; object: Record; nested: Record; @@ -74,68 +91,15 @@ export type ArrayFieldOutput = [O] extends [ ? T[] : T; -import type { TailorUser } from "#/runtime/types"; -import type { output, InferFieldsOutput } from "#/types/helpers"; -import type { NonEmptyObject } from "type-fest"; - -/** - * Validation function type - */ -export type ValidateFn = (args: { value: O; data: D; user: TailorUser }) => boolean; - -/** - * Validation configuration with custom error message - */ -export type ValidateConfig = [ValidateFn, string]; - -/** - * Field-level validation function - */ -type FieldValidateFn = ValidateFn; - -/** - * Field-level validation configuration - */ -type FieldValidateConfig = ValidateConfig; - /** - * Input type for field validation - can be either a function or a tuple of [function, errorMessage] + * Field validation function. Return an error message string to fail, or void/undefined to pass. */ -export type FieldValidateInput = FieldValidateFn | FieldValidateConfig; +export type ValidateFn = (args: { value: O }) => string | void; /** - * Base validators type for field collections - * @template F - Record of fields - * @template ExcludeKeys - Keys to exclude from validation (default: "id" for TailorDB) + * Input type for field validation */ -type ValidatorsBase< - // Structural constraint only - // oxlint-disable-next-line no-explicit-any - F extends Record, - ExcludeKeys extends string = "id", -> = NonEmptyObject<{ - [K in Exclude as F[K]["_defined"] extends { - validate: unknown; - } - ? never - : K]?: - | ValidateFn, InferFieldsOutput> - | ValidateConfig, InferFieldsOutput> - | ( - | ValidateFn, InferFieldsOutput> - | ValidateConfig, InferFieldsOutput> - )[]; -}>; - -/** - * Validators type (by default excludes "id" field for TailorDB compatibility) - * Can be used with both TailorField and TailorDBField - */ -export type Validators< - // Structural constraint only - // oxlint-disable-next-line no-explicit-any - F extends Record, -> = ValidatorsBase; +export type FieldValidateInput = ValidateFn; /** * Minimal structural interface for TailorField. diff --git a/packages/sdk/src/configure/types/idp-name.ts b/packages/sdk/src/configure/types/idp-name.ts index a6aa946e8c..db0e3908b0 100644 --- a/packages/sdk/src/configure/types/idp-name.ts +++ b/packages/sdk/src/configure/types/idp-name.ts @@ -6,7 +6,7 @@ export interface IdpNameRegistry {} /** * IdP namespace name. * - * When `tailor.d.ts` is generated (via `tailor-sdk deploy`/`generate`), this is narrowed + * When `tailor.d.ts` is generated (via `tailor deploy`/`generate`), this is narrowed * to the union of defined IdP names. When no IdPs are registered yet, falls back to * `string` to avoid blocking editing before the first generate run. */ diff --git a/packages/sdk/src/configure/types/index.ts b/packages/sdk/src/configure/types/index.ts index 7521bddc3a..398ec2c087 100644 --- a/packages/sdk/src/configure/types/index.ts +++ b/packages/sdk/src/configure/types/index.ts @@ -1,3 +1,12 @@ export * from "./machine-user"; export * from "./type"; export * from "./field"; +export * from "./scalar"; +export type { + DateString, + DateTimeString, + DecimalString, + TimeString, + TimeZoneOffsetString, + UUIDString, +} from "./scalar.types"; diff --git a/packages/sdk/src/configure/types/machine-user.ts b/packages/sdk/src/configure/types/machine-user.ts index 924afecc27..ad71dfef04 100644 --- a/packages/sdk/src/configure/types/machine-user.ts +++ b/packages/sdk/src/configure/types/machine-user.ts @@ -1,15 +1 @@ -// Interface for module augmentation -// Users can extend via: declare module "@tailor-platform/sdk" { interface MachineUserNameRegistry { ... } } -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface MachineUserNameRegistry {} - -/** - * Machine user name. - * - * When `tailor.d.ts` is generated (via `tailor-sdk deploy`/`generate`), this is narrowed - * to the union of defined machine user names. When no machine users are registered yet, - * falls back to `string` to avoid blocking editing before the first generate run. - */ -export type MachineUserName = keyof MachineUserNameRegistry extends never - ? string - : keyof MachineUserNameRegistry & string; +export type { MachineUserName, MachineUserNameRegistry } from "#/configure/services/auth/types"; diff --git a/packages/sdk/src/configure/types/scalar.test.ts b/packages/sdk/src/configure/types/scalar.test.ts new file mode 100644 index 0000000000..46e61ae739 --- /dev/null +++ b/packages/sdk/src/configure/types/scalar.test.ts @@ -0,0 +1,122 @@ +// oxlint-disable vitest/expect-expect -- Type-only assertions are checked by TypeScript. +import { describe, expect, expectTypeOf, test } from "vitest"; +import { + assertDateString, + assertDateTimeString, + assertDecimalString, + assertTimeString, + assertUUIDString, + isDateString, + isDateTimeString, + isDecimalString, + isTimeString, + isUUIDString, + parseDateString, + parseDateTimeString, + parseDecimalString, + parseTimeString, + parseUUIDString, +} from "../index"; +import type { DateString, DateTimeString, DecimalString, TimeString, UUIDString } from "../index"; + +describe("scalar string helpers", () => { + test("type guard helpers narrow unknown values", () => { + const uuid: unknown = "550e8400-e29b-41d4-a716-446655440000"; + const date: unknown = "2026-07-03"; + const datetime: unknown = "2026-07-03T12:34:56+09:00"; + const time: unknown = "12:34"; + const decimal: unknown = "123.45"; + + if (isUUIDString(uuid)) { + expectTypeOf(uuid).toEqualTypeOf(); + } + if (isDateString(date)) { + expectTypeOf(date).toEqualTypeOf(); + } + if (isDateTimeString(datetime)) { + expectTypeOf(datetime).toEqualTypeOf(); + } + if (isTimeString(time)) { + expectTypeOf(time).toEqualTypeOf(); + } + if (isDecimalString(decimal)) { + expectTypeOf(decimal).toEqualTypeOf(); + } + + expect(isUUIDString(uuid)).toBe(true); + expect(isDateString(date)).toBe(true); + expect(isDateTimeString(datetime)).toBe(true); + expect(isTimeString(time)).toBe(true); + expect(isDecimalString(decimal)).toBe(true); + }); + + test("type guard helpers reject invalid values", () => { + expect(isUUIDString("not-a-uuid")).toBe(false); + expect(isDateString("2026/07/03")).toBe(false); + expect(isDateTimeString("2026-07-03T12:34:56+0900")).toBe(false); + expect(isTimeString("12:34:56")).toBe(false); + expect(isTimeString("24:00")).toBe(false); + expect(isTimeString("23:60")).toBe(false); + expect(isTimeString("99:99")).toBe(false); + expect(isDecimalString("1_000")).toBe(false); + expect(isUUIDString(123)).toBe(false); + }); + + test("parse helpers return typed scalar strings", () => { + const uuid = parseUUIDString("550e8400-e29b-41d4-a716-446655440000"); + const date = parseDateString("2026-07-03"); + const datetime = parseDateTimeString("2026-07-03T12:34:56Z"); + const time = parseTimeString("12:34"); + const decimal = parseDecimalString("123.45"); + + expectTypeOf(uuid).toEqualTypeOf(); + expectTypeOf(date).toEqualTypeOf(); + expectTypeOf(datetime).toEqualTypeOf(); + expectTypeOf(time).toEqualTypeOf(); + expectTypeOf(decimal).toEqualTypeOf(); + + expect(uuid).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(date).toBe("2026-07-03"); + expect(datetime).toBe("2026-07-03T12:34:56Z"); + expect(time).toBe("12:34"); + expect(decimal).toBe("123.45"); + }); + + test("parse helpers throw TypeError with the given label", () => { + expect(() => parseUUIDString("not-a-uuid", "customerId")).toThrow( + new TypeError("customerId must be a UUID string"), + ); + expect(() => parseDateString("2026/07/03", "businessDate")).toThrow( + new TypeError("businessDate must be a date string"), + ); + expect(() => parseDateTimeString("2026-07-03T12:34:56+0900", "scheduledAt")).toThrow( + new TypeError("scheduledAt must be a datetime string"), + ); + expect(() => parseTimeString("12:34:56", "openingTime")).toThrow( + new TypeError("openingTime must be a time string"), + ); + expect(() => parseDecimalString("1_000", "amount")).toThrow( + new TypeError("amount must be a decimal string"), + ); + }); + + test("assert helpers narrow unknown values", () => { + const uuid: unknown = "550e8400-e29b-41d4-a716-446655440000"; + const date: unknown = "2026-07-03"; + const datetime: unknown = "2026-07-03T12:34:56Z"; + const time: unknown = "12:34"; + const decimal: unknown = "123.45"; + + assertUUIDString(uuid); + assertDateString(date); + assertDateTimeString(datetime); + assertTimeString(time); + assertDecimalString(decimal); + + expectTypeOf(uuid).toEqualTypeOf(); + expectTypeOf(date).toEqualTypeOf(); + expectTypeOf(datetime).toEqualTypeOf(); + expectTypeOf(time).toEqualTypeOf(); + expectTypeOf(decimal).toEqualTypeOf(); + }); +}); diff --git a/packages/sdk/src/configure/types/scalar.ts b/packages/sdk/src/configure/types/scalar.ts new file mode 100644 index 0000000000..3eccb9ffdd --- /dev/null +++ b/packages/sdk/src/configure/types/scalar.ts @@ -0,0 +1,207 @@ +import { + isValidDateString, + isValidDateTimeString, + isValidDecimalString, + isValidTimeString, + isValidUUIDString, +} from "./field-format"; +import type { + DateString, + DateTimeString, + DecimalString, + TimeString, + UUIDString, +} from "./scalar.types"; + +function scalarTypeError(label: string, expected: string): TypeError { + return new TypeError(`${label} must be a ${expected}`); +} + +type ScalarHelpers = { + isString: (value: unknown) => value is T; + parseString: (value: unknown, label?: string) => T; + assertString: (value: unknown, label?: string) => asserts value is T; +}; + +function makeScalarHelpers( + isValid: (value: string) => boolean, + expected: string, +): ScalarHelpers { + const isString = (value: unknown): value is T => typeof value === "string" && isValid(value); + const parseString = (value: unknown, label = "value"): T => { + if (isString(value)) return value; + throw scalarTypeError(label, expected); + }; + const assertString = (value: unknown, label?: string): asserts value is T => { + parseString(value, label); + }; + + return { isString, parseString, assertString }; +} + +const uuidString: ScalarHelpers = makeScalarHelpers( + isValidUUIDString, + "UUID string", +); +const dateString: ScalarHelpers = makeScalarHelpers( + isValidDateString, + "date string", +); +const dateTimeString: ScalarHelpers = makeScalarHelpers( + isValidDateTimeString, + "datetime string", +); +const timeString: ScalarHelpers = makeScalarHelpers( + isValidTimeString, + "time string", +); +const decimalString: ScalarHelpers = makeScalarHelpers( + isValidDecimalString, + "decimal string", +); + +/** + * Check whether a value is a UUID string accepted by Tailor fields. + * @param value - Value to check + * @returns True when the value is a UUID string + */ +export function isUUIDString(value: unknown): value is UUIDString { + return uuidString.isString(value); +} + +/** + * Check whether a value is a date string accepted by Tailor fields. + * @param value - Value to check + * @returns True when the value matches `yyyy-MM-dd` + */ +export function isDateString(value: unknown): value is DateString { + return dateString.isString(value); +} + +/** + * Check whether a value is a datetime string accepted by Tailor fields. + * @param value - Value to check + * @returns True when the value matches the supported ISO datetime format + */ +export function isDateTimeString(value: unknown): value is DateTimeString { + return dateTimeString.isString(value); +} + +/** + * Check whether a value is a time string accepted by Tailor fields. + * @param value - Value to check + * @returns True when the value matches `HH:mm` + */ +export function isTimeString(value: unknown): value is TimeString { + return timeString.isString(value); +} + +/** + * Check whether a value is a decimal string accepted by Tailor fields. + * @param value - Value to check + * @returns True when the value is a decimal string + */ +export function isDecimalString(value: unknown): value is DecimalString { + return decimalString.isString(value); +} + +/** + * Parse a value as a UUID string. + * @param value - Value to parse + * @param label - Name used in the error message + * @returns The original value typed as `UUIDString` + */ +export function parseUUIDString(value: unknown, label = "value"): UUIDString { + return uuidString.parseString(value, label); +} + +/** + * Parse a value as a date string. + * @param value - Value to parse + * @param label - Name used in the error message + * @returns The original value typed as `DateString` + */ +export function parseDateString(value: unknown, label = "value"): DateString { + return dateString.parseString(value, label); +} + +/** + * Parse a value as a datetime string. + * @param value - Value to parse + * @param label - Name used in the error message + * @returns The original value typed as `DateTimeString` + */ +export function parseDateTimeString(value: unknown, label = "value"): DateTimeString { + return dateTimeString.parseString(value, label); +} + +/** + * Parse a value as a time string. + * @param value - Value to parse + * @param label - Name used in the error message + * @returns The original value typed as `TimeString` + */ +export function parseTimeString(value: unknown, label = "value"): TimeString { + return timeString.parseString(value, label); +} + +/** + * Parse a value as a decimal string. + * @param value - Value to parse + * @param label - Name used in the error message + * @returns The original value typed as `DecimalString` + */ +export function parseDecimalString(value: unknown, label = "value"): DecimalString { + return decimalString.parseString(value, label); +} + +/** + * Assert that a value is a UUID string. + * @param value - Value to check + * @param label - Name used in the error message + */ +export function assertUUIDString(value: unknown, label?: string): asserts value is UUIDString { + uuidString.assertString(value, label); +} + +/** + * Assert that a value is a date string. + * @param value - Value to check + * @param label - Name used in the error message + */ +export function assertDateString(value: unknown, label?: string): asserts value is DateString { + dateString.assertString(value, label); +} + +/** + * Assert that a value is a datetime string. + * @param value - Value to check + * @param label - Name used in the error message + */ +export function assertDateTimeString( + value: unknown, + label?: string, +): asserts value is DateTimeString { + dateTimeString.assertString(value, label); +} + +/** + * Assert that a value is a time string. + * @param value - Value to check + * @param label - Name used in the error message + */ +export function assertTimeString(value: unknown, label?: string): asserts value is TimeString { + timeString.assertString(value, label); +} + +/** + * Assert that a value is a decimal string. + * @param value - Value to check + * @param label - Name used in the error message + */ +export function assertDecimalString( + value: unknown, + label?: string, +): asserts value is DecimalString { + decimalString.assertString(value, label); +} diff --git a/packages/sdk/src/configure/types/scalar.types.ts b/packages/sdk/src/configure/types/scalar.types.ts new file mode 100644 index 0000000000..5b2653349f --- /dev/null +++ b/packages/sdk/src/configure/types/scalar.types.ts @@ -0,0 +1,7 @@ +export type DateString = `${number}-${number}-${number}`; +export type TimeString = `${number}:${number}`; +export type TimeZoneOffsetString = "Z" | "z" | `${"+" | "-"}${TimeString}`; +export type DateTimeString = + `${DateString}${"T" | "t"}${TimeString}:${number}${"" | `.${number}`}${TimeZoneOffsetString}`; +export type UUIDString = `${string}-${string}-${string}-${string}-${string}`; +export type DecimalString = `${number}`; diff --git a/packages/sdk/src/configure/types/type-typename.test.ts b/packages/sdk/src/configure/types/type-typename.test.ts index 65a2dbd5dc..c61da6699d 100644 --- a/packages/sdk/src/configure/types/type-typename.test.ts +++ b/packages/sdk/src/configure/types/type-typename.test.ts @@ -24,7 +24,7 @@ describe("typeName method type safety", () => { TypeEquals >; - const validated = t.string().validate(() => true); + const validated = t.string().validate(() => undefined); type _ValidateDuplicate = Expect< TypeEquals >; diff --git a/packages/sdk/src/configure/types/type.test.ts b/packages/sdk/src/configure/types/type.test.ts index 92cb08443d..e95b5ee9c5 100644 --- a/packages/sdk/src/configure/types/type.test.ts +++ b/packages/sdk/src/configure/types/type.test.ts @@ -1,12 +1,13 @@ // oxlint-disable vitest/expect-expect -- Type-only assertions are checked by TypeScript. import { describe, expect, test, expectTypeOf } from "vitest"; import { t } from "./type"; -import type { TailorUser } from "#/runtime/types"; +import type { TailorPrincipal } from "#/runtime/types"; import type { output } from "#/types/helpers"; import type { AllowedValues } from "./field"; +import type { DateString, DateTimeString, TimeString, UUIDString } from "./scalar.types"; -const user: TailorUser = { - id: "test", +const invoker: TailorPrincipal = { + id: "123e4567-e89b-12d3-a456-426614174000", type: "user", workspaceId: "workspace-test", attributes: {}, @@ -59,39 +60,39 @@ describe("TailorType basic field type tests", () => { }>(); }); - test("uuid field outputs string type correctly", () => { + test("uuid field outputs UUID string type correctly", () => { const _uuidType = t.object({ id: t.uuid(), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; }>(); }); - test("date field outputs string type correctly", () => { + test("date field outputs date string type correctly", () => { const _dateType = t.object({ birthDate: t.date(), }); expectTypeOf>().toEqualTypeOf<{ - birthDate: string; + birthDate: DateString; }>(); }); - test("datetime field outputs string | Date type correctly", () => { + test("datetime field outputs datetime string | Date type correctly", () => { const _datetimeType = t.object({ createdAt: t.datetime(), }); expectTypeOf>().toEqualTypeOf<{ - createdAt: string | Date; + createdAt: DateTimeString | Date; }>(); }); - test("time field outputs string type correctly", () => { + test("time field outputs time string type correctly", () => { const _timeType = t.object({ openingTime: t.time(), }); expectTypeOf>().toEqualTypeOf<{ - openingTime: string; + openingTime: TimeString; }>(); }); }); @@ -269,7 +270,7 @@ describe("TailorType composite type tests", () => { role: t.enum(["admin", "user", "guest"]), }); expectTypeOf>().toEqualTypeOf<{ - id: string; + id: UUIDString; name: string; email: string; age?: number | null; @@ -445,7 +446,7 @@ describe("t.object tests", () => { }); expectTypeOf>().toEqualTypeOf<{ items: { - id: string; + id: UUIDString; name: string; }[]; }>(); @@ -464,7 +465,7 @@ describe("t.object tests", () => { expectTypeOf>().toEqualTypeOf<{ optionalItems?: | { - id: string; + id: UUIDString; value?: string | null; }[] | null; @@ -515,10 +516,10 @@ describe("t.object tests", () => { describe("TailorField runtime validation tests", () => { describe("validates primitive types", () => { test("validates string type", () => { - const ok = t.string().parse({ value: "valid string", data, user }); + const ok = t.string().parse({ value: "valid string", data, invoker }); expect(expectParsed(ok)).toBe("valid string"); - const bad = t.string().parse({ value: 123, data, user }); + const bad = t.string().parse({ value: 123, data, invoker }); expect(bad.issues).toBeDefined(); expect(bad.issues?.[0]?.message).toEqual("Expected a string: received 123"); expect(bad.issues?.[0]?.path).toBeUndefined(); @@ -528,13 +529,13 @@ describe("TailorField runtime validation tests", () => { { value: "invalid string", message: "Expected an integer: received invalid string" }, { value: 1.5, message: "Expected an integer: received 1.5" }, ])("validates integer type - rejects $value", ({ value, message }) => { - const result = t.int().parse({ value, data, user }); + const result = t.int().parse({ value, data, invoker }); expect(result.issues).toBeDefined(); expect(result.issues?.[0]?.message).toEqual(message); }); test("validates integer type - accepts a valid integer", () => { - const result = t.int().parse({ value: 123, data, user }); + const result = t.int().parse({ value: 123, data, invoker }); expect(expectParsed(result)).toBe(123); }); @@ -542,21 +543,21 @@ describe("TailorField runtime validation tests", () => { { value: Number.NaN, message: "Expected a number: received NaN" }, { value: "invalid string", message: "Expected a number: received invalid string" }, ])("validates float type - rejects $value", ({ value, message }) => { - const result = t.float().parse({ value, data, user }); + const result = t.float().parse({ value, data, invoker }); expect(result.issues).toBeDefined(); expect(result.issues?.[0]?.message).toEqual(message); }); test("validates float type - accepts a valid float", () => { - const result = t.float().parse({ value: 1.5, data, user }); + const result = t.float().parse({ value: 1.5, data, invoker }); expect(expectParsed(result)).toBe(1.5); }); test("validates boolean type", () => { - const ok = t.bool().parse({ value: true, data, user }); + const ok = t.bool().parse({ value: true, data, invoker }); expect(expectParsed(ok)).toBe(true); - const bad = t.bool().parse({ value: "true", data, user }); + const bad = t.bool().parse({ value: "true", data, invoker }); expect(bad.issues).toBeDefined(); expect(bad.issues?.[0]?.message).toEqual("Expected a boolean: received true"); }); @@ -593,23 +594,61 @@ describe("TailorField runtime validation tests", () => { invalidMessage: 'Expected to match "HH:mm" format: received 10:11:12', }, ])("validates $name format", ({ field, validValue, invalidValue, invalidMessage }) => { - const ok = field.parse({ value: validValue, data, user }); - expect(expectParsed(ok)).toBe(validValue); + const ok = field.parse({ value: validValue, data, invoker }); + if (ok.issues) { + throw new Error("Unexpected issues"); + } + expect(ok.value).toBe(validValue); - const bad = field.parse({ value: invalidValue, data, user }); + const bad = field.parse({ value: invalidValue, data, invoker }); expect(bad.issues).toBeDefined(); expect(bad.issues?.[0]?.message).toEqual(invalidMessage); }); + + test("accepts a date with an out-of-range day (e.g. February 30)", () => { + const result = t.date().parse({ value: "2025-02-30", data, invoker }); + expect(expectParsed(result)).toBe("2025-02-30"); + }); + + test.each([ + "2025-12-21T10:11:12Z", + "2025-12-21T10:11:12.123456Z", + "2025-12-21T10:11:12+09:00", + "2025-12-21t10:11:12-08:00", + "2025-02-30T10:11:12Z", + ])("validates datetime format - accepts %s", (value) => { + const result = t.datetime().parse({ value, data, invoker }); + expect(expectParsed(result)).toBe(value); + }); + + test.each([ + { + value: "2025-12-21T10:11:12+0900", + message: "Expected to match ISO format: received 2025-12-21T10:11:12+0900", + }, + { + value: "2025-12-21T25:11:12Z", + message: "Expected to match ISO format: received 2025-12-21T25:11:12Z", + }, + { + value: "2025-12-21T10:11:12+24:00", + message: "Expected to match ISO format: received 2025-12-21T10:11:12+24:00", + }, + ])("validates datetime format - rejects $value", ({ value, message }) => { + const result = t.datetime().parse({ value, data, invoker }); + expect(result.issues).toBeDefined(); + expect(result.issues?.[0]?.message).toEqual(message); + }); }); describe("validates complex types", () => { test("validates enum values", () => { const status = t.enum(["active", "inactive"]); - const ok = status.parse({ value: "active", data, user }); + const ok = status.parse({ value: "active", data, invoker }); expect(expectParsed(ok)).toBe("active"); - const bad = status.parse({ value: "pending", data, user }); + const bad = status.parse({ value: "pending", data, invoker }); expect(bad.issues).toBeDefined(); expect(bad.issues?.[0]?.message).toEqual( "Must be one of [active, inactive]: received pending", @@ -626,11 +665,11 @@ describe("TailorField runtime validation tests", () => { const ok = schema.parse({ value: { name: "name", age: null, gender: "male" }, data, - user, + invoker, }); expect(expectParsed(ok)).toEqual({ name: "name", age: null, gender: "male" }); - const bad = schema.parse({ value: { age: 1, gender: "invalid" }, data, user }); + const bad = schema.parse({ value: { age: 1, gender: "invalid" }, data, invoker }); expect(bad.issues).toBeDefined(); expect(bad.issues).toEqual([ { message: "Required field is missing", path: ["name"] }, @@ -639,7 +678,7 @@ describe("TailorField runtime validation tests", () => { const notAnObjectSchema = t.object({ value: t.string({ optional: true }) }); const now = new Date(); - const notAnObject = notAnObjectSchema.parse({ value: now, data, user }); + const notAnObject = notAnObjectSchema.parse({ value: now, data, invoker }); expect(notAnObject.issues).toBeDefined(); expect(notAnObject.issues?.[0]?.message).toEqual( `Expected an object: received ${String(now)}`, @@ -649,14 +688,14 @@ describe("TailorField runtime validation tests", () => { test("validates array fields and element paths", () => { const schema = t.int({ array: true }); - const ok = schema.parse({ value: [1, 2, 3], data, user }); + const ok = schema.parse({ value: [1, 2, 3], data, invoker }); expect(expectParsed(ok)).toEqual([1, 2, 3]); - const notAnArray = schema.parse({ value: "invalid", data, user }); + const notAnArray = schema.parse({ value: "invalid", data, invoker }); expect(notAnArray.issues).toBeDefined(); expect(notAnArray.issues?.[0]?.message).toEqual("Expected an array"); - const badElement = schema.parse({ value: [1, "x"], data, user }); + const badElement = schema.parse({ value: [1, "x"], data, invoker }); expect(badElement.issues).toBeDefined(); expect(badElement.issues?.[0]).toEqual({ path: ["[1]"], @@ -665,16 +704,16 @@ describe("TailorField runtime validation tests", () => { }); test("treats null/undefined as missing when required, and allowed when optional", () => { - const required = t.string().parse({ value: null, data, user }); + const required = t.string().parse({ value: null, data, invoker }); expect(required.issues).toBeDefined(); expect(required.issues?.[0]?.message).toEqual("Required field is missing"); - const optionalScalar = t.string({ optional: true }).parse({ value: null, data, user }); + const optionalScalar = t.string({ optional: true }).parse({ value: null, data, invoker }); expect(expectParsed(optionalScalar)).toBeNull(); const optionalArray = t .int({ optional: true, array: true }) - .parse({ value: null, data, user }); + .parse({ value: null, data, invoker }); expect(expectParsed(optionalArray)).toBeNull(); }); }); @@ -692,14 +731,14 @@ describe("TailorField runtime validation tests", () => { "2.41E-3", "-1.5e10", ])("accepts valid decimal string %s", (value) => { - const result = t.decimal().parse({ value, data, user }); + const result = t.decimal().parse({ value, data, invoker }); expect(expectParsed(result)).toBe(value); }); test.each(["abc", "", "1_000_000", "0b1.1p-5", "1e", "e5", ".", 123])( "rejects invalid decimal value %s", (value) => { - const result = t.decimal().parse({ value, data, user }); + const result = t.decimal().parse({ value, data, invoker }); expect(result.issues).toBeDefined(); }, ); @@ -727,7 +766,9 @@ describe("TailorField clone-on-write / no aliasing", () => { test("validate() returns a clone and never mutates the original", () => { const original = t.string(); - const updated = original.validate((args) => args.value.length > 0); + const updated = original.validate((args) => + args.value.length <= 0 ? "Must not be empty" : undefined, + ); expect(original.metadata.validate).toBeUndefined(); expect(updated.metadata.validate).toHaveLength(1); @@ -789,7 +830,9 @@ describe("TailorField clone-on-write / no aliasing", () => { expect(enumClone.metadata.allowedValues).not.toBe(enumField.metadata.allowedValues); expect(enumClone.metadata.allowedValues?.[0]).not.toBe(enumField.metadata.allowedValues?.[0]); - const validated = t.string().validate((args) => args.value.length > 0); + const validated = t + .string() + .validate((args) => (args.value.length <= 0 ? "Must not be empty" : undefined)); const validatedClone = validated.description("name"); expect(validatedClone.metadata.validate).not.toBe(validated.metadata.validate); }); @@ -798,10 +841,10 @@ describe("TailorField clone-on-write / no aliasing", () => { const status = t.enum(["active", "inactive"]); const cloned = status.description("status field"); - const ok = cloned.parse({ value: "active", data, user }); + const ok = cloned.parse({ value: "active", data, invoker }); expect(ok.issues).toBeUndefined(); - const ng = cloned.parse({ value: "pending", data, user }); + const ng = cloned.parse({ value: "pending", data, invoker }); expect(ng.issues).toBeDefined(); expect(ng.issues?.[0]?.message).toEqual("Must be one of [active, inactive]: received pending"); }); @@ -810,16 +853,18 @@ describe("TailorField clone-on-write / no aliasing", () => { const calls: unknown[] = []; const field = t.string().validate((args) => { calls.push(args.value); - return args.value.length > 0; + return args.value.length <= 0 ? "Must not be empty" : undefined; }); - const result = field.parse({ value: "x", data, user }); + const result = field.parse({ value: "x", data, invoker }); expect(result.issues).toBeUndefined(); expect(calls).toEqual(["x"]); }); test("validators survive a clone triggered by a later builder, leaving the original intact", () => { - const validated = t.string().validate((args) => args.value.length > 0); + const validated = t + .string() + .validate((args) => (args.value.length <= 0 ? "Must not be empty" : undefined)); // description() clones the field; the validators must carry over to the clone // and keep working, while the original stays unchanged. const described = validated.description("name"); @@ -828,7 +873,7 @@ describe("TailorField clone-on-write / no aliasing", () => { expect(described.metadata.description).toBe("name"); expect(validated.metadata.description).toBeUndefined(); - const failed = described.parse({ value: "", data, user }); + const failed = described.parse({ value: "", data, invoker }); expect(failed.issues).toBeDefined(); }); }); diff --git a/packages/sdk/src/configure/types/type.ts b/packages/sdk/src/configure/types/type.ts index 436493028a..0e60b8166e 100644 --- a/packages/sdk/src/configure/types/type.ts +++ b/packages/sdk/src/configure/types/type.ts @@ -107,7 +107,7 @@ export interface TailorField< /** * Parse and validate a value against this field's validation rules * Returns StandardSchema Result type with success or failure - * @param args - Value, context data, and user + * @param args - Value, context data, and invoker * @returns Validation result */ parse(args: FieldParseArgs): StandardSchemaV1.Result; @@ -275,7 +275,7 @@ function createTailorField< return parseInternal({ value: args.value, data: args.data, - user: args.user, + invoker: args.invoker, pathArray: [], }); }, diff --git a/packages/sdk/src/configure/user.ts b/packages/sdk/src/configure/user.ts deleted file mode 100644 index 76dd0ffa37..0000000000 --- a/packages/sdk/src/configure/user.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { TailorUser } from "#/runtime/types"; - -/** Represents an unauthenticated user in the Tailor platform. */ -export const unauthenticatedTailorUser: TailorUser = { - id: "00000000-0000-0000-0000-000000000000", - type: "", - workspaceId: "00000000-0000-0000-0000-000000000000", - attributes: null, - attributeList: [], -}; diff --git a/packages/sdk/src/kysely/index.test-d.ts b/packages/sdk/src/kysely/index.test-d.ts index 4b6d0d89f3..4210155f17 100644 --- a/packages/sdk/src/kysely/index.test-d.ts +++ b/packages/sdk/src/kysely/index.test-d.ts @@ -4,8 +4,11 @@ import type { ObjectColumnType, ArrayColumnType, Timestamp, + DateString, + DateTimeString, NamespaceInsertable, NamespaceSelectable, + UUIDString, } from "./index"; // Sanity check: verify typecheck catches errors @@ -20,15 +23,17 @@ describe("typecheck sanity", () => { type TestNamespace = { testNs: { Receipt: { - id: Generated; - // 1. plain timestamp - receiptDate: Timestamp; - // 2. timestamp inside object + id: Generated; + // 1. plain date + receiptDate: DateString; + // 2. plain timestamp + createdAt: Timestamp; + // 3. timestamp inside object dueSchedule: ObjectColumnType<{ dueDate: Timestamp; reminderAt?: Timestamp | null; }>; - // 3. timestamp inside object x array + // 4. timestamp inside object x array metadata: ArrayColumnType< ObjectColumnType<{ created: Timestamp; @@ -36,18 +41,19 @@ type TestNamespace = { version: number; }> >; - // 4. timestamp array + // 5. timestamp array eventDates: ArrayColumnType; }; }; }; describe("NamespaceInsertable", () => { - test("should accept Date and string for nested datetime on insert", () => { + test("should accept strict date strings and datetimes on insert", () => { type ReceiptInsertable = NamespaceInsertable; assertType({ - receiptDate: new Date(), + receiptDate: "2024-01-01", + createdAt: new Date(), dueSchedule: { dueDate: new Date(), }, @@ -57,37 +63,49 @@ describe("NamespaceInsertable", () => { assertType({ receiptDate: "2024-01-01", + createdAt: "2024-01-01T00:00:00Z", dueSchedule: { - dueDate: "2024-01-01", + dueDate: "2024-01-01T00:00:00Z", }, - metadata: [{ created: "2024-01-01", version: 1 }], - eventDates: ["2024-01-01"], + metadata: [{ created: "2024-01-01T00:00:00Z", version: 1 }], + eventDates: ["2024-01-01T00:00:00Z"], }); }); }); describe("NamespaceSelectable", () => { - test("should return Date for both top-level and nested datetime", () => { + test("should return strict date strings and datetimes", () => { type ReceiptSelectable = NamespaceSelectable; - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + Date | DateTimeString + >(); // Nullable nested fields should be required in select - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + Date | DateTimeString | null + >(); }); test("should return array of resolved objects for ObjectArrayColumnType", () => { type ReceiptSelectable = NamespaceSelectable; expectTypeOf().toEqualTypeOf< - { created: Date; lastUpdated: Date | null; version: number }[] + { + created: Date | DateTimeString; + lastUpdated: Date | DateTimeString | null; + version: number; + }[] + >(); + expectTypeOf().toEqualTypeOf< + Date | DateTimeString >(); - expectTypeOf().toEqualTypeOf(); }); - test("should return Date[] for timestamp array", () => { + test("should return datetime arrays for timestamp array", () => { type ReceiptSelectable = NamespaceSelectable; - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<(Date | DateTimeString)[]>(); }); }); diff --git a/packages/sdk/src/kysely/index.ts b/packages/sdk/src/kysely/index.ts index e063a33ded..10288b3d5a 100644 --- a/packages/sdk/src/kysely/index.ts +++ b/packages/sdk/src/kysely/index.ts @@ -16,6 +16,7 @@ import { type Transaction as KyselyTransaction, type Updateable, } from "kysely"; +import type { DateTimeString } from "#/configure/types/scalar.types"; export { type ColumnType, @@ -30,7 +31,20 @@ export { export { TailordbDialect } from "@tailor-platform/function-kysely-tailordb"; -export type Timestamp = ColumnType; +export type { + DateString, + DateTimeString, + DecimalString, + TimeString, + TimeZoneOffsetString, + UUIDString, +} from "#/configure/types/scalar.types"; + +export type Timestamp = ColumnType< + Date | DateTimeString, + Date | DateTimeString, + Date | DateTimeString +>; type ResolveSelect = T extends ColumnType ? S : T; type ResolveInsert = T extends ColumnType ? I : T; type ResolveUpdate = T extends ColumnType ? U : T; diff --git a/packages/sdk/src/parser/app-config/schema.test.ts b/packages/sdk/src/parser/app-config/schema.test.ts index 8072615882..d11b94e41a 100644 --- a/packages/sdk/src/parser/app-config/schema.test.ts +++ b/packages/sdk/src/parser/app-config/schema.test.ts @@ -47,12 +47,23 @@ describe("AppConfigSchema", () => { expect(result.success).toBe(true); }); - test("ignores unknown top-level fields without erroring", () => { + test("rejects unknown top-level fields", () => { const result = AppConfigSchema.safeParse({ name: "my-app", futureField: "ok", }); - expect(result.success).toBe(true); + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected AppConfigSchema parsing to fail"); + } + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unrecognized_keys", + keys: ["futureField"], + }), + ]), + ); }); test("rejects when env value type is unsupported", () => { diff --git a/packages/sdk/src/parser/app-config/schema.ts b/packages/sdk/src/parser/app-config/schema.ts index 193a90e590..1299decef0 100644 --- a/packages/sdk/src/parser/app-config/schema.ts +++ b/packages/sdk/src/parser/app-config/schema.ts @@ -22,7 +22,7 @@ const logLevelSchema = z * label-compatible prefix is added at the metadata boundary, so user-facing * configs only need to carry a UUID. */ -export const AppConfigSchema = z.object({ +export const AppConfigSchema = z.strictObject({ id: z.uuid({ message: "'id' must be a UUID." }).optional(), name: z.string().min(1, { message: "'name' must be a non-empty string." }), env: z.record(z.string(), envValueSchema).optional(), diff --git a/packages/sdk/src/parser/generator-config/schema.ts b/packages/sdk/src/parser/generator-config/schema.ts deleted file mode 100644 index 936d0f3465..0000000000 --- a/packages/sdk/src/parser/generator-config/schema.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { z } from "zod"; - -// Dependency kind enum for generators -const DependencyKindSchema = z.enum(["tailordb", "resolver", "executor"]); -export type DependencyKind = z.infer; - -// Literal-based schemas for each generator -const KyselyTypeConfigSchema = z.tuple([ - z.literal("@tailor-platform/kysely-type"), - z.object({ distPath: z.string() }), -]); - -const SeedConfigSchema = z.tuple([ - z.literal("@tailor-platform/seed"), - z.object({ - distPath: z.string(), - machineUserName: z.string().optional(), - disableIdpUserSync: z - .object({ - userToIdp: z.boolean().optional(), - idpToUser: z.boolean().optional(), - }) - .optional(), - }), -]); - -const EnumConstantsConfigSchema = z.tuple([ - z.literal("@tailor-platform/enum-constants"), - z.object({ distPath: z.string() }), -]); - -const FileUtilsConfigSchema = z.tuple([ - z.literal("@tailor-platform/file-utils"), - z.object({ distPath: z.string() }), -]); - -// Custom generator schema with dependencies -export const CodeGeneratorSchema = z.object({ - id: z.string(), - description: z.string(), - dependencies: z.array(DependencyKindSchema), - processType: z.function().optional(), - processResolver: z.function().optional(), - processExecutor: z.function().optional(), - processTailorDBNamespace: z.function().optional(), - processResolverNamespace: z.function().optional(), - aggregate: z.function({ output: z.any() }), -}); - -// Base schema for generator config (before transformation to actual Generator instances) -export const BaseGeneratorConfigSchema = z.union([ - KyselyTypeConfigSchema, - SeedConfigSchema, - EnumConstantsConfigSchema, - FileUtilsConfigSchema, - CodeGeneratorSchema, -]); diff --git a/packages/sdk/src/parser/plugin-config/schema.ts b/packages/sdk/src/parser/plugin-config/schema.ts index bf1f19de7d..cdae7f1b29 100644 --- a/packages/sdk/src/parser/plugin-config/schema.ts +++ b/packages/sdk/src/parser/plugin-config/schema.ts @@ -5,7 +5,7 @@ import type { Plugin } from "#/plugin/types"; // Custom plugin schema (object form) // Using passthrough() to preserve additional properties on Plugin instances export const PluginConfigSchema = z - .object({ + .looseObject({ id: z.string(), description: z.string(), importPath: z.string().optional(), @@ -19,7 +19,6 @@ export const PluginConfigSchema = z onResolverReady: functionSchema.optional(), onExecutorReady: functionSchema.optional(), }) - .passthrough() .refine( (p) => { // importPath is required when plugin has definition-time hooks diff --git a/packages/sdk/src/parser/schema-strict.test.ts b/packages/sdk/src/parser/schema-strict.test.ts new file mode 100644 index 0000000000..59ea742c3b --- /dev/null +++ b/packages/sdk/src/parser/schema-strict.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "vitest"; +import { AppConfigSchema } from "./app-config/schema"; +import { PluginConfigSchema } from "./plugin-config/schema"; +import { AIGatewaySchema } from "./service/aigateway/schema"; +import { AuthConnectionConfigSchema } from "./service/auth-connection/schema"; +import { AuthConfigSchema, SCIMAttributeSchema } from "./service/auth/schema"; +import { ExecutorSchema } from "./service/executor/schema"; +import { TailorFieldSchema } from "./service/field/schema"; +import { IdPSchema } from "./service/idp/schema"; +import { ResolverSchema } from "./service/resolver/schema"; +import { SecretsSchema } from "./service/secrets/schema"; +import { StaticWebsiteSchema } from "./service/staticwebsite/schema"; +import { TailorDBServiceConfigSchema, TailorDBTypeSchema } from "./service/tailordb/schema"; +import { WorkflowSchema } from "./service/workflow/schema"; +import type { ZodType } from "zod"; + +type StrictSchemaCase = { + readonly name: string; + readonly schema: ZodType; + readonly value: Record; +}; + +function hasUnrecognizedKeyIssue(issues: readonly unknown[]): boolean { + return issues.some((issue) => { + if (typeof issue !== "object" || issue === null) return false; + if ("code" in issue && issue.code === "unrecognized_keys") return true; + if (!("errors" in issue) || !Array.isArray(issue.errors)) return false; + return issue.errors.some( + (variantIssues) => Array.isArray(variantIssues) && hasUnrecognizedKeyIssue(variantIssues), + ); + }); +} + +const strictSchemaCases: StrictSchemaCase[] = [ + { + name: "app config", + schema: AppConfigSchema, + value: { name: "my-app" }, + }, + { + name: "AI gateway", + schema: AIGatewaySchema, + value: { name: "my-gateway", authNamespace: "my-auth" }, + }, + { + name: "auth connection", + schema: AuthConnectionConfigSchema, + value: { + type: "oauth2", + providerUrl: "https://accounts.example.com", + issuerUrl: "https://accounts.example.com", + clientId: "client-id", + clientSecret: "client-secret", + }, + }, + { + name: "auth config", + schema: AuthConfigSchema, + value: { name: "my-auth" }, + }, + { + name: "SCIM attribute", + schema: SCIMAttributeSchema, + value: { type: "string", name: "userName" }, + }, + { + name: "executor", + schema: ExecutorSchema, + value: { + name: "my-executor", + trigger: { kind: "schedule", cron: "0 12 * * *" }, + operation: { kind: "function", body: () => {} }, + }, + }, + { + name: "IdP", + schema: IdPSchema, + value: { name: "my-idp", authorization: "loggedIn", clients: ["default-client"] }, + }, + { + name: "resolver", + schema: ResolverSchema, + value: { + operation: "query", + name: "getUser", + body: () => {}, + output: { type: "string", metadata: {}, fields: {} }, + }, + }, + { + name: "secrets", + schema: SecretsSchema, + value: { + vaults: { "my-vault": { secret: "value" } }, + options: { ignoreNullishValues: true }, + }, + }, + { + name: "static website", + schema: StaticWebsiteSchema, + value: { name: "my-site" }, + }, + { + name: "TailorDB service config", + schema: TailorDBServiceConfigSchema, + value: { files: ["tailordb/*.ts"] }, + }, + { + name: "TailorDB type", + schema: TailorDBTypeSchema, + value: { + name: "User", + fields: {}, + metadata: { + name: "User", + permissions: {}, + files: {}, + }, + }, + }, + { + name: "workflow", + schema: WorkflowSchema, + value: { + name: "my-workflow", + mainJob: { + name: "main", + trigger: () => {}, + body: () => {}, + }, + }, + }, +]; + +describe("parser schemas", () => { + test.each(strictSchemaCases)("rejects unknown keys for $name", ({ schema, value }) => { + const result = schema.safeParse({ ...value, unknownOption: true }); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected schema parsing to fail"); + } + expect(hasUnrecognizedKeyIssue(result.error.issues)).toBe(true); + }); + + test("preserves plugin instance properties", () => { + const result = PluginConfigSchema.safeParse({ + id: "plugin", + description: "Plugin", + customProperty: true, + }); + + expect(result.success).toBe(true); + if (!result.success) { + throw new Error("Expected plugin config parsing to succeed"); + } + expect(result.data).toHaveProperty("customProperty", true); + }); + + test("accepts field builder properties", () => { + const result = TailorFieldSchema.safeParse({ + type: "string", + metadata: { + validate: [() => true, [() => true, "Invalid value"]], + }, + fields: {}, + builderProperty: true, + }); + + expect(result.success).toBe(true); + if (!result.success) { + throw new Error("Expected field parsing to succeed"); + } + }); +}); diff --git a/packages/sdk/src/parser/service/aigateway/schema.ts b/packages/sdk/src/parser/service/aigateway/schema.ts index 14c5d74a7a..a1a63005c2 100644 --- a/packages/sdk/src/parser/service/aigateway/schema.ts +++ b/packages/sdk/src/parser/service/aigateway/schema.ts @@ -3,8 +3,9 @@ import { z } from "zod"; const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/; const AUTH_NAMESPACE_PATTERN = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/; +// strip unknown keys export const AIGatewaySchema = z - .object({ + .strictObject({ name: z .string() .regex(NAME_PATTERN, "Must be 3-30 lowercase alphanumeric characters or hyphens") @@ -20,4 +21,5 @@ export const AIGatewaySchema = z "Allowed CORS origins for browser-based clients. Each entry is `*`, `http(s)://*`, `http(s)://*.example.com`, or `http(s)://app.example.com`, optionally with `:port`. Empty list disables cross-origin access.", ), }) + .brand("AIGatewayConfig"); diff --git a/packages/sdk/src/parser/service/auth-connection/schema.ts b/packages/sdk/src/parser/service/auth-connection/schema.ts index c99092b481..30d662c390 100644 --- a/packages/sdk/src/parser/service/auth-connection/schema.ts +++ b/packages/sdk/src/parser/service/auth-connection/schema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const AuthConnectionOAuth2ConfigSchema = z.object({ +export const AuthConnectionOAuth2ConfigSchema = z.strictObject({ providerUrl: z.string().describe("OAuth2 provider URL"), issuerUrl: z.string().describe("OAuth2 issuer URL"), clientId: z.string().describe("OAuth2 client ID"), @@ -9,8 +9,6 @@ export const AuthConnectionOAuth2ConfigSchema = z.object({ tokenUrl: z.string().optional().describe("OAuth2 token endpoint override"), }); -export const AuthConnectionConfigSchema = z - .object({ - type: z.literal("oauth2").describe("Connection type"), - }) - .and(AuthConnectionOAuth2ConfigSchema); +export const AuthConnectionConfigSchema = AuthConnectionOAuth2ConfigSchema.extend({ + type: z.literal("oauth2").describe("Connection type"), +}); diff --git a/packages/sdk/src/parser/service/auth/index.test.ts b/packages/sdk/src/parser/service/auth/index.test.ts index 89462f1ed1..907ebae9d4 100644 --- a/packages/sdk/src/parser/service/auth/index.test.ts +++ b/packages/sdk/src/parser/service/auth/index.test.ts @@ -1,8 +1,11 @@ import { describe, expectTypeOf, expect, test } from "vitest"; import { db } from "#/configure/services/tailordb/schema"; import { t } from "#/configure/types/type"; +import { brandValue } from "#/utils/brand"; import { AuthConfigSchema, OAuth2ClientSchema } from "./schema"; import type { AuthServiceInput } from "#/configure/services/auth/types"; +import type { TailorDBInstance } from "#/configure/services/tailordb/types"; +import type { UUIDString } from "#/configure/types/scalar.types"; import type { OptionalKeysOf } from "type-fest"; import type { z } from "zod"; @@ -15,7 +18,7 @@ const userType = db.type("User", { externalId: db.uuid(), }); -type AttributeMap = { +type Attributes = { role: true; isActive: true; tags: true; @@ -24,10 +27,11 @@ type AttributeMap = { type AttributeList = ["externalId"]; -type AuthInput = AuthServiceInput; +type AuthInput = AuthServiceInput; type MachineUserConfig = NonNullable["admin"]; type AuthSchemaInput = Omit, "name">; +type IsUnknown = unknown extends T ? ([keyof T] extends [never] ? true : false) : false; describe("AuthServiceInput and AuthConfigSchema type alignment", () => { test("aligns top-level keys and optionality with the schema", () => { @@ -60,6 +64,8 @@ describe("AuthServiceInput and AuthConfigSchema type alignment", () => { type AlignedSchemaAttributes = Pick; expectTypeOf().toMatchObjectType(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf().toExtend(); expectTypeOf().toExtend(); expectTypeOf().toExtend< SchemaUserProfile["usernameField"] @@ -76,7 +82,7 @@ describe("AuthServiceInput and AuthConfigSchema type alignment", () => { type SchemaAttributeList = SchemaMachineUser["attributeList"]; type FunctionMachineUser = MachineUserConfig; - type FunctionAttributeKeys = keyof AttributeMap; + type FunctionAttributeKeys = keyof Attributes; type FunctionAttributeValues = FunctionMachineUser["attributes"][FunctionAttributeKeys]; type FunctionAttributeList = FunctionMachineUser["attributeList"]; @@ -90,11 +96,11 @@ describe("AuthServiceInput and AuthConfigSchema type alignment", () => { role: string; isActive: boolean; tags: string[]; - externalId: string; + externalId: UUIDString; }>(); expectTypeOf().toMatchObjectType<{ - attributeList: [string]; + attributeList: [UUIDString]; }>(); }); @@ -268,4 +274,87 @@ describe("AuthConfigSchema userProfile/machineUserAttributes validation", () => const result = AuthConfigSchema.parse(config); expect(result.userProfile?.namespace).toBe("external-ns"); }); + + test("strips TailorDB type builder helpers from userProfile.type", () => { + const result = AuthConfigSchema.parse({ + name: "my-auth", + userProfile: { + type: userType, + usernameField: "email", + }, + }); + + expect(result.userProfile?.type).toMatchObject({ + name: "User", + fields: expect.any(Object), + }); + expect(result.userProfile?.type).not.toHaveProperty("hooks"); + expect(result.userProfile?.type).not.toHaveProperty("_output"); + }); + + test("rejects unbranded userProfile.type copies with unknown keys", () => { + const result = AuthConfigSchema.safeParse({ + name: "my-auth", + userProfile: { + type: { ...userType, unknownOption: true }, + usernameField: "email", + }, + }); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected AuthConfigSchema parsing to fail"); + } + expect(JSON.stringify(result.error.issues)).toContain("unknownOption"); + }); + + test("omits unknown outer userProfile.type keys with TailorDB builder helpers", () => { + const typeWithUnknownKey = brandValue( + { + ...userType, + unknownOption: true, + }, + "tailordb-type", + ); + + const result = AuthConfigSchema.parse({ + name: "my-auth", + userProfile: { + type: typeWithUnknownKey, + usernameField: "email", + }, + }); + + expect(result.userProfile?.type).not.toHaveProperty("unknownOption"); + }); + + test("rejects invalid TailorDB fields in userProfile.type", () => { + const typeWithInvalidField = brandValue( + { + ...userType, + fields: { + ...userType.fields, + unsupported: { + type: "unsupported", + metadata: {}, + }, + }, + }, + "tailordb-type", + ); + + const result = AuthConfigSchema.safeParse({ + name: "my-auth", + userProfile: { + type: typeWithInvalidField, + usernameField: "email", + }, + }); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected AuthConfigSchema parsing to fail"); + } + expect(JSON.stringify(result.error.issues)).toContain("unsupported"); + }); }); diff --git a/packages/sdk/src/parser/service/auth/schema.ts b/packages/sdk/src/parser/service/auth/schema.ts index f506b22999..1a4a3d1d6a 100644 --- a/packages/sdk/src/parser/service/auth/schema.ts +++ b/packages/sdk/src/parser/service/auth/schema.ts @@ -1,9 +1,12 @@ import { z } from "zod"; import { AuthConnectionConfigSchema } from "#/parser/service/auth-connection/index"; import { TailorFieldSchema } from "#/parser/service/field/schema"; +import { stripTailorDBTypeBuilderHelpers } from "#/parser/service/tailordb/builder-helpers"; +import { TailorDBTypeSchema } from "#/parser/service/tailordb/index"; import type { ValueOperand } from "#/configure/services/auth/types"; +import type { TailorDBInstance } from "#/configure/services/tailordb/types"; -export const AuthInvokerObjectSchema = z.object({ +export const AuthInvokerObjectSchema = z.strictObject({ namespace: z.string().describe("Auth namespace"), machineUserName: z.string().describe("Machine user name for authentication"), }); @@ -13,12 +16,12 @@ export const AuthInvokerSchema = z.union([ AuthInvokerObjectSchema, ]); -const secretValueSchema = z.object({ +const secretValueSchema = z.strictObject({ vaultName: z.string().describe("Vault name containing the secret"), secretKey: z.string().describe("Key of the secret in the vault"), }); -export const OIDCSchema = z.object({ +export const OIDCSchema = z.strictObject({ name: z.string().describe("Identity provider name"), kind: z.literal("OIDC"), clientID: z.string().describe("OAuth2 client ID"), @@ -29,7 +32,7 @@ export const OIDCSchema = z.object({ }); export const SAMLSchema = z - .object({ + .strictObject({ name: z.string().describe("Identity provider name"), kind: z.literal("SAML"), enableSignRequest: z.boolean().default(false).describe("Enable signing of SAML requests"), @@ -46,13 +49,14 @@ export const SAMLSchema = z .optional() .describe("URL to redirect to when SAML ACS receives a response with an empty RelayState."), }) + .refine((value) => { const hasMetadata = value.metadataURL !== undefined; const hasRaw = value.rawMetadata !== undefined; return hasMetadata !== hasRaw; }, "Provide either metadataURL or rawMetadata"); -export const IDTokenSchema = z.object({ +export const IDTokenSchema = z.strictObject({ name: z.string().describe("Identity provider name"), kind: z.literal("IDToken"), providerURL: z.string().describe("ID token provider URL"), @@ -61,7 +65,7 @@ export const IDTokenSchema = z.object({ usernameClaim: z.string().optional().describe("JWT claim to use as username"), }); -export const BuiltinIdPSchema = z.object({ +export const BuiltinIdPSchema = z.strictObject({ name: z.string().describe("Identity provider name"), kind: z.literal("BuiltInIdP"), namespace: z.string().describe("IdP namespace"), @@ -80,7 +84,7 @@ export const OAuth2ClientGrantTypeSchema = z .describe("OAuth2 grant type"); export const OAuth2ClientSchema = z - .object({ + .strictObject({ description: z.string().optional().describe("Client description"), grantTypes: z .array(OAuth2ClientGrantTypeSchema) @@ -121,12 +125,13 @@ export const OAuth2ClientSchema = z .optional() .describe("Require DPoP (Demonstrating Proof-of-Possession) for token requests"), }) + .refine((data) => !(data.clientType === "browser" && data.requireDpop === true), { message: "requireDpop cannot be set to true for browser clients as they don't support DPoP", path: ["requireDpop"], }); -export const SCIMAuthorizationSchema = z.object({ +export const SCIMAuthorizationSchema = z.strictObject({ type: z.union([z.literal("oauth2"), z.literal("bearer")]).describe("SCIM authorization type"), bearerSecret: secretValueSchema .optional() @@ -143,7 +148,7 @@ export const SCIMAttributeTypeSchema = z ]) .describe("SCIM attribute data type"); -export const SCIMAttributeSchema = z.object({ +export const SCIMAttributeSchema = z.strictObject({ type: SCIMAttributeTypeSchema.describe("Attribute data type"), name: z.string().describe("Attribute name"), description: z.string().optional().describe("Attribute description"), @@ -163,17 +168,17 @@ export const SCIMAttributeSchema = z.object({ }, }); -const SCIMSchemaSchema = z.object({ +const SCIMSchemaSchema = z.strictObject({ name: z.string().describe("SCIM schema name"), attributes: z.array(SCIMAttributeSchema).describe("Schema attributes"), }); -export const SCIMAttributeMappingSchema = z.object({ +export const SCIMAttributeMappingSchema = z.strictObject({ tailorDBField: z.string().describe("TailorDB field name to map to"), scimPath: z.string().describe("SCIM attribute path"), }); -export const SCIMResourceSchema = z.object({ +export const SCIMResourceSchema = z.strictObject({ name: z.string().describe("SCIM resource name"), tailorDBNamespace: z.string().describe("TailorDB namespace for the resource"), tailorDBType: z.string().describe("TailorDB type name for the resource"), @@ -181,34 +186,24 @@ export const SCIMResourceSchema = z.object({ attributeMapping: z.array(SCIMAttributeMappingSchema).describe("Attribute mapping configuration"), }); -export const SCIMSchema = z.object({ +export const SCIMSchema = z.strictObject({ machineUserName: z.string().describe("Machine user name for SCIM operations"), authorization: SCIMAuthorizationSchema.describe("SCIM authorization configuration"), resources: z.array(SCIMResourceSchema).describe("SCIM resource definitions"), }); -export const TenantProviderSchema = z.object({ +export const TenantProviderSchema = z.strictObject({ namespace: z.string().describe("TailorDB namespace for the tenant type"), type: z.string().describe("TailorDB type name for tenants"), signatureField: z.string().describe("Field used as the tenant signature"), }); -const UserProfileSchema = z.object({ +const UserProfileSchema = z.strictObject({ namespace: z.string().optional().describe("TailorDB namespace where the user type is defined"), - // FIXME: improve TailorDBInstance schema validation - type: z.object({ - name: z.string(), - fields: z.any(), - metadata: z.any(), - hooks: z.any(), - validate: z.any(), - features: z.any(), - indexes: z.any(), - files: z.any(), - permission: z.any(), - gqlPermission: z.any(), - _output: z.any(), - }), + type: z + .custom() + .transform(stripTailorDBTypeBuilderHelpers) + .pipe(TailorDBTypeSchema), usernameField: z.string(), attributes: z.record(z.string(), z.literal(true)).optional(), attributeList: z.array(z.string()).optional(), @@ -221,20 +216,20 @@ const ValueOperandSchema: z.ZodType = z.union([ z.array(z.boolean()), ]); -const MachineUserSchema = z.object({ +const MachineUserSchema = z.strictObject({ attributes: z.record(z.string(), ValueOperandSchema).optional(), attributeList: z.array(z.uuid()).optional(), }); -const BeforeLoginHookSchema = z.object({ +const BeforeLoginHookSchema = z.strictObject({ handler: z.function(), invoker: z.string(), }); -const AuthConfigBaseSchema = z.object({ +const AuthConfigBaseSchema = z.strictObject({ name: z.string().describe("Auth service name"), hooks: z - .object({ + .strictObject({ beforeLogin: BeforeLoginHookSchema.optional().describe("Before login auth hook"), }) .optional() diff --git a/packages/sdk/src/parser/service/executor/schema.test.ts b/packages/sdk/src/parser/service/executor/schema.test.ts index d50d014808..ef07516e0d 100644 --- a/packages/sdk/src/parser/service/executor/schema.test.ts +++ b/packages/sdk/src/parser/service/executor/schema.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "vitest"; -import { ExecutorSchema, GqlOperationSchema, WorkflowOperationSchema } from "./schema"; +import { + ExecutorSchema, + FunctionOperationSchema, + GqlOperationSchema, + WorkflowOperationSchema, +} from "./schema"; function expectParseSuccess( result: { success: true; data: T } | { success: false; error: unknown }, @@ -11,6 +16,43 @@ function expectParseSuccess( return result.data; } +function expectParseFailure( + result: { success: true; data: T } | { success: false; error: { issues: unknown[] } }, +): { issues: unknown[] } { + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected schema parsing to fail"); + } + return result.error; +} + +function expectUnknownKeyRejected( + result: { success: true; data: T } | { success: false; error: { issues: unknown[] } }, +) { + const error = expectParseFailure(result); + expect(error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unrecognized_keys", + }), + ]), + ); +} + +describe("FunctionOperationSchema", () => { + test("rejects unknown options", () => { + expect.hasAssertions(); + + expectUnknownKeyRejected( + FunctionOperationSchema.safeParse({ + kind: "function", + body: () => {}, + unknownOption: true, + }), + ); + }); +}); + describe("GqlOperationSchema", () => { const documentNode = { kind: "Document", @@ -26,6 +68,18 @@ describe("GqlOperationSchema", () => { const data = expectParseSuccess(result); expect(data.query).toBe("query { users { id } }"); }); + + test("rejects unknown options", () => { + expect.hasAssertions(); + + expectUnknownKeyRejected( + GqlOperationSchema.safeParse({ + kind: "graphql", + query: "query { users { id } }", + unknownOption: true, + }), + ); + }); }); describe("WorkflowOperationSchema", () => { @@ -51,6 +105,18 @@ describe("WorkflowOperationSchema", () => { const data = expectParseSuccess(result); expect(data.workflowName).toBe("my-workflow"); }); + + test("rejects unknown options", () => { + expect.hasAssertions(); + + expectUnknownKeyRejected( + WorkflowOperationSchema.safeParse({ + kind: "workflow", + workflowName: "my-workflow", + unknownOption: true, + }), + ); + }); }); describe("ExecutorSchema", () => { @@ -101,4 +167,23 @@ describe("ExecutorSchema", () => { } expect(data.operation.query).toBe("mutation { createUser { id } }"); }); + + test("rejects unknown options on operations", () => { + expect.hasAssertions(); + + expectParseFailure( + ExecutorSchema.safeParse({ + name: "test-executor", + trigger: { + kind: "schedule", + cron: "0 12 * * *", + }, + operation: { + kind: "function", + body: () => {}, + unknownOption: true, + }, + }), + ); + }); }); diff --git a/packages/sdk/src/parser/service/executor/schema.ts b/packages/sdk/src/parser/service/executor/schema.ts index d5fd47d7eb..cad70a6a6f 100644 --- a/packages/sdk/src/parser/service/executor/schema.ts +++ b/packages/sdk/src/parser/service/executor/schema.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { AuthInvokerSchema } from "../auth"; import { functionSchema } from "../common"; -export const TailorDBTriggerSchema = z.object({ +export const TailorDBTriggerSchema = z.strictObject({ kind: z.literal("tailordb").describe("TailorDB record event trigger"), events: z .array( @@ -19,13 +19,13 @@ export const TailorDBTriggerSchema = z.object({ condition: functionSchema.optional().describe("Condition function to filter events"), }); -export const ResolverExecutedTriggerSchema = z.object({ +export const ResolverExecutedTriggerSchema = z.strictObject({ kind: z.literal("resolverExecuted"), resolverName: z.string().describe("Name of the resolver to trigger on"), condition: functionSchema.optional().describe("Condition function to filter events"), }); -export const ScheduleTriggerSchema = z.object({ +export const ScheduleTriggerSchema = z.strictObject({ kind: z.literal("schedule"), cron: z.string().describe("CRON expression for the schedule"), timezone: z @@ -35,17 +35,17 @@ export const ScheduleTriggerSchema = z.object({ .describe("Timezone for the CRON schedule (default: UTC)"), }); -export const IncomingWebhookTriggerResponseSchema = z.object({ +export const IncomingWebhookTriggerResponseSchema = z.strictObject({ body: functionSchema.optional().describe("Function returning the response body"), statusCode: z.number().int().optional().describe("HTTP status code for the response"), }); -export const IncomingWebhookTriggerSchema = z.object({ +export const IncomingWebhookTriggerSchema = z.strictObject({ kind: z.literal("incomingWebhook"), response: IncomingWebhookTriggerResponseSchema.optional().describe("Response configuration"), }); -export const IdpUserTriggerSchema = z.object({ +export const IdpUserTriggerSchema = z.strictObject({ kind: z.literal("idpUser").describe("IdP user event trigger"), events: z .array(z.enum(["idp.user.created", "idp.user.updated", "idp.user.deleted"])) @@ -60,7 +60,7 @@ export const IdpUserTriggerSchema = z.object({ ), }); -export const AuthAccessTokenTriggerSchema = z.object({ +export const AuthAccessTokenTriggerSchema = z.strictObject({ kind: z.literal("authAccessToken").describe("Auth access token event trigger"), events: z .array( @@ -84,26 +84,29 @@ export const TriggerSchema = z.discriminatedUnion("kind", [ AuthAccessTokenTriggerSchema, ]); -export const FunctionOperationSchema = z.object({ +export const FunctionOperationSchema = z.strictObject({ kind: z.enum(["function", "jobFunction"]), body: functionSchema.describe("Function implementation"), - authInvoker: AuthInvokerSchema.optional().describe("Auth invoker for the function execution"), + invoker: AuthInvokerSchema.optional().describe("Invoker for the function execution"), }); -export const GqlOperationSchema = z.object({ +export const GqlOperationSchema = z.strictObject({ kind: z.literal("graphql"), appName: z.string().optional().describe("Target application name for the GraphQL query"), query: z.preprocess((val) => String(val), z.string().describe("GraphQL query string")), variables: functionSchema.optional().describe("Function to compute GraphQL variables"), - authInvoker: AuthInvokerSchema.optional().describe("Auth invoker for the GraphQL execution"), + invoker: AuthInvokerSchema.optional().describe("Invoker for the GraphQL execution"), }); -export const WebhookOperationSchema = z.object({ +export const WebhookOperationSchema = z.strictObject({ kind: z.literal("webhook"), url: functionSchema.describe("Function returning the webhook URL"), requestBody: functionSchema.optional().describe("Function to compute the request body"), headers: z - .record(z.string(), z.union([z.string(), z.object({ vault: z.string(), key: z.string() })])) + .record( + z.string(), + z.union([z.string(), z.strictObject({ vault: z.string(), key: z.string() })]), + ) .optional() .describe("HTTP headers for the webhook request"), }); @@ -123,14 +126,14 @@ export const WorkflowOperationSchema = z.preprocess( const { workflow, ...rest } = val as { workflow: { name: string } }; return { ...rest, workflowName: workflow.name }; }, - z.object({ + z.strictObject({ kind: z.literal("workflow"), workflowName: z.string().describe("Name of the workflow to execute"), args: z .union([z.record(z.string(), z.unknown()), functionSchema]) .optional() .describe("Arguments to pass to the workflow"), - authInvoker: AuthInvokerSchema.optional().describe("Auth invoker for the workflow execution"), + invoker: AuthInvokerSchema.optional().describe("Invoker for the workflow execution"), }), ); @@ -141,7 +144,7 @@ const OperationSchema = z.union([ WorkflowOperationSchema, ]); -export const ExecutorSchema = z.object({ +export const ExecutorSchema = z.strictObject({ name: z.string().describe("Executor name"), description: z.string().optional().describe("Executor description"), disabled: z.boolean().optional().default(false).describe("Whether the executor is disabled"), diff --git a/packages/sdk/src/parser/service/field/schema.ts b/packages/sdk/src/parser/service/field/schema.ts index 8686c02aee..ed2405ef50 100644 --- a/packages/sdk/src/parser/service/field/schema.ts +++ b/packages/sdk/src/parser/service/field/schema.ts @@ -15,26 +15,31 @@ const TailorFieldTypeSchema = z.enum([ "nested", ]); -const AllowedValueSchema = z.object({ +const AllowedValueSchema = z.strictObject({ value: z.string().describe("The allowed value"), description: z.string().optional().describe("Description of the allowed value"), }); -const FieldMetadataSchema = z.object({ +const FieldMetadataSchema = z.strictObject({ required: z.boolean().optional().describe("Whether the field is required"), array: z.boolean().optional().describe("Whether the field is an array"), description: z.string().optional().describe("Field description"), allowedValues: z.array(AllowedValueSchema).optional().describe("Allowed values for enum fields"), hooks: z - .object({ + .strictObject({ create: functionSchema.optional().describe("Hook function called on creation"), update: functionSchema.optional().describe("Hook function called on update"), }) .optional() .describe("Lifecycle hooks"), + validate: z + .array(z.union([functionSchema, z.tuple([functionSchema, z.string()])])) + .optional() + .describe("Validation functions for the field"), typeName: z.string().optional().describe("Type name for nested or enum fields"), }); +// strip unknown keys export const TailorFieldSchema = z.object({ type: TailorFieldTypeSchema.describe("Field data type"), metadata: FieldMetadataSchema.describe("Field metadata configuration"), diff --git a/packages/sdk/src/parser/service/http-adapter/schema.ts b/packages/sdk/src/parser/service/http-adapter/schema.ts index f1f5deed53..ddd2b7e767 100644 --- a/packages/sdk/src/parser/service/http-adapter/schema.ts +++ b/packages/sdk/src/parser/service/http-adapter/schema.ts @@ -11,6 +11,7 @@ const inputHandlersSchema = z patch: functionSchema.optional().describe("Handler for PATCH requests"), delete: functionSchema.optional().describe("Handler for DELETE requests"), }) + .refine( // optional fields become undefined after zod parses them // oxlint-disable-next-line typescript/no-unnecessary-condition @@ -44,4 +45,5 @@ export const HttpAdapterConfigSchema = z .optional() .describe("Function that transforms GraphQL response to HTTP response"), }) + .brand("HttpAdapterConfig"); diff --git a/packages/sdk/src/parser/service/idp/schema.ts b/packages/sdk/src/parser/service/idp/schema.ts index 9b4bc802bf..5eece0c13e 100644 --- a/packages/sdk/src/parser/service/idp/schema.ts +++ b/packages/sdk/src/parser/service/idp/schema.ts @@ -41,7 +41,7 @@ function normalizeIdPGqlOperations( export const IdPGqlOperationsSchema = z .union([ z.literal("query"), - z.object({ + z.strictObject({ create: z.boolean().optional().describe("Enable _createUser mutation (default: true)"), update: z.boolean().optional().describe("Enable _updateUser mutation (default: true)"), delete: z.boolean().optional().describe("Enable _deleteUser mutation (default: true)"), @@ -74,7 +74,7 @@ const allowedReturnOriginPattern = /^(https?:\/\/[a-zA-Z0-9.-]+(:[0-9]+)?|[a-z0-9][a-z0-9-]{1,61}[a-z0-9]:url)$/; export const IdPUserAuthPolicySchema = z - .object({ + .strictObject({ useNonEmailIdentifier: z .boolean() .optional() @@ -151,6 +151,7 @@ export const IdPUserAuthPolicySchema = z .optional() .describe("Label shown next to the user account in authenticator apps"), }) + .refine( (data) => data.passwordMinLength === undefined || @@ -240,12 +241,13 @@ const emailFieldSchema = z .regex(/^[^\r\n]*$/, "must not contain newline characters"); export const IdPEmailConfigSchema = z - .object({ + .strictObject({ fromName: emailFieldSchema.optional().describe("Default sender display name for emails"), passwordResetSubject: emailFieldSchema .optional() .describe("Default subject for password reset emails"), }) + .describe("Namespace-level email configuration defaults"); const IdPPermissionOperandSchema = z.union([ @@ -253,10 +255,10 @@ const IdPPermissionOperandSchema = z.union([ z.boolean(), z.array(z.string()).readonly(), z.array(z.boolean()).readonly(), - z.object({ user: z.string() }), - z.object({ idpUser: z.enum(["id", "name", "disabled"]) }), - z.object({ oldIdpUser: z.enum(["id", "name", "disabled"]) }), - z.object({ newIdpUser: z.enum(["id", "name", "disabled"]) }), + z.strictObject({ user: z.string() }), + z.strictObject({ idpUser: z.enum(["id", "name", "disabled"]) }), + z.strictObject({ oldIdpUser: z.enum(["id", "name", "disabled"]) }), + z.strictObject({ newIdpUser: z.enum(["id", "name", "disabled"]) }), ]); const IdPPermissionOperatorSchema = z.enum(["=", "!=", "in", "not in"]); @@ -267,7 +269,7 @@ const IdPPermissionConditionSchema = z const IdPActionPermissionSchema = z.union([ // Object format: { conditions, description?, permit? } - z.object({ + z.strictObject({ conditions: z.union([ IdPPermissionConditionSchema, z.array(IdPPermissionConditionSchema).readonly(), @@ -302,7 +304,7 @@ const IdPActionPermissionSchema = z.union([ ]); export const IdPPermissionSchema = z - .object({ + .strictObject({ create: z.array(IdPActionPermissionSchema).readonly(), read: z.array(IdPActionPermissionSchema).readonly(), update: z.array(IdPActionPermissionSchema).readonly(), @@ -310,13 +312,14 @@ export const IdPPermissionSchema = z sendPasswordResetEmail: z.array(IdPActionPermissionSchema).readonly().optional(), unenrollMfa: z.array(IdPActionPermissionSchema).readonly().optional(), }) + .describe("Per-operation permission policies for IdP users"); export const IdPSchema = z - .object({ + .strictObject({ name: z.string().describe("IdP service name"), authorization: z - .union([z.literal("insecure"), z.literal("loggedIn"), z.object({ cel: z.string() })]) + .union([z.literal("insecure"), z.literal("loggedIn"), z.strictObject({ cel: z.string() })]) .optional() .describe("Authorization mode for IdP API access"), clients: z.array(z.string()).describe("OAuth2 client names that can use this IdP"), @@ -339,6 +342,7 @@ export const IdPSchema = z "Per-operation permission policies for IdP users", ), }) + .refine( (data) => !data.userAuthPolicy?.enableMfa || diff --git a/packages/sdk/src/parser/service/resolver/schema.test.ts b/packages/sdk/src/parser/service/resolver/schema.test.ts new file mode 100644 index 0000000000..fe2c189d77 --- /dev/null +++ b/packages/sdk/src/parser/service/resolver/schema.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest"; +import { ResolverSchema } from "./schema"; +import type { ZodError } from "zod"; + +function expectParseFailure( + result: { success: true; data: T } | { success: false; error: ZodError }, +): ZodError { + expect(result.success).toBe(false); + if (result.success) { + throw new Error("Expected schema parsing to fail"); + } + return result.error; +} + +describe("ResolverSchema", () => { + const validResolver = { + operation: "query", + name: "getUser", + body: () => {}, + output: { + type: "string", + fields: {}, + metadata: {}, + }, + }; + + test("rejects unknown options", () => { + const error = expectParseFailure( + ResolverSchema.safeParse({ + ...validResolver, + unknownOption: true, + }), + ); + + expect(error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unrecognized_keys", + }), + ]), + ); + }); +}); diff --git a/packages/sdk/src/parser/service/resolver/schema.ts b/packages/sdk/src/parser/service/resolver/schema.ts index e909bbff52..d6a6ad2174 100644 --- a/packages/sdk/src/parser/service/resolver/schema.ts +++ b/packages/sdk/src/parser/service/resolver/schema.ts @@ -7,7 +7,7 @@ export const QueryTypeSchema = z .union([z.literal("query"), z.literal("mutation")]) .describe("GraphQL operation type"); -export const ResolverSchema = z.object({ +export const ResolverSchema = z.strictObject({ operation: QueryTypeSchema.describe("GraphQL operation type (query or mutation)"), name: z.string().describe("Resolver name"), description: z.string().optional().describe("Resolver description"), @@ -15,5 +15,5 @@ export const ResolverSchema = z.object({ body: functionSchema.describe("Resolver implementation function"), output: TailorFieldSchema.describe("Output field definition"), publishEvents: z.boolean().optional().describe("Enable publishing events from this resolver"), - authInvoker: AuthInvokerSchema.optional().describe("Machine user to execute this resolver as"), + invoker: AuthInvokerSchema.optional().describe("Machine user to execute this resolver as"), }); diff --git a/packages/sdk/src/parser/service/secrets/schema.ts b/packages/sdk/src/parser/service/secrets/schema.ts index 06d0f4f625..e2e462df21 100644 --- a/packages/sdk/src/parser/service/secrets/schema.ts +++ b/packages/sdk/src/parser/service/secrets/schema.ts @@ -4,9 +4,9 @@ const namePattern = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/; const nameSchema = z.string().regex(namePattern); const secretsVaultSchema = z.record(nameSchema, z.string().nullish()); -export const SecretsSchema = z.object({ +export const SecretsSchema = z.strictObject({ vaults: z.record(nameSchema, secretsVaultSchema), - options: z.object({ + options: z.strictObject({ ignoreNullishValues: z.boolean(), }), }); diff --git a/packages/sdk/src/parser/service/staticwebsite/schema.ts b/packages/sdk/src/parser/service/staticwebsite/schema.ts index ee53f0de98..355b8d3135 100644 --- a/packages/sdk/src/parser/service/staticwebsite/schema.ts +++ b/packages/sdk/src/parser/service/staticwebsite/schema.ts @@ -1,7 +1,8 @@ import { z } from "zod"; +// strip unknown keys export const StaticWebsiteSchema = z - .object({ + .strictObject({ name: z.string().describe("Static website name"), description: z.string().optional().describe("Static website description"), allowedIpAddresses: z @@ -10,4 +11,5 @@ export const StaticWebsiteSchema = z .describe("IP addresses allowed to access the website"), customDomains: z.array(z.string()).optional().describe("Custom domains for the static website"), }) + .brand("StaticWebsiteConfig"); diff --git a/packages/sdk/src/parser/service/tailordb/builder-helpers.ts b/packages/sdk/src/parser/service/tailordb/builder-helpers.ts new file mode 100644 index 0000000000..bcd6cca641 --- /dev/null +++ b/packages/sdk/src/parser/service/tailordb/builder-helpers.ts @@ -0,0 +1,19 @@ +import { isSdkBranded } from "#/utils/brand"; +import { TailorDBTypeSchema } from "./schema"; + +const TAILORDB_TYPE_SCHEMA_KEYS = TailorDBTypeSchema.keyof().options; + +export function stripTailorDBTypeBuilderHelpers(type: unknown): unknown { + if (!isSdkBranded(type, "tailordb-type")) { + return type; + } + + const config: Record = {}; + const input = type as Record; + for (const key of TAILORDB_TYPE_SCHEMA_KEYS) { + if (Object.prototype.hasOwnProperty.call(input, key)) { + config[key] = input[key]; + } + } + return config; +} diff --git a/packages/sdk/src/parser/service/tailordb/field.precompiled.test.ts b/packages/sdk/src/parser/service/tailordb/field.precompiled.test.ts index 87552f9bef..56d69f653f 100644 --- a/packages/sdk/src/parser/service/tailordb/field.precompiled.test.ts +++ b/packages/sdk/src/parser/service/tailordb/field.precompiled.test.ts @@ -20,7 +20,8 @@ describe("parseFieldConfig precompiled expressions", () => { }); test("uses precompiled validate expression when attached", () => { - const validator = ({ value }: { value: string }) => value.length > 0; + const validator = ({ value }: { value: string }) => + value.length <= 0 ? "Must not be empty" : undefined; setPrecompiledScriptExpr(validator, "PRECOMPILED_VALIDATE_EXPR"); const type = db.type("User", { diff --git a/packages/sdk/src/parser/service/tailordb/field.test.ts b/packages/sdk/src/parser/service/tailordb/field.test.ts index e551aef147..f8b6e99001 100644 --- a/packages/sdk/src/parser/service/tailordb/field.test.ts +++ b/packages/sdk/src/parser/service/tailordb/field.test.ts @@ -124,8 +124,8 @@ describe("parseFieldConfig validator expressions", () => { test("normalizes a method-shorthand validator whose body contains an arrow function", () => { // Method shorthand syntax, obtained the same way a user's helper object would produce it. const validators = { - isValid({ value }: { value: string }) { - return [value].map((v) => v.includes("@"))[0] ?? false; + isValid({ value }: { value: string }): string | void { + if (!([value].map((v) => v.includes("@"))[0] ?? false)) return "invalid email"; }, }; const type = db.type("User", { @@ -162,8 +162,8 @@ describe("parseFieldConfig script expression validation", () => { }); test("throws a clear error when a validator cannot be converted to valid JavaScript", () => { - const check = function check({ value }: { value: string }) { - return value.length > 0; + const check = function check({ value }: { value: string }): string | void { + if (value.length === 0) return "must not be empty"; }.bind(null); const type = db.type("User", { email: db.string().validate(check), @@ -177,8 +177,8 @@ describe("parseFieldConfig script expression validation", () => { }); test("includes the type and field path in conversion errors from type parsing", () => { - const check = function check({ value }: { value: string }) { - return value.length > 0; + const check = function check({ value }: { value: string }): string | void { + if (value.length === 0) return "must not be empty"; }.bind(null); const type = db.type("User", { email: db.string().validate(check), diff --git a/packages/sdk/src/parser/service/tailordb/field.ts b/packages/sdk/src/parser/service/tailordb/field.ts index d1ece9aaec..f26b9aaec5 100644 --- a/packages/sdk/src/parser/service/tailordb/field.ts +++ b/packages/sdk/src/parser/service/tailordb/field.ts @@ -18,9 +18,98 @@ type ScriptFunction = (...args: never[]) => unknown; type ScriptContextKind = "hooks.create" | "hooks.update" | "validate"; -// Since there's naming difference between platform and sdk, -// use this mapping in all scripts to provide variables that match sdk types. -export const tailorUserMap = /* js */ `{ id: user.id, type: user.type, workspaceId: user.workspace_id, attributes: user.attribute_map, attributeList: user.attributes }`; +const NIL_UUID = "00000000-0000-0000-0000-000000000000"; + +/** + * Per-source field expressions for {@link makePrincipalExpr}. Each value is a JS + * snippet evaluated against the raw server payload bound to `$raw`. + */ +interface PrincipalFieldExprs { + /** + * Raw type accessor. `raw` is matched against `USER_TYPE_*` when normalizing; + * `fallback` (defaulting to `raw`) supplies the type for non-matching values, + * which lets sources whose primary field is empty fall back to a secondary. + */ + type: { raw: string; fallback?: string }; + /** Raw id accessor. */ + id: string; + workspaceId: string; + attributes: string; + attributeList: string; +} + +interface MakePrincipalExprOptions { + source: string; + fields: PrincipalFieldExprs; + normalize: boolean; + requireId?: boolean; +} + +/** + * Build the server→SDK principal mapping expression shared across services. + * + * All principal-bearing call sites (`caller`, `actor`, `invoker`) must agree on + * the `TailorPrincipal | null` shape, so they are generated here rather than + * hand-written per service. + * @param options - Mapping source and field expressions + * @param options.source - Expression yielding the raw server payload (e.g. `user`) + * @param options.fields - Per-source accessors for each principal field + * @param options.normalize - When true, map `USER_TYPE_*` to SDK type literals and + * return `null` for unspecified types or the nil-UUID id; when false, the + * payload is already in SDK shape and only `null` pass-through is applied + * @param options.requireId - When true, missing ids are also mapped to `null` + * @returns A JS expression string evaluating to `TailorPrincipal | null` + */ +export function makePrincipalExpr(options: MakePrincipalExprOptions): string { + const { source, fields, normalize, requireId = false } = options; + const body = /* js */ `{ + id, + type, + workspaceId: ${fields.workspaceId}, + attributes: ${fields.attributes}, + attributeList: ${fields.attributeList}, + }`; + if (!normalize) { + return /* js */ `(($raw) => { + if (!$raw) { + return null; + } + const id = ${fields.id}; + const type = ${fields.type.raw}; + return ${body}; +})(${source})`; + } + const missingIdGuard = requireId ? " || !id" : ""; + return /* js */ `(($raw) => { + if (!$raw) { + return null; + } + const type = ${fields.type.raw} === "USER_TYPE_USER" + ? "user" + : ${fields.type.raw} === "USER_TYPE_MACHINE_USER" + ? "machine_user" + : ${fields.type.fallback ?? fields.type.raw}; + const id = ${fields.id}; + if (!type || type === "USER_TYPE_UNSPECIFIED" || id === "${NIL_UUID}"${missingIdGuard}) { + return null; + } + return ${body}; +})(${source})`; +} + +// Since there's naming difference between platform and SDK, use this mapping in +// all scripts to provide variables that match `TailorPrincipal | null`. +export const tailorPrincipalMap = makePrincipalExpr({ + source: "user", + normalize: true, + fields: { + type: { raw: "$raw?.type" }, + id: "$raw.id", + workspaceId: "$raw.workspace_id ?? $raw.workspaceId", + attributes: "$raw.attribute_map ?? $raw.attributeMap ?? {}", + attributeList: "$raw.attributes ?? []", + }, +}); /** * Parse `wrapped` and return the first property of the top-level parenthesized @@ -109,12 +198,42 @@ const convertToScriptExpr = ( return precompiledExpr; } const normalized = stringifyFunction(fn); + const argsObject = + kind === "validate" + ? `{ value: _value }` + : `{ value: _value, oldValue: _oldValue, invoker: ${tailorPrincipalMap}, now: _now }`; return assertParsableExpression( - `(${normalized})({ value: _value, data: _data, user: ${tailorUserMap} })`, + `(${normalized})(${argsObject})`, formatScriptContext(kind, context), ); }; +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export const convertTypeHookToExpr = (fn: Function): string => { + const precompiledExpr = getPrecompiledScriptExpr(fn as (...args: never[]) => unknown); + if (precompiledExpr) { + return precompiledExpr; + } + const normalized = stringifyFunction(fn); + return assertParsableExpression( + `(${normalized})({ input: _input, oldRecord: _oldRecord, invoker: ${tailorPrincipalMap}, now: _now })`, + "type-hook", + ); +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export const convertTypeValidateToExpr = (fn: Function): string => { + const precompiledExpr = getPrecompiledScriptExpr(fn as (...args: never[]) => unknown); + if (precompiledExpr) { + return precompiledExpr; + } + const normalized = stringifyFunction(fn); + return assertParsableExpression( + `(${normalized})({ newRecord: _newRecord, oldRecord: _oldRecord, invoker: ${tailorPrincipalMap} }, __issues)`, + "type-validate", + ); +}; + /** * Parse TailorDBField into OperatorFieldConfig. * This transforms user-defined functions into script expressions. @@ -153,19 +272,12 @@ export function parseFieldConfig( ), } : {}), - validate: metadata.validate?.map((v) => { - const { fn, message } = - typeof v === "function" - ? { fn: v, message: `failed by \`${v.toString().trim()}\`` } - : { fn: v[0], message: v[1] }; - - return { - script: { - expr: convertToScriptExpr(fn, "validate", context), - }, - errorMessage: message, - }; - }), + validate: metadata.validate?.map((fn) => ({ + script: { + expr: convertToScriptExpr(fn, "validate", context), + }, + errorMessage: "", + })), hooks: metadata.hooks ? { create: metadata.hooks.create diff --git a/packages/sdk/src/parser/service/tailordb/index.ts b/packages/sdk/src/parser/service/tailordb/index.ts index 5cb0f781b3..2a5692b988 100644 --- a/packages/sdk/src/parser/service/tailordb/index.ts +++ b/packages/sdk/src/parser/service/tailordb/index.ts @@ -1,3 +1,3 @@ -export { stringifyFunction, tailorUserMap } from "./field"; +export { makePrincipalExpr, stringifyFunction, tailorPrincipalMap } from "./field"; export { parseTypes } from "./type-parser"; export { TailorDBServiceConfigSchema, TailorDBTypeSchema } from "./schema"; diff --git a/packages/sdk/src/parser/service/tailordb/schema.ts b/packages/sdk/src/parser/service/tailordb/schema.ts index f14db9bd49..e77d06ec79 100644 --- a/packages/sdk/src/parser/service/tailordb/schema.ts +++ b/packages/sdk/src/parser/service/tailordb/schema.ts @@ -25,7 +25,7 @@ function normalizeGqlOperations( export const GqlOperationsSchema = z .union([ z.literal("query"), - z.object({ + z.strictObject({ create: z.boolean().optional().describe("Enable create mutation (default: true)"), update: z.boolean().optional().describe("Enable update mutation (default: true)"), delete: z.boolean().optional().describe("Enable delete mutation (default: true)"), @@ -54,12 +54,12 @@ const TailorFieldTypeSchema = z.enum([ "nested", ]); -const AllowedValueSchema = z.object({ +const AllowedValueSchema = z.strictObject({ value: z.string(), description: z.string().optional(), }); -export const DBFieldMetadataSchema = z.object({ +export const DBFieldMetadataSchema = z.strictObject({ required: z.boolean().optional().describe("Whether the field is required"), array: z.boolean().optional().describe("Whether the field is an array"), description: z.string().optional().describe("Field description"), @@ -75,18 +75,15 @@ export const DBFieldMetadataSchema = z.object({ foreignKeyType: z.string().optional().describe("Target type name for foreign key relations"), foreignKeyField: z.string().optional().describe("Target field name for foreign key relations"), hooks: z - .object({ + .strictObject({ create: functionSchema.optional().describe("Hook function called on record creation"), update: functionSchema.optional().describe("Hook function called on record update"), }) .optional() .describe("Lifecycle hooks for the field"), - validate: z - .array(z.union([functionSchema, z.tuple([functionSchema, z.string()])])) - .optional() - .describe("Validation functions for the field"), + validate: z.array(functionSchema).optional().describe("Validation functions for the field"), serial: z - .object({ + .strictObject({ start: z.number().describe("Starting value for the serial sequence"), maxValue: z.number().optional().describe("Maximum value for the serial sequence"), format: z.string().optional().describe("Format string for serial value (string type only)"), @@ -100,13 +97,14 @@ export const DBFieldMetadataSchema = z.object({ .max(12) .optional() .describe("Decimal scale (number of digits after decimal point, 0-12)"), + default: z.unknown().optional().describe("Default value for the field on create"), }); const RelationTypeSchema = z.enum(relationTypesKeys); -export const RawRelationConfigSchema = z.object({ +export const RawRelationConfigSchema = z.strictObject({ type: RelationTypeSchema.describe("Relation cardinality type"), - toward: z.object({ + toward: z.strictObject({ type: z.string().describe("Target type name, or 'self' for self-relations"), as: z.string().optional().describe("Custom forward relation name"), key: z.string().optional().describe("Target field to join on (default: 'id')"), @@ -115,6 +113,7 @@ export const RawRelationConfigSchema = z.object({ }); const TailorDBFieldSchema: z.ZodType = z.lazy(() => + // strip unknown keys z.object({ type: TailorFieldTypeSchema, fields: z.record(z.string(), TailorDBFieldSchema).optional(), @@ -127,7 +126,7 @@ const TailorDBFieldSchema: z.ZodType = z.lazy(() => * Schema for TailorDB type settings. * Normalizes gqlOperations from alias ("query") to object format. */ -export const TailorDBTypeSettingsSchema = z.object({ +export const TailorDBTypeSettingsSchema = z.strictObject({ pluralForm: z.string().optional().describe("Custom plural form of the type name for GraphQL"), aggregation: z.boolean().optional().describe("Enable aggregation queries for this type"), bulkUpsert: z.boolean().optional().describe("Enable bulk upsert mutation for this type"), @@ -147,7 +146,7 @@ export const GQL_PERMISSION_INVALID_OPERAND_MESSAGE = const GqlPermissionOperandSchema = z.union( [ - z.object({ user: z.string() }).strict(), + z.strictObject({ user: z.string() }), z.string(), z.boolean(), z.array(z.string()), @@ -169,9 +168,9 @@ const GqlPermissionOperandSchema = z.union( const RecordPermissionOperandSchema = z.union([ GqlPermissionOperandSchema, - z.object({ record: z.string() }), - z.object({ oldRecord: z.string() }), - z.object({ newRecord: z.string() }), + z.strictObject({ record: z.string() }), + z.strictObject({ oldRecord: z.string() }), + z.strictObject({ newRecord: z.string() }), ]); const PermissionOperatorSchema = z.enum(["=", "!=", "in", "not in", "hasAny", "not hasAny"]); @@ -186,7 +185,7 @@ const GqlPermissionConditionSchema = z const ActionPermissionSchema = z.union([ // Object format: { conditions, description?, permit? } - z.object({ + z.strictObject({ conditions: z.union([ RecordPermissionConditionSchema, z.array(RecordPermissionConditionSchema).readonly(), @@ -229,16 +228,16 @@ const GqlPermissionActionSchema = z.enum([ "bulkUpsert", ]); -const GqlPermissionPolicySchema = z.object({ +const GqlPermissionPolicySchema = z.strictObject({ conditions: z.array(GqlPermissionConditionSchema).readonly(), actions: z.union([z.literal("all"), z.array(GqlPermissionActionSchema).readonly()]), permit: z.boolean().optional(), description: z.string().optional(), }); -export const RawPermissionsSchema = z.object({ +export const RawPermissionsSchema = z.strictObject({ record: z - .object({ + .strictObject({ create: z.array(ActionPermissionSchema).readonly(), read: z.array(ActionPermissionSchema).readonly(), update: z.array(ActionPermissionSchema).readonly(), @@ -248,28 +247,38 @@ export const RawPermissionsSchema = z.object({ gql: z.array(GqlPermissionPolicySchema).readonly().optional(), }); -export const TailorDBTypeSchema = z.object({ +export const TailorDBTypeSchema = z.strictObject({ name: z.string(), fields: z.record(z.string(), TailorDBFieldSchema), - metadata: z.object({ - name: z.string(), - description: z.string().optional(), - settings: TailorDBTypeSettingsSchema.optional(), - permissions: RawPermissionsSchema, - files: z.record(z.string(), z.string()), - indexes: z - .record( - z.string(), - z.object({ - fields: z.array(z.string()), - unique: z.boolean().optional(), - }), - ) - .optional(), - }), + // oxlint-disable-next-line zod/prefer-strict-object, tailor-zod/require-object-policy-comment -- Keep z.object().strict() so zinfer preserves the RawPermissions alias in generated types. + metadata: z + .object({ + name: z.string(), + description: z.string().optional(), + settings: TailorDBTypeSettingsSchema.optional(), + permissions: RawPermissionsSchema, + files: z.record(z.string(), z.string()), + indexes: z + .record( + z.string(), + z.strictObject({ + fields: z.array(z.string()), + unique: z.boolean().optional(), + }), + ) + .optional(), + typeHook: z + .strictObject({ + create: functionSchema.optional(), + update: functionSchema.optional(), + }) + .optional(), + typeValidate: functionSchema.optional(), + }) + .strict(), }); -const TailorDBMigrationConfigSchema = z.object({ +const TailorDBMigrationConfigSchema = z.strictObject({ directory: z.string().describe("Directory containing migration files"), machineUser: z.string().optional().describe("Machine user name for migration execution"), }); @@ -278,7 +287,7 @@ const TailorDBMigrationConfigSchema = z.object({ * Schema for TailorDB service configuration. * Normalizes gqlOperations from alias ("query") to object format. */ -export const TailorDBServiceConfigSchema = z.object({ +export const TailorDBServiceConfigSchema = z.strictObject({ files: z.array(z.string()).describe("Glob patterns for TailorDB type definition files"), ignores: z.array(z.string()).optional().describe("Glob patterns to exclude from type discovery"), erdSite: z.string().optional().describe("URL for the ERD (Entity Relationship Diagram) site"), diff --git a/packages/sdk/src/parser/service/tailordb/type-parser.ts b/packages/sdk/src/parser/service/tailordb/type-parser.ts index cd539376b8..43d76ec64f 100644 --- a/packages/sdk/src/parser/service/tailordb/type-parser.ts +++ b/packages/sdk/src/parser/service/tailordb/type-parser.ts @@ -1,6 +1,6 @@ import * as inflection from "inflection"; import { isPluginGeneratedType } from "#/parser/service/tailordb/type-source"; -import { parseFieldConfig } from "./field"; +import { convertTypeHookToExpr, convertTypeValidateToExpr, parseFieldConfig } from "./field"; import { parsePermissions } from "./permission"; import { validateRelationConfig, @@ -171,6 +171,19 @@ function parseTailorDBType( permissions: parsePermissions(metadata.permissions), indexes: metadata.indexes, files: metadata.files, + ...(metadata.typeHook && { + typeHookExpr: { + ...(typeof metadata.typeHook.create === "function" && { + create: convertTypeHookToExpr(metadata.typeHook.create), + }), + ...(typeof metadata.typeHook.update === "function" && { + update: convertTypeHookToExpr(metadata.typeHook.update), + }), + }, + }), + ...(typeof metadata.typeValidate === "function" && { + typeValidateExpr: convertTypeValidateToExpr(metadata.typeValidate), + }), }; } diff --git a/packages/sdk/src/parser/service/tailordb/type-script.test.ts b/packages/sdk/src/parser/service/tailordb/type-script.test.ts new file mode 100644 index 0000000000..ff5bc2fb1e --- /dev/null +++ b/packages/sdk/src/parser/service/tailordb/type-script.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "vitest"; +import { buildTypeScripts, type ScriptFieldConfig } from "./type-script"; + +describe("buildTypeScripts", () => { + test("returns empty result when no field has hooks or validators", () => { + const fields: Record = { + id: { type: "uuid" }, + name: { type: "string" }, + }; + expect(buildTypeScripts(fields)).toEqual({}); + }); + + test("aggregates field hooks into one script binding a shared timestamp", () => { + const fields: Record = { + createdAt: { + type: "datetime", + hooks: { create: { expr: "_now" } }, + }, + updatedAt: { + type: "datetime", + hooks: { create: { expr: "_now" }, update: { expr: "_now" } }, + }, + }; + + const { typeHook, typeValidate } = buildTypeScripts(fields); + expect(typeValidate).toBeUndefined(); + + // A single `new Date()` is bound once and dispatched to every field. + const createExpr = typeHook?.create?.expr ?? ""; + expect(createExpr.match(/new Date\(\)/g)).toHaveLength(1); + expect(createExpr).toContain( + '"createdAt": ((_value, _oldValue) => (_now))(_input["createdAt"], _oldRecord?.["createdAt"] ?? null)', + ); + expect(createExpr).toContain( + '"updatedAt": ((_value, _oldValue) => (_now))(_input["updatedAt"], _oldRecord?.["updatedAt"] ?? null)', + ); + + // createdAt has no update hook, so the update script only touches updatedAt. + const updateExpr = typeHook?.update?.expr ?? ""; + expect(updateExpr).toContain('"updatedAt":'); + expect(updateExpr).not.toContain('"createdAt":'); + }); + + test("reconstructs nested objects so unhooked siblings are preserved", () => { + const fields: Record = { + profile: { + type: "nested", + fields: { + displayName: { type: "string", hooks: { create: { expr: "_value.trim()" } } }, + contact: { + type: "nested", + fields: { + email: { type: "string", hooks: { create: { expr: "_value.toLowerCase()" } } }, + }, + }, + }, + }, + }; + + const createExpr = buildTypeScripts(fields).typeHook?.create?.expr ?? ""; + expect(createExpr).toContain('"profile": Object.assign({}, _input["profile"], {'); + expect(createExpr).toContain( + '(_input["profile"] || {})["displayName"], _oldRecord?.["profile"]?.["displayName"] ?? null)', + ); + expect(createExpr).toContain( + '"contact": Object.assign({}, (_input["profile"] || {})["contact"], {', + ); + expect(createExpr).toContain( + '((_input["profile"] || {})["contact"] || {})["email"], _oldRecord?.["profile"]?.["contact"]?.["email"] ?? null)', + ); + }); + + test("applies default as ?? fallback after hook on create only", () => { + const fields: Record = { + status: { + type: "enum", + default: "active", + hooks: { create: { expr: "_value" } }, + }, + name: { + type: "string", + default: "unnamed", + }, + }; + + const { typeHook } = buildTypeScripts(fields); + const createExpr = typeHook?.create?.expr ?? ""; + + // hook + default: hookResult ?? defaultValue + expect(createExpr).toContain( + '"status": ((_value, _oldValue) => (_value))(_input["status"], _oldRecord?.["status"] ?? null) ?? "active"', + ); + // default only: input ?? defaultValue + expect(createExpr).toContain('"name": _input["name"] ?? "unnamed"'); + + // defaults are create-only — update script should not include them + expect(typeHook?.update).toBeUndefined(); + }); + + test("uses _now for datetime/date/time defaults with 'now'", () => { + const fields: Record = { + createdAt: { type: "datetime", default: "now" }, + startDate: { type: "date", default: "now" }, + startTime: { type: "time", default: "now" }, + label: { type: "string", default: "now" }, + }; + + const createExpr = buildTypeScripts(fields).typeHook?.create?.expr ?? ""; + expect(createExpr).toContain('"createdAt": _input["createdAt"] ?? _now'); + expect(createExpr).toContain('"startDate": _input["startDate"] ?? _now'); + expect(createExpr).toContain('"startTime": _input["startTime"] ?? _now'); + // "now" on a string field is just a literal string, not _now + expect(createExpr).toContain('"label": _input["label"] ?? "now"'); + }); + + test("builds a validate script with ?? chain and typeof string check", () => { + const fields: Record = { + age: { + type: "integer", + validate: [ + { script: { expr: "_value >= 0" }, errorMessage: "" }, + { script: { expr: "_value < 200" }, errorMessage: "" }, + ], + }, + }; + + const { typeValidate, typeHook } = buildTypeScripts(fields); + expect(typeHook).toBeUndefined(); + + const createExpr = typeValidate?.create?.expr ?? ""; + expect(typeValidate?.update?.expr).toBe(createExpr); + expect(createExpr).toContain("const __errs = {}"); + expect(createExpr).toContain('const _value = _newRecord["age"]'); + expect(createExpr).not.toContain("_oldValue"); + expect(createExpr).toContain("(_value >= 0) ?? (_value < 200)"); + expect(createExpr).toContain('if (typeof __r === "string") { __errs["age"] = __r; }'); + expect(createExpr).toContain("return __errs"); + expect(createExpr).not.toContain("new Date()"); + }); + + test("includes type-level validate with __issues function", () => { + const typeValidateExpr = + '(({ newRecord }) => { if (newRecord.start > newRecord.end) __issues("start", "bad"); })({ newRecord: _newRecord, oldRecord: _oldRecord }, __issues)'; + + const { typeValidate } = buildTypeScripts({}, { typeValidateExpr }); + const expr = typeValidate?.create?.expr ?? ""; + expect(typeValidate?.update?.expr).toBe(expr); + expect(expr).toContain("const __errs = {}"); + expect(expr).toContain("const __issues = (f, m) => { __errs[f] = m; }"); + expect(expr).toContain(typeValidateExpr); + expect(expr).toContain("return __errs"); + }); + + test("combines field validators and type-level validate in one script", () => { + const fields: Record = { + name: { + type: "string", + validate: [{ script: { expr: "checkName(_value)" }, errorMessage: "" }], + }, + }; + const typeValidateExpr = "typeValidateFn({ newRecord: _newRecord }, __issues)"; + + const { typeValidate } = buildTypeScripts(fields, { typeValidateExpr }); + const expr = typeValidate?.create?.expr ?? ""; + expect(expr).toContain('const _value = _newRecord["name"]'); + expect(expr).toContain("const __issues = (f, m) => { __errs[f] = m; }"); + expect(expr).toContain(typeValidateExpr); + }); + + test("no typeValidate output when no field validators and no type-level validate", () => { + expect(buildTypeScripts({})).toEqual({}); + expect(buildTypeScripts({}, undefined)).toEqual({}); + }); +}); diff --git a/packages/sdk/src/parser/service/tailordb/type-script.ts b/packages/sdk/src/parser/service/tailordb/type-script.ts new file mode 100644 index 0000000000..5fe134c066 --- /dev/null +++ b/packages/sdk/src/parser/service/tailordb/type-script.ts @@ -0,0 +1,193 @@ +// Platform-injected record map for type-level hook/validate scripts. +const INPUT = "_input"; +const NEW_RECORD = "_newRecord"; +const OLD_RECORD = "_oldRecord"; +// Shared operation timestamp bound once per script execution. +const NOW = "_now"; + +const TIME_TYPES = new Set(["datetime", "date", "time"]); + +type HookOperation = "create" | "update"; + +interface ScriptRef { + expr: string; +} + +/** + * Minimal structural shape shared by parser `OperatorFieldConfig` and migration + * `SnapshotFieldConfig`. Only the parts needed to aggregate type-level scripts. + */ +export interface ScriptFieldConfig { + type: string; + hooks?: { + create?: ScriptRef; + update?: ScriptRef; + }; + validate?: { script?: ScriptRef; errorMessage: string }[]; + default?: unknown; + fields?: Record; +} + +export interface TypeScripts { + typeHook?: { create?: ScriptRef; update?: ScriptRef }; + typeValidate?: { create?: ScriptRef; update?: ScriptRef }; +} + +const key = (name: string) => JSON.stringify(name); + +const isNestedType = (config: ScriptFieldConfig): boolean => + config.type === "nested" && config.fields !== undefined; + +function serializeDefault(value: unknown, fieldType: string): string { + if (value === "now" && TIME_TYPES.has(fieldType)) return NOW; + if (value instanceof Date) return `new Date(${JSON.stringify(value.toISOString())})`; + return JSON.stringify(value); +} + +/** + * Build the object literal that reconstructs one record level with hook + * overrides and defaults applied. For create, defaults are appended as + * `?? defaultValue` after the hook expression (field hook → default). + * Returns null when no field under this level has a hook or default for + * the operation. + * @param {Record} fields - Field configurations + * @param {string} accessExpr - JS expression to access the parent object + * @param {string} oldAccessExpr - JS expression to access the old record parent + * @param {HookOperation} operation - Hook operation type + * @returns {string | null} Object literal expression or null + */ +function buildHookObject( + fields: Record, + accessExpr: string, + oldAccessExpr: string, + operation: HookOperation, +): string | null { + const parts: string[] = []; + + for (const [name, config] of Object.entries(fields)) { + const access = `${accessExpr}[${key(name)}]`; + const oldAccess = `${oldAccessExpr}?.[${key(name)}]`; + if (isNestedType(config) && config.fields) { + const inner = buildHookObject(config.fields, `(${access} || {})`, oldAccess, operation); + if (inner !== null) { + parts.push(`${key(name)}: Object.assign({}, ${access}, ${inner})`); + } + continue; + } + + const hook = config.hooks?.[operation]; + const hasDefault = operation === "create" && config.default !== undefined; + + if (hook && hasDefault) { + parts.push( + `${key(name)}: ((_value, _oldValue) => (${hook.expr}))(${access}, ${oldAccess} ?? null) ?? ${serializeDefault(config.default, config.type)}`, + ); + } else if (hook) { + parts.push( + `${key(name)}: ((_value, _oldValue) => (${hook.expr}))(${access}, ${oldAccess} ?? null)`, + ); + } else if (hasDefault) { + parts.push(`${key(name)}: ${access} ?? ${serializeDefault(config.default, config.type)}`); + } + } + + if (parts.length === 0) return null; + return `{ ${parts.join(", ")} }`; +} + +/** + * Build validation statements for one record level. + * Each leaf field with validators contributes a block that records the first + * failing message keyed by its dotted field path. + * @param {Record} fields - Field configurations + * @param {string} accessExpr - JS expression to access the parent object + * @param {string} keyPrefix - Dotted path prefix for error keys + * @returns {string[]} Array of validation statement strings + */ +function buildValidateStatements( + fields: Record, + accessExpr: string, + keyPrefix: string, +): string[] { + const statements: string[] = []; + + for (const [name, config] of Object.entries(fields)) { + const access = `${accessExpr}[${key(name)}]`; + const fieldPath = keyPrefix ? `${keyPrefix}.${name}` : name; + + if (isNestedType(config) && config.fields) { + statements.push(...buildValidateStatements(config.fields, `(${access} || {})`, fieldPath)); + continue; + } + + const validators = (config.validate ?? []).filter((v) => v.script?.expr); + if (validators.length > 0) { + const chain = validators.map((v) => `(${v.script?.expr})`).join(" ?? "); + statements.push( + `{ const _value = ${access};` + + ` const __r = ${chain}; if (typeof __r === "string") { __errs[${key(fieldPath)}] = __r; } }`, + ); + } + } + + return statements; +} + +function wrapHook(objectExpr: string): string { + return `(() => { const ${NOW} = new Date(); return ${objectExpr}; })()`; +} + +function wrapValidate(statements: string[], typeValidateExpr?: string): string { + const issuesFn = typeValidateExpr ? " const __issues = (f, m) => { __errs[f] = m; };" : ""; + const typeValidateStmt = typeValidateExpr ? ` ${typeValidateExpr};` : ""; + return `(() => { const __errs = {};${issuesFn} ${statements.join(" ")}${typeValidateStmt} return __errs; })()`; +} + +/** + * Aggregate every field's create/update hook, default, and validate into + * type-level scripts. Hooks compute a single shared timestamp (`now`) per + * operation, so all fields touched in one create/update observe the same + * instant. Defaults are applied after hooks on create only. Validators + * run with the same rules on create and update. + * @param fields - Per-field script configuration + * @param options - Optional type-level hook/validate expressions + * @returns Aggregated type-level scripts + */ +export function buildTypeScripts( + fields: Record, + options?: { + typeHookExpr?: { create?: string; update?: string }; + typeValidateExpr?: string; + }, +): TypeScripts { + const result: TypeScripts = {}; + const typeHookExpr = options?.typeHookExpr; + const typeValidateExpr = options?.typeValidateExpr; + + const hook: { create?: ScriptRef; update?: ScriptRef } = {}; + for (const operation of ["create", "update"] as const) { + const perFieldExpr = buildHookObject(fields, INPUT, OLD_RECORD, operation); + const typeLevelExpr = typeHookExpr?.[operation]; + + if (perFieldExpr !== null && typeLevelExpr) { + hook[operation] = { + expr: wrapHook(`Object.assign({}, ${perFieldExpr}, ${typeLevelExpr})`), + }; + } else if (typeLevelExpr) { + hook[operation] = { expr: wrapHook(typeLevelExpr) }; + } else if (perFieldExpr !== null) { + hook[operation] = { expr: wrapHook(perFieldExpr) }; + } + } + if (hook.create || hook.update) { + result.typeHook = hook; + } + + const statements = buildValidateStatements(fields, NEW_RECORD, ""); + if (statements.length > 0 || typeValidateExpr) { + const expr = wrapValidate(statements, typeValidateExpr); + result.typeValidate = { create: { expr }, update: { expr } }; + } + + return result; +} diff --git a/packages/sdk/src/parser/service/tailordb/types.ts b/packages/sdk/src/parser/service/tailordb/types.ts index c478ca7e0e..a3dd0a75b7 100644 --- a/packages/sdk/src/parser/service/tailordb/types.ts +++ b/packages/sdk/src/parser/service/tailordb/types.ts @@ -79,6 +79,7 @@ export interface OperatorFieldConfig { format?: string; }; scale?: number; + default?: unknown; fields?: Record; } @@ -171,4 +172,6 @@ export interface TailorDBType { permissions: Permissions; indexes?: TailorDBTypeMetadata["indexes"]; files?: TailorDBTypeMetadata["files"]; + typeHookExpr?: { create?: string; update?: string }; + typeValidateExpr?: string; } diff --git a/packages/sdk/src/parser/service/workflow/schema.ts b/packages/sdk/src/parser/service/workflow/schema.ts index 3953bca7cf..0fb4a0f2a2 100644 --- a/packages/sdk/src/parser/service/workflow/schema.ts +++ b/packages/sdk/src/parser/service/workflow/schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { functionSchema } from "../common"; -export const WorkflowJobSchema = z.object({ +export const WorkflowJobSchema = z.strictObject({ name: z.string().describe("Job name (must be unique across the project)"), trigger: functionSchema.describe("Trigger function that initiates the job"), body: functionSchema.describe("Job implementation function"), @@ -32,7 +32,7 @@ const durationSchema = (maxSeconds: number) => }); export const RetryPolicySchema = z - .object({ + .strictObject({ maxRetries: z.number().int().min(1).max(10).describe("Maximum number of retries (1-10)"), initialBackoff: durationSchema(3600).describe( "Initial backoff duration (e.g., '1s', '500ms', '1m', max 1h)", @@ -42,6 +42,7 @@ export const RetryPolicySchema = z ), backoffMultiplier: z.number().min(1).describe("Backoff multiplier (>= 1)"), }) + .refine((data) => durationToSeconds(data.initialBackoff) <= durationToSeconds(data.maxBackoff), { message: "initialBackoff must be less than or equal to maxBackoff", path: ["initialBackoff"], @@ -51,7 +52,7 @@ export const RetryPolicySchema = z path: ["initialBackoff"], }); -export const ConcurrencyPolicySchema = z.object({ +export const ConcurrencyPolicySchema = z.strictObject({ maxConcurrentExecutions: z .number() .int() @@ -60,7 +61,7 @@ export const ConcurrencyPolicySchema = z.object({ .describe("Maximum number of concurrent executions (1-1000)"), }); -export const WorkflowSchema = z.object({ +export const WorkflowSchema = z.strictObject({ name: z.string().describe("Workflow name"), mainJob: WorkflowJobSchema.describe("Main job that starts the workflow"), retryPolicy: RetryPolicySchema.optional().describe("Retry policy for the workflow"), diff --git a/packages/sdk/src/plugin/builtin/file-utils/generate-file-utils.ts b/packages/sdk/src/plugin/builtin/file-utils/generate-file-utils.ts index 4c72383326..1c227327c5 100644 --- a/packages/sdk/src/plugin/builtin/file-utils/generate-file-utils.ts +++ b/packages/sdk/src/plugin/builtin/file-utils/generate-file-utils.ts @@ -43,7 +43,7 @@ export function generateUnifiedFileUtils( FileUploadOptions, FileUploadResponse, FileMetadata, - FileStreamIterator, + FileDownloadStreamResponse, } from "@tailor-platform/sdk/runtime/file"; ` + "\n"; @@ -116,15 +116,15 @@ export function generateUnifiedFileUtils( } ` + "\n"; - // Generate openFileDownloadStream helper function - const openDownloadStreamFunction = + // Generate downloadFileStream helper function + const downloadStreamFunction = multiline /* ts */ ` - export async function openFileDownloadStream( + export async function downloadFileStream( type: T, field: TypeWithFiles[T]["fields"], recordId: string, - ): Promise { - return await file.openDownloadStream(namespaces[type], type, field, recordId); + ): Promise { + return await file.downloadStream(namespaces[type], type, field, recordId); } ` + "\n"; @@ -136,6 +136,6 @@ export function generateUnifiedFileUtils( uploadFunction, deleteFunction, getMetadataFunction, - openDownloadStreamFunction, + downloadStreamFunction, ].join("\n"); } diff --git a/packages/sdk/src/plugin/builtin/file-utils/index.test.ts b/packages/sdk/src/plugin/builtin/file-utils/index.test.ts index b7c40d5083..7971e0ec01 100644 --- a/packages/sdk/src/plugin/builtin/file-utils/index.test.ts +++ b/packages/sdk/src/plugin/builtin/file-utils/index.test.ts @@ -85,6 +85,12 @@ describe("FileUtilsPlugin", () => { expect(result).toContain('"receipt" | "form"'); expect(result).toContain('User: "tailordb"'); expect(result).toContain('SalesOrder: "tailordb"'); + expect(result).toContain("FileDownloadStreamResponse"); + expect(result).toContain("export async function downloadFileStream"); + expect(result).toContain("return await file.downloadStream"); + expect(result).not.toContain("FileStreamIterator"); + expect(result).not.toContain("openFileDownloadStream"); + expect(result).not.toContain("openDownloadStream"); }); test("should merge types from multiple namespaces", () => { diff --git a/packages/sdk/src/plugin/builtin/kysely-type/index.test.ts b/packages/sdk/src/plugin/builtin/kysely-type/index.test.ts index 5b857eaf11..9210fe9c46 100644 --- a/packages/sdk/src/plugin/builtin/kysely-type/index.test.ts +++ b/packages/sdk/src/plugin/builtin/kysely-type/index.test.ts @@ -78,17 +78,17 @@ describe("KyselyTypePlugin integration tests", () => { expect(result.name).toBe("User"); expect(result.typeDef).toContain("User: {"); - expect(result.typeDef).toContain("id: Generated;"); + expect(result.typeDef).toContain("id: Generated;"); expect(result.typeDef).toContain("name: string;"); expect(result.typeDef).toContain("email: string;"); expect(result.typeDef).toContain("age: number | null;"); expect(result.typeDef).toContain("isActive: boolean;"); expect(result.typeDef).toContain("score: number | null;"); - expect(result.typeDef).toContain("birthDate: Timestamp | null;"); + expect(result.typeDef).toContain("birthDate: DateString | null;"); expect(result.typeDef).toContain("lastLogin: Timestamp | null;"); expect(result.typeDef).toContain("tags: string[];"); expect(result.typeDef).toContain("createdAt: Generated;"); - expect(result.typeDef).toContain("updatedAt: Timestamp | null;"); + expect(result.typeDef).toContain("updatedAt: Generated;"); }); test("should have correct id and description", () => { diff --git a/packages/sdk/src/plugin/builtin/kysely-type/index.ts b/packages/sdk/src/plugin/builtin/kysely-type/index.ts index 9d47c9a885..c5da7764e2 100644 --- a/packages/sdk/src/plugin/builtin/kysely-type/index.ts +++ b/packages/sdk/src/plugin/builtin/kysely-type/index.ts @@ -42,8 +42,17 @@ export function kyselyTypePlugin( (acc, type) => ({ Timestamp: acc.Timestamp || type.usedUtilityTypes.Timestamp, Serial: acc.Serial || type.usedUtilityTypes.Serial, + DateString: acc.DateString || type.usedUtilityTypes.DateString, + DecimalString: acc.DecimalString || type.usedUtilityTypes.DecimalString, + TimeString: acc.TimeString || type.usedUtilityTypes.TimeString, }), - { Timestamp: false, Serial: false }, + { + Timestamp: false, + Serial: false, + DateString: false, + DecimalString: false, + TimeString: false, + }, ); allNamespaceData.push({ diff --git a/packages/sdk/src/plugin/builtin/kysely-type/type-processor.test.ts b/packages/sdk/src/plugin/builtin/kysely-type/type-processor.test.ts index b35bd36bcc..9f5ac813d3 100644 --- a/packages/sdk/src/plugin/builtin/kysely-type/type-processor.test.ts +++ b/packages/sdk/src/plugin/builtin/kysely-type/type-processor.test.ts @@ -63,7 +63,7 @@ describe("Kysely TypeProcessor", () => { cancelledAt: db.datetime({ optional: true }), }), expected: [ - "startDate: Timestamp;", + "startDate: DateString;", "endDate: Timestamp;", "cancelledAt: Timestamp | null;", ], @@ -74,7 +74,7 @@ describe("Kysely TypeProcessor", () => { userId: db.uuid(), deviceId: db.uuid({ optional: true }), }), - expected: ["userId: string;", "deviceId: string | null;"], + expected: ["userId: UUIDString;", "deviceId: UUIDString | null;"], }, ])("should handle $name", async ({ type, expected }) => { const typeDef = await getTypeDef(type); @@ -104,7 +104,7 @@ describe("Kysely TypeProcessor", () => { ); expect(typeDef).toContain("eventDates: ArrayColumnType;"); - expect(typeDef).toContain("optionalDates: ArrayColumnType | null;"); + expect(typeDef).toContain("optionalDates: DateString[] | null;"); }); }); @@ -184,7 +184,7 @@ describe("Kysely TypeProcessor", () => { expect(typeDef).toContain("phone?: string | null"); }); - test("should use Date | string instead of Timestamp for date fields inside nested objects", async () => { + test("should use DateString for date fields inside nested objects", async () => { const type = db.type("Receipt", { receiptDate: db.date(), dueSchedule: db.object({ @@ -195,10 +195,10 @@ describe("Kysely TypeProcessor", () => { const result = await processKyselyType(parseTailorDBType(toSchemaOutput(type))); - expect(result.typeDef).toContain("receiptDate: Timestamp;"); + expect(result.typeDef).toContain("receiptDate: DateString;"); // Nested object with datetime is wrapped in ObjectColumnType expect(result.typeDef).toContain("ObjectColumnType<"); - expect(result.typeDef).toContain("dueDate: Timestamp"); + expect(result.typeDef).toContain("dueDate: DateString"); expect(result.typeDef).toContain("reminderAt?: Timestamp | null"); expect(result.usedUtilityTypes.Timestamp).toBe(true); }); @@ -287,17 +287,29 @@ describe("Kysely TypeProcessor", () => { expect(result.typeDef).toContain("UserWithTimestamp: {"); expect(result.typeDef).toContain("name: string"); expect(result.typeDef).toContain("createdAt: Generated;"); - expect(result.typeDef).toContain("updatedAt: Timestamp | null;"); + expect(result.typeDef).toContain("updatedAt: Generated;"); }); - test("should always include Generated for id field", async () => { + test("should wrap fields with default in Generated<>", async () => { + const typeDef = await getTypeDef( + db.type("Order", { + status: db.string().default("pending"), + priority: db.int(), + }), + ); + + expect(typeDef).toContain("status: Generated;"); + expect(typeDef).toContain("priority: number;"); + }); + + test("should always include Generated for id field", async () => { const typeDef = await getTypeDef( db.type("User", { name: db.string(), }), ); - expect(typeDef).toContain("id: Generated;"); + expect(typeDef).toContain("id: Generated;"); }); test.each([ diff --git a/packages/sdk/src/plugin/builtin/kysely-type/type-processor.ts b/packages/sdk/src/plugin/builtin/kysely-type/type-processor.ts index 024487e055..af363a1d1b 100644 --- a/packages/sdk/src/plugin/builtin/kysely-type/type-processor.ts +++ b/packages/sdk/src/plugin/builtin/kysely-type/type-processor.ts @@ -2,7 +2,38 @@ import multiline from "#/utils/multiline"; import { type KyselyNamespaceMetadata, type KyselyTypeMetadata } from "./types"; import type { OperatorFieldConfig, TailorDBType } from "#/parser/service/tailordb/types"; -type UsedUtilityTypes = { Timestamp: boolean; Serial: boolean }; +type UsedUtilityTypes = { + Timestamp: boolean; + Serial: boolean; + DateString: boolean; + DecimalString: boolean; + TimeString: boolean; +}; + +function createUsedUtilityTypes(): UsedUtilityTypes { + return { + Timestamp: false, + Serial: false, + DateString: false, + DecimalString: false, + TimeString: false, + }; +} + +function mergeUsedUtilityTypes( + results: { usedUtilityTypes: UsedUtilityTypes }[], +): UsedUtilityTypes { + return results.reduce( + (acc, result) => ({ + Timestamp: acc.Timestamp || result.usedUtilityTypes.Timestamp, + Serial: acc.Serial || result.usedUtilityTypes.Serial, + DateString: acc.DateString || result.usedUtilityTypes.DateString, + DecimalString: acc.DecimalString || result.usedUtilityTypes.DecimalString, + TimeString: acc.TimeString || result.usedUtilityTypes.TimeString, + }), + createUsedUtilityTypes(), + ); +} type FieldTypeResult = { type: string; @@ -38,7 +69,7 @@ function getNestedType(fieldConfig: OperatorFieldConfig): FieldTypeResult { if (!fields || typeof fields !== "object") { return { type: "string", - usedUtilityTypes: { Timestamp: false, Serial: false }, + usedUtilityTypes: createUsedUtilityTypes(), }; } @@ -51,13 +82,7 @@ function getNestedType(fieldConfig: OperatorFieldConfig): FieldTypeResult { }; }); - const aggregatedUtilityTypes = fieldResults.reduce( - (acc, result) => ({ - Timestamp: acc.Timestamp || result.usedUtilityTypes.Timestamp, - Serial: acc.Serial || result.usedUtilityTypes.Serial, - }), - { Timestamp: false, Serial: false }, - ); + const aggregatedUtilityTypes = mergeUsedUtilityTypes(fieldResults); const fieldTypes = fieldResults.map((r) => r.fieldType); const obj = `{\n ${fieldTypes.join(";\n ")}${fieldTypes.length > 0 ? ";" : ""}\n}`; @@ -76,24 +101,36 @@ function getNestedType(fieldConfig: OperatorFieldConfig): FieldTypeResult { */ function getBaseType(fieldConfig: OperatorFieldConfig): FieldTypeResult { const fieldType = fieldConfig.type; - const usedUtilityTypes = { Timestamp: false, Serial: false }; + const usedUtilityTypes = createUsedUtilityTypes(); let type: string; switch (fieldType) { case "uuid": + type = "UUIDString"; + break; case "string": - case "decimal": type = "string"; break; + case "decimal": + usedUtilityTypes.DecimalString = true; + type = "DecimalString"; + break; case "integer": case "float": type = "number"; break; - case "date": case "datetime": usedUtilityTypes.Timestamp = true; type = "Timestamp"; break; + case "date": + usedUtilityTypes.DateString = true; + type = "DateString"; + break; + case "time": + usedUtilityTypes.TimeString = true; + type = "TimeString"; + break; case "bool": case "boolean": type = "boolean"; @@ -149,7 +186,7 @@ function generateFieldType(fieldConfig: OperatorFieldConfig): FieldTypeResult { usedUtilityTypes.Serial = true; finalType = `Serial<${finalType}>`; } - if (fieldConfig.hooks?.create) { + if (fieldConfig.hooks?.create || fieldConfig.default !== undefined) { finalType = `Generated<${finalType}>`; } @@ -173,18 +210,11 @@ function generateTableInterface(type: TailorDBType): { })); const fields = [ - "id: Generated;", + "id: Generated;", ...fieldResults.map((result) => `${result.fieldName}: ${result.type};`), ]; - const aggregatedUtilityTypes = fieldResults.reduce( - (acc, result) => ({ - Timestamp: acc.Timestamp || result.usedUtilityTypes.Timestamp, - - Serial: acc.Serial || result.usedUtilityTypes.Serial, - }), - { Timestamp: false, Serial: false }, - ); + const aggregatedUtilityTypes = mergeUsedUtilityTypes(fieldResults); const typeDef = multiline /* ts */ ` ${type.name}: { @@ -225,14 +255,26 @@ export function generateUnifiedKyselyTypes(namespaceData: KyselyNamespaceMetadat (acc, ns) => ({ Timestamp: acc.Timestamp || ns.usedUtilityTypes.Timestamp, Serial: acc.Serial || ns.usedUtilityTypes.Serial, + DateString: acc.DateString || ns.usedUtilityTypes.DateString, + DecimalString: acc.DecimalString || ns.usedUtilityTypes.DecimalString, + TimeString: acc.TimeString || ns.usedUtilityTypes.TimeString, }), - { Timestamp: false, Serial: false }, + createUsedUtilityTypes(), ); - const utilityTypeImports: string[] = ["type Generated"]; + const utilityTypeImports: string[] = ["type Generated", "type UUIDString"]; if (globalUsedUtilityTypes.Timestamp) { utilityTypeImports.push("type Timestamp"); } + if (globalUsedUtilityTypes.DateString) { + utilityTypeImports.push("type DateString"); + } + if (globalUsedUtilityTypes.DecimalString) { + utilityTypeImports.push("type DecimalString"); + } + if (globalUsedUtilityTypes.TimeString) { + utilityTypeImports.push("type TimeString"); + } const hasObjectColumnType = namespaceData.some((ns) => ns.types.some((t) => t.typeDef.includes("ObjectColumnType<")), ); diff --git a/packages/sdk/src/plugin/builtin/kysely-type/types.ts b/packages/sdk/src/plugin/builtin/kysely-type/types.ts index fd1cd3c0e4..d238a21148 100644 --- a/packages/sdk/src/plugin/builtin/kysely-type/types.ts +++ b/packages/sdk/src/plugin/builtin/kysely-type/types.ts @@ -8,6 +8,9 @@ export interface KyselyTypeMetadata { usedUtilityTypes: { Timestamp: boolean; Serial: boolean; + DateString: boolean; + DecimalString: boolean; + TimeString: boolean; }; } @@ -17,5 +20,8 @@ export interface KyselyNamespaceMetadata { usedUtilityTypes: { Timestamp: boolean; Serial: boolean; + DateString: boolean; + DecimalString: boolean; + TimeString: boolean; }; } diff --git a/packages/sdk/src/plugin/builtin/registry.ts b/packages/sdk/src/plugin/builtin/registry.ts deleted file mode 100644 index 8a2e4cba1a..0000000000 --- a/packages/sdk/src/plugin/builtin/registry.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { enumConstantsPlugin, EnumConstantsGeneratorID } from "./enum-constants"; -import { fileUtilsPlugin, FileUtilsGeneratorID } from "./file-utils"; -import { kyselyTypePlugin, KyselyGeneratorID } from "./kysely-type"; -import { seedPlugin, SeedGeneratorID } from "./seed"; -import type { Plugin } from "#/plugin/types"; - -// Map of builtin generator IDs to plugin factory functions -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- builtin plugins accept heterogeneous options -export const builtinPlugins = new Map Plugin>([ - [KyselyGeneratorID, (options) => kyselyTypePlugin(options)], - [SeedGeneratorID, (options) => seedPlugin(options)], - [EnumConstantsGeneratorID, (options) => enumConstantsPlugin(options)], - [FileUtilsGeneratorID, (options) => fileUtilsPlugin(options)], -]); diff --git a/packages/sdk/src/plugin/builtin/seed/index.ts b/packages/sdk/src/plugin/builtin/seed/index.ts index 174141fdb3..fee5193af2 100644 --- a/packages/sdk/src/plugin/builtin/seed/index.ts +++ b/packages/sdk/src/plugin/builtin/seed/index.ts @@ -100,7 +100,7 @@ function generateIdpUserSeedFunction(hasIdpUser: boolean, idpNamespace: string | workspaceId, name: "seed-idp-user.ts", code: idpSeedCode, - arg: JSON.stringify({ users: rows }), + arg: { users: rows }, invoker: { namespace: authNamespace, machineUserName, @@ -186,7 +186,6 @@ function generateIdpUserTruncateFunction(hasIdpUser: boolean, idpNamespace: stri workspaceId, name: "truncate-idp-user.ts", code: idpTruncateCode, - arg: JSON.stringify({}), invoker: { namespace: authNamespace, machineUserName, @@ -674,7 +673,7 @@ ${namespaceSelfRefEntries} workspaceId, name: \`seed-\${namespace}.ts\`, code: bundled.bundledCode, - arg: JSON.stringify({ data: chunk.data, order: chunk.order, selfRefTypes }), + arg: { data: chunk.data, order: chunk.order, selfRefTypes }, invoker: { namespace: authNamespace, machineUserName, diff --git a/packages/sdk/src/plugin/compat.test.ts b/packages/sdk/src/plugin/compat.test.ts deleted file mode 100644 index b57b6bc834..0000000000 --- a/packages/sdk/src/plugin/compat.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { randomUUID } from "node:crypto"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { beforeAll, describe, expect, test } from "vitest"; -import { generate } from "#/cli/commands/generate/service"; - -describe("defineGenerators and definePlugins produce identical output", () => { - const fixtureDir = path.resolve(__dirname, "../cli/commands/deploy/__test_fixtures__"); - const generatorsDir = path.join(fixtureDir, "generators-compat-out"); - const pluginsDir = path.join(fixtureDir, "plugins-compat-out"); - - const collectFiles = (rootDir: string): string[] => { - const files: string[] = []; - const traverse = (currentDir: string) => { - const entries = fs.readdirSync(currentDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.name === ".DS_Store") continue; - const fullPath = path.join(currentDir, entry.name); - if (entry.isDirectory()) { - traverse(fullPath); - } else { - files.push(path.relative(rootDir, fullPath).split(path.sep).join("/")); - } - } - }; - traverse(rootDir); - return files.toSorted(); - }; - - beforeAll(async () => { - process.env.TAILOR_PLATFORM_WORKSPACE_ID ??= randomUUID(); - - for (const dir of [generatorsDir, pluginsDir]) { - if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true }); - } - - await generate({ - configPath: path.join(fixtureDir, "tailor.config.generators-compat.ts"), - }); - await generate({ - configPath: path.join(fixtureDir, "tailor.config.plugins-compat.ts"), - }); - }, 120000); - - test("plugin output includes all generated files from defineGenerators", () => { - const generatorFiles = collectFiles(generatorsDir); - const pluginFiles = collectFiles(pluginsDir); - expect(generatorFiles.length).toBeGreaterThan(0); - for (const file of generatorFiles) { - expect(pluginFiles, `File ${file} missing from plugins output`).toContain(file); - } - }); - - test("all files have identical content", () => { - const files = collectFiles(generatorsDir); - expect(files.length).toBeGreaterThan(0); - - const normalizeConfigPath = (content: string) => - content.replace( - /tailor\.config\.(generators|plugins)-compat\.ts/g, - "tailor.config.compat.ts", - ); - - for (const file of files) { - const generatorContent = normalizeConfigPath( - fs.readFileSync(path.join(generatorsDir, file), "utf-8"), - ); - const pluginContent = normalizeConfigPath( - fs.readFileSync(path.join(pluginsDir, file), "utf-8"), - ); - expect(pluginContent, `Content mismatch in ${file}`).toBe(generatorContent); - } - }); -}); diff --git a/packages/sdk/src/plugin/manager.ts b/packages/sdk/src/plugin/manager.ts index 79f71ad85e..f24428bd76 100644 --- a/packages/sdk/src/plugin/manager.ts +++ b/packages/sdk/src/plugin/manager.ts @@ -5,8 +5,8 @@ import type { TailorTypePermission, TailorTypeGqlPermission, } from "#/configure/services/tailordb/permission"; -import type { DependencyKind } from "#/parser/generator-config/schema"; import type { + DependencyKind, Plugin, PluginAttachment, PluginGeneratedExecutor, @@ -563,7 +563,7 @@ export interface PluginTypeGenerationResult { * Parameters for generating plugin files */ export interface GeneratePluginFilesParams { - /** Base output directory (e.g., .tailor-sdk/plugin) */ + /** Base output directory (e.g., .tailor/plugin) */ outputDir: string; /** Map of source type names to their source info */ sourceTypeInfoMap: Map; diff --git a/packages/sdk/src/plugin/types.ts b/packages/sdk/src/plugin/types.ts index fbd1c434ea..5ea57a640c 100644 --- a/packages/sdk/src/plugin/types.ts +++ b/packages/sdk/src/plugin/types.ts @@ -4,19 +4,8 @@ // This is a pure type module: type declarations only, no zod/schema // references, importable type-only from any layer. -import type { - BaseGeneratorConfigInput, - CodeGeneratorInput, -} from "#/types/generator-config.generated"; - export type DependencyKind = "tailordb" | "resolver" | "executor"; -export type GeneratorConfig = BaseGeneratorConfigInput; - -export type CodeGeneratorBase = Omit & { - dependencies: readonly DependencyKind[]; -}; - import type { PluginAttachment, TailorAnyDBField, diff --git a/packages/sdk/src/plugin/with-context.ts b/packages/sdk/src/plugin/with-context.ts index 0bbacf2c4e..87fe583a92 100644 --- a/packages/sdk/src/plugin/with-context.ts +++ b/packages/sdk/src/plugin/with-context.ts @@ -4,7 +4,7 @@ * context (like type references and namespace) at runtime. */ -import type { TailorActor, TailorEnv } from "#/runtime/types"; +import type { TailorEnv, TailorPrincipal } from "#/runtime/types"; /** * Plugin executor factory function type. @@ -28,8 +28,8 @@ export interface PluginFunctionArgs { appNamespace: string; /** Environment variables */ env: TailorEnv; - /** Actor (user) who triggered the event, null for system events */ - actor: TailorActor | null; + /** Principal that triggered the event, null for system events */ + actor: TailorPrincipal | null; /** Name of the TailorDB type */ typeName: string; /** TailorDB connections by namespace */ diff --git a/packages/sdk/src/runtime/aigateway.ts b/packages/sdk/src/runtime/aigateway.ts index 0e91d3857a..2d21b05146 100644 --- a/packages/sdk/src/runtime/aigateway.ts +++ b/packages/sdk/src/runtime/aigateway.ts @@ -6,7 +6,7 @@ * from `@tailor-platform/sdk/vitest` to mock these calls in unit tests. * * `name` is narrowed to the AI Gateway names defined via `defineAIGateway()` - * once `tailor.d.ts` has been generated (via `tailor-sdk deploy`/`generate`). + * once `tailor.d.ts` has been generated (via `tailor deploy`/`generate`). * @example * import { aigateway } from "@tailor-platform/sdk/runtime"; * diff --git a/packages/sdk/src/runtime/authconnection.ts b/packages/sdk/src/runtime/authconnection.ts index 9e0aa7b62f..1a9cf36a1e 100644 --- a/packages/sdk/src/runtime/authconnection.ts +++ b/packages/sdk/src/runtime/authconnection.ts @@ -6,7 +6,7 @@ * `mockAuthconnection` from `@tailor-platform/sdk/vitest` to mock in unit tests. * * `connectionName` is narrowed to the connection names defined in `defineAuth()`'s - * `connections` once `tailor.d.ts` has been generated (via `tailor-sdk deploy`/`generate`). + * `connections` once `tailor.d.ts` has been generated (via `tailor deploy`/`generate`). * @example * import { authconnection } from "@tailor-platform/sdk/runtime"; * diff --git a/packages/sdk/src/runtime/context.test.ts b/packages/sdk/src/runtime/context.test.ts index 3de72d57e1..7153f2b463 100644 --- a/packages/sdk/src/runtime/context.test.ts +++ b/packages/sdk/src/runtime/context.test.ts @@ -23,7 +23,7 @@ describe("@tailor-platform/sdk/runtime/context", () => { test("getInvoker exposes SDK shape (attributes map + attributeList array)", () => { using _invokerSpy = vi.spyOn(globalThis.tailor.context, "getInvoker").mockReturnValue({ - id: "u-1", + id: "11111111-1111-4111-8111-111111111111", type: "machine_user", workspaceId: "ws-1", attributes: ["role"], @@ -33,7 +33,7 @@ describe("@tailor-platform/sdk/runtime/context", () => { const invoker = context.getInvoker(); expect(invoker).toEqual({ - id: "u-1", + id: "11111111-1111-4111-8111-111111111111", type: "machine_user", workspaceId: "ws-1", attributes: { role: "MANAGER" }, diff --git a/packages/sdk/src/runtime/context.ts b/packages/sdk/src/runtime/context.ts index 5fac684a5a..805a851d5a 100644 --- a/packages/sdk/src/runtime/context.ts +++ b/packages/sdk/src/runtime/context.ts @@ -12,24 +12,16 @@ * } */ +import type { UUIDString } from "#/configure/types/scalar.types"; +import type { TailorPrincipal } from "#/runtime/types"; + /** * Information about the invoker of the current function execution. * - * Matches the shape of `TailorUser` and `TailorActor` — `attributes` is the - * attribute map and `attributeList` is the array of attribute IDs. + * Matches the public `TailorPrincipal` shape — `attributes` is the attribute + * map and `attributeList` is the array of attribute IDs. */ -export interface Invoker { - /** The invoker's ID */ - id: string; - /** The invoker's type */ - type: "user" | "machine_user"; - /** The workspace ID */ - workspaceId: string; - /** A map of the invoker's attributes */ - attributes: Record; - /** The list of attribute IDs */ - attributeList: string[]; -} +export type Invoker = TailorPrincipal; /** * Raw platform-side invoker payload returned by `tailor.context.getInvoker()`. @@ -38,7 +30,7 @@ export interface Invoker { */ export interface ContextInvoker { /** The invoker's ID */ - id: string; + id: UUIDString; /** The invoker's type */ type: "user" | "machine_user"; /** The workspace ID */ @@ -70,7 +62,7 @@ export function getInvoker(): Invoker | null { id: raw.id, type: raw.type, workspaceId: raw.workspaceId, - attributes: raw.attributeMap, - attributeList: raw.attributes, + attributes: raw.attributeMap as Invoker["attributes"], + attributeList: raw.attributes as Invoker["attributeList"], }; } diff --git a/packages/sdk/src/runtime/file.test.ts b/packages/sdk/src/runtime/file.test.ts index 832fd98cad..a1dd23bfd0 100644 --- a/packages/sdk/src/runtime/file.test.ts +++ b/packages/sdk/src/runtime/file.test.ts @@ -91,28 +91,8 @@ describe("@tailor-platform/sdk/runtime/file", () => { expect(fileM.calls).toEqual([expectedCall("delete")]); }); - test("openDownloadStream forwards and yields StreamValue chunks", async () => { - using fileM = mockFile(); - const sequence: file.StreamValue[] = [ - { - type: "metadata", - metadata: { contentType: "application/octet-stream", fileSize: 2, sha256sum: "h" }, - }, - { type: "chunk", data: new Uint8Array([1]), position: 0 }, - { type: "chunk", data: new Uint8Array([2]), position: 1 }, - { type: "complete" }, - ]; - fileM.enqueueResult(sequence); - - const stream = await file.openDownloadStream(...args); - - const chunks: file.StreamValue[] = []; - for await (const chunk of stream) { - chunks.push(chunk); - } - - expect(chunks).toEqual(sequence); - expect(fileM.calls[0]?.method).toBe("openDownloadStream"); + test("does not export the removed openDownloadStream wrapper", () => { + expect("openDownloadStream" in file).toBe(false); }); test("downloadStream forwards and returns body with metadata", async () => { diff --git a/packages/sdk/src/runtime/file.ts b/packages/sdk/src/runtime/file.ts index d062b038a8..1ea2b0e31c 100644 --- a/packages/sdk/src/runtime/file.ts +++ b/packages/sdk/src/runtime/file.ts @@ -39,13 +39,6 @@ export interface FileMetadata { lastUploadedAt?: string; } -/** Stream metadata (first chunk emitted by {@link openDownloadStream}). */ -export interface StreamMetadata { - contentType: string; - fileSize: number; - sha256sum: string; -} - /** Upload options. */ export interface FileUploadOptions { contentType?: string; @@ -80,18 +73,6 @@ export interface FileDownloadStreamResponse { metadata: DownloadMetadata; } -/** Stream chunk types emitted by {@link FileStreamIterator}. */ -export type StreamValue = - | { type: "metadata"; metadata: StreamMetadata } - | { type: "chunk"; data: Uint8Array; position: number } - | { type: "complete" }; - -/** Stream iterator returned by {@link openDownloadStream}. */ -export interface FileStreamIterator extends AsyncIterableIterator { - next(): Promise>; - close(): Promise; -} - /** Error code emitted by {@link TailorDBFileError}. */ export type TailorDBFileErrorCode = | "INVALID_PARAMS" @@ -206,22 +187,6 @@ export interface TailorDBFileAPI { recordId: string, ): Promise; - /** - * Open a download stream for large files. - * @deprecated Use {@link downloadStream} instead. - * @param namespace - TailorDB namespace - * @param typeName - TailorDB type name - * @param fieldName - File field name on the type - * @param recordId - Record ID owning the field - * @returns Async iterator yielding file chunks; call `close()` to release resources - */ - openDownloadStream( - namespace: string, - typeName: string, - fieldName: string, - recordId: string, - ): Promise; - /** * Download a file as a ReadableStream. * @param namespace - TailorDB namespace @@ -296,15 +261,6 @@ export const deleteFile: TailorDBFileAPI["delete"] = (...args) => api().delete(. */ export const getMetadata: TailorDBFileAPI["getMetadata"] = (...args) => api().getMetadata(...args); -/** - * See {@link TailorDBFileAPI.openDownloadStream}. - * @deprecated Use {@link downloadStream} instead. - * @param args - Forwarded to {@link TailorDBFileAPI.openDownloadStream} - * @returns Async iterator yielding file chunks; call `close()` to release resources - */ -export const openDownloadStream: TailorDBFileAPI["openDownloadStream"] = (...args) => - api().openDownloadStream(...args); - /** * See {@link TailorDBFileAPI.downloadStream}. * @param args - Forwarded to {@link TailorDBFileAPI.downloadStream} diff --git a/packages/sdk/src/runtime/globals.test.ts b/packages/sdk/src/runtime/globals.test.ts index d617b5e1ea..608b38f667 100644 --- a/packages/sdk/src/runtime/globals.test.ts +++ b/packages/sdk/src/runtime/globals.test.ts @@ -1,13 +1,19 @@ /** * Type-level tests confirming that opting into `@tailor-platform/sdk/runtime/globals` - * activates the ambient `tailor.*` / `tailordb` declarations. + * activates the ambient `tailor.*` / `tailordb` declarations, and that the + * removed capital-cased `Tailordb` namespace no longer resolves. * - * These assertions are type-only — they reference `tailor`, `tailordb`, and - * `TailorDBFileError` solely through `typeof` so the test does not require - * the platform runtime to inject those values into the unit test environment. + * These assertions are type-only — the positive checks reference `tailor`, + * `tailordb`, and `TailorDBFileError` without depending on the platform + * runtime injecting those values into the unit test environment. */ import "#/runtime/globals"; import { describe, expectTypeOf, test } from "vitest"; +import type { TailordbCommandType } from "#/runtime/index"; + +// @ts-expect-error Tailordb was removed in v2; use lowercase tailordb.*. +const legacyTailordbQueryResult = null as unknown as Tailordb.QueryResult<{ id: string }>; +void legacyTailordbQueryResult; describe("@tailor-platform/sdk/runtime/globals activates ambient globals", () => { test("tailor.iconv.convert is declared as a function", () => { @@ -40,6 +46,12 @@ describe("@tailor-platform/sdk/runtime/globals activates ambient globals", () => expectTypeOf().toBeFunction(); }); + test("tailordb namespace exposes query helper types", () => { + expectTypeOf>().not.toBeAny(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toBeAny(); + }); + test("TailorDBFileError is declared as a global class", () => { expectTypeOf().not.toBeAny(); }); diff --git a/packages/sdk/src/runtime/globals.ts b/packages/sdk/src/runtime/globals.ts index 698b283598..0f0f2b3602 100644 --- a/packages/sdk/src/runtime/globals.ts +++ b/packages/sdk/src/runtime/globals.ts @@ -48,8 +48,8 @@ import type { UserQuery as IdpUserQuery, } from "./idp"; import type { - AuthInvoker as WorkflowAuthInvoker, - TriggerWorkflowOptions as WorkflowTriggerWorkflowOptions, + Invoker as WorkflowInvoker, + PlatformTriggerWorkflowOptions as WorkflowTriggerWorkflowOptions, } from "./workflow"; type TailorIdpClientConfig = IdpClientConfig; @@ -62,7 +62,7 @@ type TailorIdpUnenrollMfaInput = IdpUnenrollMfaInput; type TailorIdpUpdateUserInput = IdpUpdateUserInput; type TailorIdpUser = IdpUser; type TailorIdpUserQuery = IdpUserQuery; -type TailorWorkflowAuthInvoker = WorkflowAuthInvoker; +type TailorWorkflowInvoker = WorkflowInvoker; type TailorWorkflowTriggerWorkflowOptions = WorkflowTriggerWorkflowOptions; declare global { @@ -75,40 +75,6 @@ declare global { // eslint-disable-next-line no-var var tailordb: TailordbRuntime; - /** - * @deprecated Use the lowercase `tailordb.*` namespace instead (e.g. - * `tailordb.QueryResult`, `tailordb.CommandType`, - * `typeof tailordb.Client`). This capital-cased namespace is retained - * only for backwards compatibility with `@tailor-platform/function-types` - * and will be removed in v2. Run - * `pnpm dlx @tailor-platform/sdk-codemod v2/tailordb-namespace` - * to migrate. - */ - namespace Tailordb { - /** - * @deprecated Use `tailordb.Client` (lowercase) instead. - * Will be removed in v2. - */ - class Client { - constructor(config: { namespace: string }); - connect(): Promise; - end(): Promise; - queryObject(sql: string, args?: readonly unknown[]): Promise>; - } - - /** - * @deprecated Use `tailordb.QueryResult` (lowercase) instead. - * Will be removed in v2. - */ - type QueryResult = TailordbQueryResult; - - /** - * @deprecated Use `tailordb.CommandType` (lowercase) instead. - * Will be removed in v2. - */ - type CommandType = TailordbCommandType; - } - namespace tailor { namespace iconv { type Iconv = IconvInstance; @@ -128,7 +94,7 @@ declare global { } namespace workflow { - type AuthInvoker = TailorWorkflowAuthInvoker; + type Invoker = TailorWorkflowInvoker; type TriggerWorkflowOptions = TailorWorkflowTriggerWorkflowOptions; } diff --git a/packages/sdk/src/runtime/idp.test.ts b/packages/sdk/src/runtime/idp.test.ts index 0c32ccdaad..3f68941eb5 100644 --- a/packages/sdk/src/runtime/idp.test.ts +++ b/packages/sdk/src/runtime/idp.test.ts @@ -19,18 +19,32 @@ describe("@tailor-platform/sdk/runtime/idp", () => { test("Client.user forwards args and namespace", async () => { using idpM = mockIdp(); - idpM.enqueueResult({ id: "u-1", name: "alice", disabled: false }); + idpM.enqueueResult({ + id: "11111111-1111-4111-8111-111111111111", + name: "alice", + disabled: false, + }); const client = new idp.Client({ namespace: "ns" }); - const result = await client.user("u-1"); + const result = await client.user("11111111-1111-4111-8111-111111111111"); - expect(result).toEqual({ id: "u-1", name: "alice", disabled: false }); - expect(idpM.calls).toEqual([{ method: "user", args: ["u-1"], namespace: "ns" }]); + expect(result).toEqual({ + id: "11111111-1111-4111-8111-111111111111", + name: "alice", + disabled: false, + }); + expect(idpM.calls).toEqual([ + { method: "user", args: ["11111111-1111-4111-8111-111111111111"], namespace: "ns" }, + ]); }); test("Client.userByName forwards", async () => { using idpM = mockIdp(); - idpM.enqueueResult({ id: "u-1", name: "alice", disabled: false }); + idpM.enqueueResult({ + id: "11111111-1111-4111-8111-111111111111", + name: "alice", + disabled: false, + }); const client = new idp.Client({ namespace: "ns" }); await client.userByName("alice"); @@ -41,7 +55,7 @@ describe("@tailor-platform/sdk/runtime/idp", () => { test("Client.users forwards options", async () => { using idpM = mockIdp(); idpM.enqueueResult({ - users: [{ id: "u-1", name: "alice", disabled: false }], + users: [{ id: "11111111-1111-4111-8111-111111111111", name: "alice", disabled: false }], nextPageToken: null, totalCount: 1, }); @@ -56,15 +70,15 @@ describe("@tailor-platform/sdk/runtime/idp", () => { test("Client.createUser / updateUser / deleteUser forward", async () => { using idpM = mockIdp(); idpM.enqueueResults( - { id: "u-2", name: "bob", disabled: false }, - { id: "u-2", name: "bob2", disabled: false }, + { id: "22222222-2222-4222-8222-222222222222", name: "bob", disabled: false }, + { id: "22222222-2222-4222-8222-222222222222", name: "bob2", disabled: false }, true, ); const client = new idp.Client({ namespace: "ns" }); await client.createUser({ name: "bob", password: "p" }); - await client.updateUser({ id: "u-2", name: "bob2" }); - const removed = await client.deleteUser("u-2"); + await client.updateUser({ id: "22222222-2222-4222-8222-222222222222", name: "bob2" }); + const removed = await client.deleteUser("22222222-2222-4222-8222-222222222222"); expect(removed).toBe(true); expect(idpM.calls.map((c) => c.method)).toEqual(["createUser", "updateUser", "deleteUser"]); @@ -73,7 +87,10 @@ describe("@tailor-platform/sdk/runtime/idp", () => { test("Client.sendPasswordResetEmail forwards", async () => { using idpM = mockIdp(); const client = new idp.Client({ namespace: "ns" }); - const args = { userId: "u-1", redirectUri: "https://example.com/reset" }; + const args = { + userId: "11111111-1111-4111-8111-111111111111", + redirectUri: "https://example.com/reset", + } as const; const ok = await client.sendPasswordResetEmail(args); expect(ok).toBe(true); @@ -85,7 +102,10 @@ describe("@tailor-platform/sdk/runtime/idp", () => { test("Client.unenrollMfa forwards", async () => { using idpM = mockIdp(); const client = new idp.Client({ namespace: "ns" }); - const args = { userId: "u-1", mfaFactorId: "f-1" }; + const args = { + userId: "11111111-1111-4111-8111-111111111111", + mfaFactorId: "f-1", + } as const; const ok = await client.unenrollMfa(args); expect(ok).toBe(true); diff --git a/packages/sdk/src/runtime/idp.ts b/packages/sdk/src/runtime/idp.ts index c707de7934..c6e64c41ed 100644 --- a/packages/sdk/src/runtime/idp.ts +++ b/packages/sdk/src/runtime/idp.ts @@ -11,6 +11,8 @@ * const { users } = await client.users({ first: 10 }); */ +import type { UUIDString } from "#/configure/types/scalar.types"; + /** Configuration object for {@link Client}. */ export interface ClientConfig { namespace: string; @@ -18,7 +20,7 @@ export interface ClientConfig { /** User record returned by IDP operations. */ export interface User { - id: string; + id: UUIDString; name: string; disabled: boolean; createdAt?: string; @@ -37,7 +39,7 @@ export interface User { /** Filter options for {@link Client.users}. */ export interface UserQuery { /** Filter by user IDs */ - ids?: string[]; + ids?: UUIDString[]; /** Filter by user names */ names?: string[]; } @@ -72,7 +74,7 @@ export interface CreateUserInput { /** Input for {@link Client.updateUser}. */ export interface UpdateUserInput { /** The user's ID */ - id: string; + id: UUIDString; /** New name for the user */ name?: string; /** New password for the user. Cannot be used with clearPassword. */ @@ -86,7 +88,7 @@ export interface UpdateUserInput { /** Input for {@link Client.sendPasswordResetEmail}. */ export interface SendPasswordResetEmailInput { /** The ID of the user */ - userId: string; + userId: UUIDString; /** The URI to redirect to after password reset */ redirectUri: string; /** The sender display name. Defaults to 'Tailor Platform IdP'. */ @@ -98,7 +100,7 @@ export interface SendPasswordResetEmailInput { /** Input for {@link Client.unenrollMfa}. */ export interface UnenrollMfaInput { /** The ID of the user whose factor will be unenrolled. */ - userId: string; + userId: UUIDString; /** * The ID of the factor to unenroll. Factor IDs are exposed on the user * record (see {@link User.mfaFactorIds}). @@ -109,11 +111,11 @@ export interface UnenrollMfaInput { /** Instance methods exposed by `tailor.idp.Client`. */ export interface IdpClientInstance { users(options?: ListUsersOptions): Promise; - user(userId: string): Promise; + user(userId: UUIDString): Promise; userByName(name: string): Promise; createUser(input: CreateUserInput): Promise; updateUser(input: UpdateUserInput): Promise; - deleteUser(userId: string): Promise; + deleteUser(userId: UUIDString): Promise; sendPasswordResetEmail(input: SendPasswordResetEmailInput): Promise; unenrollMfa(input: UnenrollMfaInput): Promise; } @@ -161,7 +163,7 @@ export class Client { * @param userId - IDP user ID * @returns The matching user */ - user(userId: string): Promise { + user(userId: UUIDString): Promise { return this.#impl.user(userId); } @@ -197,7 +199,7 @@ export class Client { * @param userId - IDP user ID * @returns `true` when the user was deleted */ - deleteUser(userId: string): Promise { + deleteUser(userId: UUIDString): Promise { return this.#impl.deleteUser(userId); } diff --git a/packages/sdk/src/runtime/types.ts b/packages/sdk/src/runtime/types.ts index 99b928e00d..9e54d1798a 100644 --- a/packages/sdk/src/runtime/types.ts +++ b/packages/sdk/src/runtime/types.ts @@ -4,94 +4,36 @@ // not reference zod or schema modules, so every layer can import it type-only // without pulling any runtime dependency. +import type { UUIDString } from "#/configure/types/scalar.types"; + // Interfaces for module augmentation -// Users can extend these via: declare module "@tailor-platform/sdk" { interface AttributeMap { ... } } +// Users can extend these via: declare module "@tailor-platform/sdk" { interface Attributes { ... } } // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface AttributeMap {} +export interface Attributes {} export interface AttributeList { __tuple?: []; // Marker for tuple type } -export type InferredAttributeMap = keyof AttributeMap extends never +export type InferredAttributes = keyof Attributes extends never ? Record - : AttributeMap; + : Attributes; export type InferredAttributeList = AttributeList["__tuple"] extends [] ? string[] : AttributeList["__tuple"]; -/** Represents a user in the Tailor platform. */ -export type TailorUser = { - /** - * The ID of the user. - * For unauthenticated users, this will be a nil UUID. - */ - id: string; - /** - * The type of the user. - * For unauthenticated users, this will be an empty string. - */ - type: "user" | "machine_user" | ""; - /** The ID of the workspace the user belongs to. */ - workspaceId: string; - /** - * A map of the user's attributes. - * For unauthenticated users, this will be null. - */ - attributes: InferredAttributeMap | null; - /** - * A list of the user's attributes. - * For unauthenticated users, this will be an empty array. - */ - attributeList: InferredAttributeList; -}; - -/** - * The invoker of the current function execution. - * - * Reflects `authInvoker` delegation: when `authInvoker` is specified, this is - * the machine user; otherwise it is the calling user. - * Distinct from resolver's `user` (the authenticated caller) and executor's - * `actor` (the subject of the event). - * - * `null` for anonymous requests. - * - * TODO(v2): unify with `TailorUser` — same underlying principal shape. - */ -export type TailorInvoker = { - /** The ID of the invoker (user ID or machine user ID). */ - id: string; - /** The type of the invoker. */ +/** Represents a user or machine user principal in the Tailor Platform. */ +export type TailorPrincipal = { + /** The ID of the principal. */ + id: UUIDString; + /** The type of the principal. */ type: "user" | "machine_user"; - /** The ID of the workspace the invoker belongs to. */ - workspaceId: string; - /** A map of the invoker's attributes. */ - attributes: InferredAttributeMap; - /** A list of the invoker's attribute IDs. */ - attributeList: InferredAttributeList; -} | null; - -/** User type enum values from the Tailor Platform server. */ -export type TailorActorType = "USER_TYPE_USER" | "USER_TYPE_MACHINE_USER" | "USER_TYPE_UNSPECIFIED"; - -/** Represents an actor in event triggers. */ -export type TailorActor = { - /** The ID of the workspace the user belongs to. */ + /** The ID of the workspace the principal belongs to. */ workspaceId: string; - /** The ID of the user. */ - userId: string; - /** - * A map of the user's attributes. - * Maps from server's `attributeMap` field. - */ - attributes: InferredAttributeMap | null; - /** - * A list of the user's attributes. - * Maps from server's `attributes` field. - */ + /** A map of the principal's attributes. */ + attributes: InferredAttributes; + /** A list of the principal's attribute IDs. */ attributeList: InferredAttributeList; - /** The type of the user. */ - userType: TailorActorType; }; // Interface for module augmentation diff --git a/packages/sdk/src/runtime/workflow.test.ts b/packages/sdk/src/runtime/workflow.test.ts index 2c3df2c997..2dcde19be9 100644 --- a/packages/sdk/src/runtime/workflow.test.ts +++ b/packages/sdk/src/runtime/workflow.test.ts @@ -31,7 +31,7 @@ describe("@tailor-platform/sdk/runtime/workflow", () => { "my-workflow", { a: 1 }, { - authInvoker: { namespace: "ns", machineUserName: "mu" }, + invoker: { namespace: "ns", machineUserName: "mu" }, }, ); diff --git a/packages/sdk/src/runtime/workflow.ts b/packages/sdk/src/runtime/workflow.ts index 84c177318f..629f359c5b 100644 --- a/packages/sdk/src/runtime/workflow.ts +++ b/packages/sdk/src/runtime/workflow.ts @@ -16,7 +16,7 @@ * Specifies the machine user that should be used to execute the workflow. * This allows workflows to run with specific authentication context. */ -export interface AuthInvoker { +export interface Invoker { /** The namespace where the machine user is defined */ namespace: string; /** The name of the machine user to use for workflow execution */ @@ -25,8 +25,12 @@ export interface AuthInvoker { /** Options for {@link triggerWorkflow}. */ export interface TriggerWorkflowOptions { - /** Optional authentication invoker to specify which machine user should execute the workflow */ - authInvoker?: AuthInvoker; + /** Optional invoker to specify which machine user should execute the workflow */ + invoker?: Invoker; +} + +export interface PlatformTriggerWorkflowOptions { + authInvoker?: Invoker; } /** @@ -42,13 +46,13 @@ export interface TailorWorkflowAPI { * Triggers a workflow and returns its execution ID. * @param workflowName - Workflow name as defined in tailor.config * @param args - Arguments forwarded to the workflow's main job - * @param options - Optional trigger options (e.g. `authInvoker`) + * @param options - Optional platform trigger options * @returns The execution ID of the triggered workflow */ triggerWorkflow( workflowName: string, args?: any, - options?: TriggerWorkflowOptions, + options?: PlatformTriggerWorkflowOptions, ): Promise; /** @@ -89,11 +93,21 @@ const api = (): TailorWorkflowAPI => /** * See {@link TailorWorkflowAPI.triggerWorkflow}. - * @param args - Forwarded to {@link TailorWorkflowAPI.triggerWorkflow} + * @param workflowName - Workflow name as defined in tailor.config + * @param args - Arguments forwarded to the workflow's main job + * @param options - Optional trigger options * @returns The execution ID of the triggered workflow */ -export const triggerWorkflow: TailorWorkflowAPI["triggerWorkflow"] = (...args) => - api().triggerWorkflow(...args); +export function triggerWorkflow( + workflowName: string, + args?: any, + options?: TriggerWorkflowOptions, +): Promise { + if (options?.invoker === undefined) { + return api().triggerWorkflow(workflowName, args); + } + return api().triggerWorkflow(workflowName, args, { authInvoker: options.invoker }); +} /** * See {@link TailorWorkflowAPI.resumeWorkflow}. diff --git a/packages/sdk/src/types/auth-connection.generated.ts b/packages/sdk/src/types/auth-connection.generated.ts index 1b00348292..654eff1129 100644 --- a/packages/sdk/src/types/auth-connection.generated.ts +++ b/packages/sdk/src/types/auth-connection.generated.ts @@ -17,12 +17,19 @@ export type AuthConnectionOAuth2Config = { export type AuthConnectionOAuth2ConfigInput = AuthConnectionOAuth2Config; export type AuthConnectionConfig = { - type: "oauth2"; + /** OAuth2 provider URL */ providerUrl: string; + /** OAuth2 issuer URL */ issuerUrl: string; + /** OAuth2 client ID */ clientId: string; + /** OAuth2 client secret */ clientSecret: string; + /** Connection type */ + type: "oauth2"; + /** OAuth2 authorization endpoint override */ authUrl?: string | undefined; + /** OAuth2 token endpoint override */ tokenUrl?: string | undefined; }; export type AuthConnectionConfigInput = AuthConnectionConfig; diff --git a/packages/sdk/src/types/auth.generated.ts b/packages/sdk/src/types/auth.generated.ts index ecd8f7bedf..0138877c7d 100644 --- a/packages/sdk/src/types/auth.generated.ts +++ b/packages/sdk/src/types/auth.generated.ts @@ -412,11 +412,11 @@ export type AuthConfigInput = connections?: | { [x: string]: { - type: "oauth2"; providerUrl: string; issuerUrl: string; clientId: string; clientSecret: string; + type: "oauth2"; authUrl?: string | undefined; tokenUrl?: string | undefined; }; @@ -426,17 +426,919 @@ export type AuthConfigInput = userProfile?: | { type: { - name: string; - fields: any; - metadata: any; - hooks: any; - validate: any; - features: any; - indexes: any; - files: any; - permission: any; - gqlPermission: any; - _output: any; + readonly name: string; + readonly fields: any; + readonly _output: {}; + readonly metadata: { + name: string; + description?: string | undefined; + settings?: + | { + pluralForm?: string | undefined; + aggregation?: boolean | undefined; + bulkUpsert?: boolean | undefined; + gqlOperations?: + | "query" + | { + create?: boolean | undefined | undefined; + update?: boolean | undefined | undefined; + delete?: boolean | undefined | undefined; + read?: boolean | undefined | undefined; + } + | undefined; + publishEvents?: boolean | undefined; + } + | undefined; + permissions: { + record?: + | { + create: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined | undefined; + permit?: boolean | undefined | undefined; + } + )[]; + read: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined | undefined; + permit?: boolean | undefined | undefined; + } + )[]; + update: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined | undefined; + permit?: boolean | undefined | undefined; + } + )[]; + delete: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined | undefined; + permit?: boolean | undefined | undefined; + } + )[]; + } + | undefined; + gql?: + | readonly { + conditions: readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + ), + ])[]; + actions: + | "all" + | readonly ( + | "bulkUpsert" + | "create" + | "update" + | "delete" + | "read" + | "aggregate" + )[]; + permit?: boolean | undefined | undefined; + description?: string | undefined | undefined; + }[] + | undefined; + }; + files: { + [x: string]: string; + }; + indexes?: + | { + [x: string]: { + fields: string[]; + unique?: boolean | undefined; + }; + } + | undefined; + typeHook?: + | { + create?: Function | undefined; + update?: Function | undefined; + } + | undefined; + typeValidate?: Function | undefined; + }; + readonly plugins: { + pluginId: string; + config: unknown; + }[]; }; usernameField: string; namespace?: string | undefined; @@ -482,6 +1384,7 @@ export type AuthConfigInput = update?: Function | undefined; } | undefined; + validate?: (Function | [Function, string])[] | undefined; typeName?: string | undefined; }; fields: any; @@ -610,11 +1513,11 @@ export type AuthConfigInput = connections?: | { [x: string]: { - type: "oauth2"; providerUrl: string; issuerUrl: string; clientId: string; clientSecret: string; + type: "oauth2"; authUrl?: string | undefined; tokenUrl?: string | undefined; }; @@ -760,11 +1663,11 @@ export type AuthConfig = connections?: | { [x: string]: { - type: "oauth2"; providerUrl: string; issuerUrl: string; clientId: string; clientSecret: string; + type: "oauth2"; authUrl?: string | undefined; tokenUrl?: string | undefined; }; @@ -775,16 +1678,962 @@ export type AuthConfig = | { type: { name: string; - fields: any; - metadata: any; - hooks: any; - validate: any; - features: any; - indexes: any; - files: any; - permission: any; - gqlPermission: any; - _output: any; + fields: { + [x: string]: { + type: string; + fields?: any | undefined; + metadata: { + required?: boolean | undefined | undefined; + array?: boolean | undefined | undefined; + description?: string | undefined | undefined; + typeName?: string | undefined | undefined; + allowedValues?: + | { + value: string; + description?: string | undefined | undefined; + }[] + | undefined; + index?: boolean | undefined | undefined; + unique?: boolean | undefined | undefined; + vector?: boolean | undefined | undefined; + foreignKey?: boolean | undefined | undefined; + foreignKeyType?: string | undefined | undefined; + foreignKeyField?: string | undefined | undefined; + hooks?: + | { + create?: Function | undefined; + update?: Function | undefined; + } + | undefined; + validate?: Function[] | undefined; + serial?: + | { + start: number; + maxValue?: number | undefined | undefined; + format?: string | undefined | undefined; + } + | undefined; + scale?: number | undefined | undefined; + default?: unknown; + }; + rawRelation?: + | { + type: "1-1" | "n-1" | "keyOnly" | "oneToOne" | "manyToOne" | "N-1"; + toward: { + type: string; + as?: string | undefined | undefined; + key?: string | undefined | undefined; + }; + backward?: string | undefined | undefined; + } + | undefined; + }; + }; + metadata: { + name: string; + permissions: { + record?: + | { + create: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined; + permit?: boolean | undefined; + } + )[]; + read: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined; + permit?: boolean | undefined; + } + )[]; + update: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined; + permit?: boolean | undefined; + } + )[]; + delete: readonly ( + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + boolean, + ] + | readonly ( + | boolean + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + )[] + | { + conditions: + | readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + | { + record: string; + } + | { + oldRecord: string; + } + | { + newRecord: string; + } + ), + ])[]; + description?: string | undefined; + permit?: boolean | undefined; + } + )[]; + } + | undefined; + gql?: + | readonly { + conditions: readonly (readonly [ + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + ), + "in" | "=" | "!=" | "not in" | "hasAny" | "not hasAny", + ( + | string + | boolean + | string[] + | boolean[] + | { + user: string; + } + ), + ])[]; + actions: + | "all" + | readonly ( + | "bulkUpsert" + | "create" + | "update" + | "delete" + | "read" + | "aggregate" + )[]; + permit?: boolean | undefined; + description?: string | undefined; + }[] + | undefined; + }; + files: { + [x: string]: string; + }; + description?: string | undefined; + settings?: + | { + pluralForm?: string | undefined; + aggregation?: boolean | undefined; + bulkUpsert?: boolean | undefined; + gqlOperations?: + | { + create?: boolean | undefined; + update?: boolean | undefined; + delete?: boolean | undefined; + read?: boolean | undefined; + } + | undefined; + publishEvents?: boolean | undefined; + } + | undefined; + indexes?: + | { + [x: string]: { + fields: string[]; + unique?: boolean | undefined; + }; + } + | undefined; + typeHook?: + | { + create?: Function | undefined; + update?: Function | undefined; + } + | undefined; + typeValidate?: Function | undefined; + }; }; usernameField: string; namespace?: string | undefined; @@ -830,6 +2679,7 @@ export type AuthConfig = update?: Function | undefined; } | undefined; + validate?: (Function | [Function, string])[] | undefined; typeName?: string | undefined; }; fields: any; @@ -968,11 +2818,11 @@ export type AuthConfig = connections?: | { [x: string]: { - type: "oauth2"; providerUrl: string; issuerUrl: string; clientId: string; clientSecret: string; + type: "oauth2"; authUrl?: string | undefined; tokenUrl?: string | undefined; }; diff --git a/packages/sdk/src/types/executor.generated.ts b/packages/sdk/src/types/executor.generated.ts index 551aca559e..61b872588f 100644 --- a/packages/sdk/src/types/executor.generated.ts +++ b/packages/sdk/src/types/executor.generated.ts @@ -103,8 +103,8 @@ export type FunctionOperation = { kind: "function" | "jobFunction"; /** Function implementation */ body: Function; - /** Auth invoker for the function execution */ - authInvoker?: + /** Invoker for the function execution */ + invoker?: | string | { namespace: string; @@ -121,8 +121,8 @@ export type GqlOperationInput = { appName?: string | undefined; /** Function to compute GraphQL variables */ variables?: Function | undefined; - /** Auth invoker for the GraphQL execution */ - authInvoker?: + /** Invoker for the GraphQL execution */ + invoker?: | string | { namespace: string; @@ -138,8 +138,8 @@ export type GqlOperation = { appName?: string | undefined; /** Function to compute GraphQL variables */ variables?: Function | undefined; - /** Auth invoker for the GraphQL execution */ - authInvoker?: + /** Invoker for the GraphQL execution */ + invoker?: | string | { namespace: string; @@ -179,7 +179,7 @@ export type WorkflowOperation = { [x: string]: unknown; } | undefined; - authInvoker?: + invoker?: | string | { namespace: string; @@ -303,7 +303,7 @@ export type Executor = { [x: string]: unknown; } | undefined; - authInvoker?: + invoker?: | string | { namespace: string; @@ -314,7 +314,7 @@ export type Executor = { | { kind: "function" | "jobFunction"; body: Function; - authInvoker?: + invoker?: | string | { namespace: string; @@ -327,7 +327,7 @@ export type Executor = { query: string; appName?: string | undefined; variables?: Function | undefined; - authInvoker?: + invoker?: | string | { namespace: string; diff --git a/packages/sdk/src/types/field.generated.ts b/packages/sdk/src/types/field.generated.ts index 3653e97349..65d0dd2d8a 100644 --- a/packages/sdk/src/types/field.generated.ts +++ b/packages/sdk/src/types/field.generated.ts @@ -31,6 +31,7 @@ export type TailorFieldInput = { update?: Function | undefined; } | undefined; + validate?: (Function | [Function, string])[] | undefined; typeName?: string | undefined; }; fields: { @@ -69,6 +70,7 @@ export type TailorField = { update?: Function | undefined; } | undefined; + validate?: (Function | [Function, string])[] | undefined; typeName?: string | undefined; }; fields: { diff --git a/packages/sdk/src/types/generator-config.generated.ts b/packages/sdk/src/types/generator-config.generated.ts deleted file mode 100644 index ba3ac8cca8..0000000000 --- a/packages/sdk/src/types/generator-config.generated.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Generated by zinfer - Do not edit manually - -export type CodeGenerator = { - id: string; - description: string; - dependencies: ("tailordb" | "resolver" | "executor")[]; - aggregate: Function; - processType?: Function | undefined; - processResolver?: Function | undefined; - processExecutor?: Function | undefined; - processTailorDBNamespace?: Function | undefined; - processResolverNamespace?: Function | undefined; -}; -export type CodeGeneratorInput = CodeGenerator; - -export type BaseGeneratorConfig = - | ["@tailor-platform/kysely-type", { distPath: string }] - | [ - "@tailor-platform/seed", - { - distPath: string; - machineUserName?: string | undefined; - disableIdpUserSync?: - | { userToIdp?: boolean | undefined; idpToUser?: boolean | undefined } - | undefined; - }, - ] - | ["@tailor-platform/enum-constants", { distPath: string }] - | ["@tailor-platform/file-utils", { distPath: string }] - | { - id: string; - description: string; - dependencies: ("tailordb" | "resolver" | "executor")[]; - aggregate: Function; - processType?: Function | undefined; - processResolver?: Function | undefined; - processExecutor?: Function | undefined; - processTailorDBNamespace?: Function | undefined; - processResolverNamespace?: Function | undefined; - }; -export type BaseGeneratorConfigInput = BaseGeneratorConfig; diff --git a/packages/sdk/src/types/resolver.generated.ts b/packages/sdk/src/types/resolver.generated.ts index de57d64512..f867c5d107 100644 --- a/packages/sdk/src/types/resolver.generated.ts +++ b/packages/sdk/src/types/resolver.generated.ts @@ -39,6 +39,7 @@ export type Resolver = { update?: Function | undefined; } | undefined; + validate?: (Function | [Function, string])[] | undefined; typeName?: string | undefined; }; fields: { @@ -56,7 +57,7 @@ export type Resolver = { /** Enable publishing events from this resolver */ publishEvents?: boolean | undefined; /** Machine user to execute this resolver as */ - authInvoker?: + invoker?: | string | { namespace: string; diff --git a/packages/sdk/src/types/tailordb.generated.ts b/packages/sdk/src/types/tailordb.generated.ts index 40984ceb81..d5dcf0510c 100644 --- a/packages/sdk/src/types/tailordb.generated.ts +++ b/packages/sdk/src/types/tailordb.generated.ts @@ -57,7 +57,7 @@ export type DBFieldMetadata = { } | undefined; /** Validation functions for the field */ - validate?: (Function | [Function, string])[] | undefined; + validate?: Function[] | undefined; /** Serial (auto-increment) configuration */ serial?: | { @@ -68,6 +68,8 @@ export type DBFieldMetadata = { | undefined; /** Decimal scale (number of digits after decimal point, 0-12) */ scale?: number | undefined; + /** Default value for the field on create */ + default?: unknown; }; export type DBFieldMetadataInput = DBFieldMetadata; @@ -1021,6 +1023,13 @@ export type TailorDBTypeRawInput = { }; } | undefined; + typeHook?: + | { + create?: Function | undefined; + update?: Function | undefined; + } + | undefined; + typeValidate?: Function | undefined; }; }; @@ -1053,7 +1062,7 @@ export type TailorDBTypeRaw = { update?: Function | undefined; } | undefined; - validate?: (Function | [Function, string])[] | undefined; + validate?: Function[] | undefined; serial?: | { start: number; @@ -1062,6 +1071,7 @@ export type TailorDBTypeRaw = { } | undefined; scale?: number | undefined | undefined; + default?: unknown; }; rawRelation?: | { @@ -1092,6 +1102,13 @@ export type TailorDBTypeRaw = { }; } | undefined; + typeHook?: + | { + create?: Function | undefined; + update?: Function | undefined; + } + | undefined; + typeValidate?: Function | undefined; }; }; diff --git a/packages/sdk/src/utils/test/index.test.ts b/packages/sdk/src/utils/test/index.test.ts index 9565d02658..eabc55e497 100644 --- a/packages/sdk/src/utils/test/index.test.ts +++ b/packages/sdk/src/utils/test/index.test.ts @@ -168,49 +168,57 @@ describe("createTailorDBHook", () => { }); }); - describe("create hook on a top-level field", () => { - test("invokes the create hook with value, full data, and the unauthenticated user", () => { - const seen: { value: unknown; data: unknown; userId: string }[] = []; + describe("type-level create hook", () => { + test("invokes the create hook and applies field overrides", () => { + const seen: { input: unknown; invoker: unknown }[] = []; const type = db.type("Order", { total: db.float(), tax: db.float() }).hooks({ - tax: { - create: ({ value, data, user }) => { - seen.push({ value, data, userId: user.id }); - return (data as { total: number }).total * 0.1; - }, + create: ({ input, invoker }) => { + seen.push({ input, invoker }); + return { tax: (input as { total: number }).total * 0.1 }; }, }); const result = createTailorDBHook(type)({ total: 100, tax: undefined }); expect(result.tax).toBe(10); expect(seen).toEqual([ { - value: undefined, - data: { total: 100, tax: undefined }, - userId: "00000000-0000-0000-0000-000000000000", + input: { total: 100, tax: undefined }, + invoker: null, }, ]); }); - test("normalizes a Date returned from the create hook to an ISO string", () => { + test("normalizes a Date returned from the type hook to an ISO string", () => { const fixed = new Date("2026-04-15T00:00:00.000Z"); const type = db .type("Test", { createdAt: db.datetime() }) - .hooks({ createdAt: { create: () => fixed } }); + .hooks({ create: () => ({ createdAt: fixed }) }); expect(createTailorDBHook(type)({}).createdAt).toBe("2026-04-15T00:00:00.000Z"); }); + test("shares the same now timestamp between field-level and type-level hooks", () => { + let fieldNow: Date | undefined; + let typeNow: Date | undefined; + const type = db + .type("Test", { + createdAt: db.datetime().hooks({ create: ({ now }) => (fieldNow = now) }), + label: db.string(), + }) + .hooks({ create: ({ now }) => ((typeNow = now), { label: "x" }) }); + createTailorDBHook(type)({}); + expect(fieldNow).toBeInstanceOf(Date); + expect(typeNow).toBe(fieldNow); + }); + test("does not invoke a hook that only defines update (createTailorDBHook is create-only)", () => { let updateCalled = false; const type = db.type("Test", { updatedAt: db.datetime() }).hooks({ - updatedAt: { - update: () => { - updateCalled = true; - return new Date(); - }, + update: () => { + updateCalled = true; + return { updatedAt: new Date() }; }, }); const result = createTailorDBHook(type)({ updatedAt: "2026-01-01T00:00:00.000Z" }); expect(updateCalled).toBe(false); - // Falls through to plain passthrough expect(result.updatedAt).toBe("2026-01-01T00:00:00.000Z"); }); }); diff --git a/packages/sdk/src/utils/test/index.ts b/packages/sdk/src/utils/test/index.ts index 1586e2f221..5288005cc8 100644 --- a/packages/sdk/src/utils/test/index.ts +++ b/packages/sdk/src/utils/test/index.ts @@ -1,9 +1,8 @@ -import type { output, TailorUser } from "#/configure/index"; +import type { output } from "#/configure/index"; import type { TailorDBType } from "#/configure/services/tailordb/schema"; import type { TailorField } from "#/configure/types/type"; import type { StandardSchemaV1 } from "@standard-schema/spec"; -export { WORKFLOW_TEST_ENV_KEY } from "#/configure/services/workflow/job"; export { setupTailordbMock, setupTailorErrorsMock, @@ -13,15 +12,6 @@ export { createImportMain, } from "./mock"; -/** Represents an unauthenticated user in the Tailor platform. */ -export const unauthenticatedTailorUser = { - id: "00000000-0000-0000-0000-000000000000", - type: "", - workspaceId: "00000000-0000-0000-0000-000000000000", - attributes: null, - attributeList: [], -} as const satisfies TailorUser; - /** * Creates a hook function that processes TailorDB type fields * - Uses existing id from data if provided, otherwise generates UUID for id fields @@ -34,12 +24,12 @@ export const unauthenticatedTailorUser = { // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createTailorDBHook>(type: T) { return (data: unknown) => { - return Object.entries(type.fields).reduce( + const now = new Date(); + const hooked = Object.entries(type.fields).reduce( (hooked, [key, value]) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const field = value as TailorField; if (key === "id") { - // Use existing id from data if provided, otherwise generate new UUID const existingId = data && typeof data === "object" ? (data as Record)[key] : undefined; hooked[key] = existingId ?? crypto.randomUUID(); @@ -49,8 +39,6 @@ export function createTailorDBHook>(type: T) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const nestedHook = createTailorDBHook({ fields: field.fields } as any); if (field.metadata.array) { - // For nested array fields, recurse per element and pass through non-array values - // (e.g. null/undefined for optional fields) so validation sees the original value. hooked[key] = Array.isArray(nestedValue) ? nestedValue.map((item) => nestedHook(item)) : nestedValue; @@ -60,8 +48,9 @@ export function createTailorDBHook>(type: T) { } else if (field.metadata.hooks?.create) { hooked[key] = field.metadata.hooks.create({ value: (data as Record)[key], - data: data, - user: unauthenticatedTailorUser, + oldValue: null, + invoker: null, + now, }); if (hooked[key] instanceof Date) { hooked[key] = hooked[key].toISOString(); @@ -72,7 +61,25 @@ export function createTailorDBHook>(type: T) { return hooked; }, {} as Record, - ) as Partial>; + ); + + // oxlint-disable-next-line typescript/no-unnecessary-condition -- metadata absent in recursive nested calls + if (type.metadata?.typeHook?.create) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + const overrides = (type.metadata.typeHook.create as Function)({ + input: data, + oldRecord: null, + invoker: null, + now, + }); + if (overrides && typeof overrides === "object") { + for (const [key, value] of Object.entries(overrides as Record)) { + hooked[key] = value instanceof Date ? value.toISOString() : value; + } + } + } + + return hooked as Partial>; }; } @@ -98,7 +105,7 @@ export function createStandardSchema>( const result = schemaType.parse({ value: hooked, data: hooked, - user: unauthenticatedTailorUser, + invoker: null, }); if (result.issues) { return result; diff --git a/packages/sdk/src/utils/test/internal.ts b/packages/sdk/src/utils/test/internal.ts index 275a0433b4..3ab767429b 100644 --- a/packages/sdk/src/utils/test/internal.ts +++ b/packages/sdk/src/utils/test/internal.ts @@ -1,3 +1,4 @@ +import { stripTailorDBTypeBuilderHelpers } from "#/parser/service/tailordb/builder-helpers"; /** * Internal test utilities for SDK development. * These are NOT exported to library users. @@ -15,7 +16,7 @@ export function toSchemaOutput( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accept any db.type() result for testing type: any, ): TailorDBTypeSchemaOutput { - const parsed = TailorDBTypeSchema.safeParse(type); + const parsed = TailorDBTypeSchema.safeParse(stripTailorDBTypeBuilderHelpers(type)); if (!parsed.success) { throw new Error(`Failed to parse type ${type.name}: ${parsed.error.message}`); } diff --git a/packages/sdk/src/utils/test/mock.ts b/packages/sdk/src/utils/test/mock.ts index f599eb5a30..660b979c27 100644 --- a/packages/sdk/src/utils/test/mock.ts +++ b/packages/sdk/src/utils/test/mock.ts @@ -1,7 +1,7 @@ import * as path from "node:path"; import { pathToFileURL } from "node:url"; import type { ContextInvoker } from "#/runtime/context"; -import type { TailorInvoker } from "#/runtime/types"; +import type { TailorPrincipal } from "#/runtime/types"; type MainFunction = (args: Record) => unknown | Promise; type QueryResolver = (query: string, params: unknown[]) => unknown[]; @@ -134,9 +134,9 @@ export function setupWorkflowMock(handler: JobHandler): { * Sets up a mock for `globalThis.tailor.context.getInvoker` used in bundled * resolver/executor/workflow tests. * @deprecated With the `tailor-runtime` environment from `@tailor-platform/sdk/vitest`, drive the invoker via `vi.spyOn(globalThis.tailor.context, "getInvoker").mockReturnValue(...)` for bundled tests, or pass `invoker` directly to `.body()` when unit-testing resolvers/executors/workflow jobs against the TypeScript source. - * @param invoker - The `TailorInvoker` value to return, or `null` for anonymous. + * @param invoker - The `TailorPrincipal` value to return, or `null` for anonymous. */ -export function setupInvokerMock(invoker: TailorInvoker): void { +export function setupInvokerMock(invoker: TailorPrincipal | null): void { const raw: ContextInvoker | null = invoker ? { id: invoker.id, diff --git a/packages/sdk/src/vitest/index.ts b/packages/sdk/src/vitest/index.ts index c3dedb15a5..98548a1dfb 100644 --- a/packages/sdk/src/vitest/index.ts +++ b/packages/sdk/src/vitest/index.ts @@ -70,4 +70,5 @@ export { mockAigateway, } from "./mock"; +export { runWorkflowLocally, type RunWorkflowLocallyOptions } from "./workflow-local"; export { createKyselyMock, type KyselyMock, type ExecutedQuery } from "./mock-kysely"; diff --git a/packages/sdk/src/vitest/mock.test.ts b/packages/sdk/src/vitest/mock.test.ts index e2edaaf97e..837581d69f 100644 --- a/packages/sdk/src/vitest/mock.test.ts +++ b/packages/sdk/src/vitest/mock.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { createWorkflowJob, WORKFLOW_TEST_ENV_KEY } from "../configure/services/workflow/job"; +import { createWorkflowJob } from "../configure/services/workflow/job"; import { createWorkflow } from "../configure/services/workflow/workflow"; import { mockTailordb, @@ -14,6 +14,7 @@ import { cleanupMocks, RUNTIME_FLAG_KEY, } from "./mock"; +import { runWorkflowLocally } from "./workflow-local"; describe("mock", () => { beforeEach(() => { @@ -116,10 +117,10 @@ describe("mock", () => { vi.unstubAllEnvs(); }); - test("records triggered jobs", () => { + test("records triggered jobs even when no handler is configured", () => { using wf = mockWorkflow(); const trigger = (globalThis as any).tailor.workflow.triggerJobFunction; - trigger("my-job", { key: "value" }); + expect(() => trigger("my-job", { key: "value" })).toThrow(/No workflow job mock/); expect(wf.triggeredJobs).toEqual([{ jobName: "my-job", args: { key: "value" } }]); }); @@ -159,113 +160,243 @@ describe("mock", () => { test("reset clears all state", () => { using wf = mockWorkflow(); const trigger = (globalThis as any).tailor.workflow.triggerJobFunction; - trigger("job", {}); + expect(() => trigger("job", {})).toThrow(/No workflow job mock/); wf.reset(); expect(wf.triggeredJobs).toHaveLength(0); }); - test("setEnv exposes env to job bodies via .trigger()", async () => { + test("setEnv exposes env to job bodies via runWorkflowLocally()", async () => { using wf = mockWorkflow(); const captureEnv = createWorkflowJob({ name: "capture-env", body: (_input: undefined, ctx) => ctx.env, }); + const workflow = createWorkflow({ name: "capture-env-workflow", mainJob: captureEnv }); wf.setEnv({ STAGE: "test", REGION: "asia" }); - const env = await captureEnv.trigger(); + const env = await runWorkflowLocally(workflow); expect(env).toEqual({ STAGE: "test", REGION: "asia" }); }); + test("runWorkflowLocally env option overrides and restores the mock env", async () => { + using wf = mockWorkflow(); + const captureEnv = createWorkflowJob({ + name: "capture-env-option", + body: (_input: undefined, ctx) => ctx.env, + }); + const workflow = createWorkflow({ name: "capture-env-option-workflow", mainJob: captureEnv }); + + wf.setEnv({ STAGE: "from-mock" }); + + expect( + await runWorkflowLocally(workflow, undefined, { env: { STAGE: "from-option" } }), + ).toEqual({ STAGE: "from-option" }); + expect(await runWorkflowLocally(workflow)).toEqual({ STAGE: "from-mock" }); + }); + test("reset clears env back to {}", async () => { using wf = mockWorkflow(); const captureEnv = createWorkflowJob({ name: "capture-env-reset", body: (_input: undefined, ctx) => ctx.env, }); + const workflow = createWorkflow({ name: "capture-env-reset-workflow", mainJob: captureEnv }); wf.setEnv({ STAGE: "test" }); wf.reset(); - expect(await captureEnv.trigger()).toEqual({}); + expect(await runWorkflowLocally(workflow)).toEqual({}); }); - describe("backward-compat: deprecated WORKFLOW_TEST_ENV_KEY env-var", () => { - test("setEnv takes priority over the env-var", async () => { - using wf = mockWorkflow(); - const captureEnv = createWorkflowJob({ - name: "capture-env-compat-priority", - body: (_input: undefined, ctx) => ctx.env, - }); - - vi.stubEnv(WORKFLOW_TEST_ENV_KEY, JSON.stringify({ STAGE: "fallback" })); - wf.setEnv({ STAGE: "from-setenv" }); - - expect(await captureEnv.trigger()).toEqual({ STAGE: "from-setenv" }); + test("ignores the legacy TAILOR_TEST_WORKFLOW_ENV env-var", async () => { + const captureEnv = createWorkflowJob({ + name: "capture-env-ignore-legacy-env", + body: (_input: undefined, ctx) => ctx.env, }); - - test("env-var is used when setEnv has not been called", async () => { - const captureEnv = createWorkflowJob({ - name: "capture-env-compat-fallback", - body: (_input: undefined, ctx) => ctx.env, - }); - - vi.stubEnv(WORKFLOW_TEST_ENV_KEY, JSON.stringify({ STAGE: "from-env-var" })); - - expect(await captureEnv.trigger()).toEqual({ STAGE: "from-env-var" }); + const workflow = createWorkflow({ + name: "capture-env-ignore-legacy-env-workflow", + mainJob: captureEnv, }); - test("throws when the env-var is valid JSON but not an object", async () => { - using _wf = mockWorkflow(); - const captureEnv = createWorkflowJob({ - name: "capture-env-compat-nonobject", - body: (_input: undefined, ctx) => ctx.env, - }); + vi.stubEnv("TAILOR_TEST_WORKFLOW_ENV", JSON.stringify({ STAGE: "from-env-var" })); - vi.stubEnv(WORKFLOW_TEST_ENV_KEY, "42"); - - await expect(captureEnv.trigger()).rejects.toThrow(/must be a JSON object/); - }); + expect(await runWorkflowLocally(workflow)).toEqual({}); }); }); describe("default workflow runtime (no mockWorkflow needed)", () => { - test("a job's .trigger() runs its real body", async () => { + test("a job's .trigger() requires a configured workflow mock", () => { const double = createWorkflowJob({ name: "default-runtime-double", body: (input: { n: number }) => ({ doubled: input.n * 2 }), }); - expect(await double.trigger({ n: 21 })).toEqual({ doubled: 42 }); + expect(() => double.trigger({ n: 21 })).toThrow(/No workflow job mock/); }); - test("workflow.mainJob.trigger() runs the whole chain", async () => { + test("workflow.trigger() returns an execution id without running the main job", async () => { + let bodyRan = false; + const main = createWorkflowJob({ + name: "default-runtime-main-trigger", + body: () => { + bodyRan = true; + return { ok: true }; + }, + }); + const workflow = createWorkflow({ name: "default-runtime-trigger-wf", mainJob: main }); + + await expect(workflow.trigger(undefined)).resolves.toBe( + "00000000-0000-4000-8000-000000000000", + ); + expect(bodyRan).toBe(false); + }); + + test("workflow.trigger() validates args in the default runtime", async () => { + const main = createWorkflowJob({ + name: "default-runtime-trigger-args", + body: (input: { when: string }) => ({ when: input.when }), + }); + const workflow = createWorkflow({ name: "default-runtime-trigger-args-wf", mainJob: main }); + + await expect(workflow.trigger({ when: new Date() } as never)).rejects.toThrow( + /Date instance/, + ); + }); + + test("runWorkflowLocally() runs the whole chain", async () => { const inner = createWorkflowJob({ name: "default-runtime-inner", - body: (input: { n: number }) => ({ n: input.n + 1 }), + body: async (input: { n: number }) => ({ n: input.n + 1 }), }); const main = createWorkflowJob({ name: "default-runtime-main", body: async (input: { n: number }) => { - const a = await inner.trigger({ n: input.n }); - const b = await inner.trigger({ n: a.n }); + const a = inner.trigger({ n: input.n }); + const b = inner.trigger({ n: a.n }); return { total: b.n }; }, }); const workflow = createWorkflow({ name: "default-runtime-wf", mainJob: main }); - expect(await workflow.mainJob.trigger({ n: 0 })).toEqual({ total: 2 }); + expect(await runWorkflowLocally(workflow, { n: 0 })).toEqual({ total: 2 }); }); - test("the JSON boundary rejects a non-serializable trigger result", async () => { + test("runWorkflowLocally() clones cached trigger results on replay", async () => { + const mutable = createWorkflowJob({ + name: "default-runtime-mutable", + body: () => ({ items: [] as string[] }), + }); + const step = createWorkflowJob({ + name: "default-runtime-step", + body: () => ({ ok: true }), + }); + const main = createWorkflowJob({ + name: "default-runtime-mutation-main", + body: () => { + const result = mutable.trigger(); + result.items.push("x"); + step.trigger(); + return result; + }, + }); + const workflow = createWorkflow({ + name: "default-runtime-mutation-wf", + mainJob: main, + }); + + expect(await runWorkflowLocally(workflow)).toEqual({ items: ["x"] }); + }); + + test("runWorkflowLocally() hides replay signals from user catch blocks", async () => { + const inner = createWorkflowJob({ + name: "default-runtime-caught-inner", + body: async () => ({ ok: true }), + }); + const main = createWorkflowJob({ + name: "default-runtime-caught-main", + body: () => { + try { + return inner.trigger(); + } catch { + return { ok: false }; + } + }, + }); + const workflow = createWorkflow({ + name: "default-runtime-caught-wf", + mainJob: main, + }); + + expect(await runWorkflowLocally(workflow)).toEqual({ ok: true }); + }); + + test("runWorkflowLocally() replays failed trigger errors into user catch blocks once", async () => { + let attempts = 0; + const inner = createWorkflowJob({ + name: "default-runtime-failing-inner", + body: async () => { + attempts += 1; + throw new Error("inner failed"); + }, + }); + const main = createWorkflowJob({ + name: "default-runtime-failing-main", + body: () => { + try { + inner.trigger(); + return { handled: false, message: "" }; + } catch (cause) { + return { handled: true, message: (cause as Error).message }; + } + }, + }); + const workflow = createWorkflow({ + name: "default-runtime-failing-wf", + mainJob: main, + }); + + expect(await runWorkflowLocally(workflow)).toEqual({ + handled: true, + message: "inner failed", + }); + expect(attempts).toBe(1); + }); + + test("runWorkflowLocally() validates nested workflow trigger args", async () => { + const innerMain = createWorkflowJob({ + name: "default-runtime-nested-workflow-inner", + body: (_input: { when: string }) => ({ ok: true }), + }); + const innerWorkflow = createWorkflow({ + name: "default-runtime-nested-workflow", + mainJob: innerMain, + }); + const main = createWorkflowJob({ + name: "default-runtime-nested-workflow-main", + body: async () => { + await innerWorkflow.trigger({ when: new Date() } as never); + return { ok: true }; + }, + }); + const workflow = createWorkflow({ + name: "default-runtime-nested-workflow-wf", + mainJob: main, + }); + + await expect(runWorkflowLocally(workflow)).rejects.toThrow(/Date instance/); + }); + + test("runWorkflowLocally() rejects a non-serializable trigger result", async () => { const bad = createWorkflowJob({ name: "default-runtime-bad", body: () => ({ when: new Date() }) as never, }); + const workflow = createWorkflow({ name: "default-runtime-bad-wf", mainJob: bad }); - await expect(bad.trigger()).rejects.toThrow(/Date instance/); + await expect(runWorkflowLocally(workflow)).rejects.toThrow(/Date instance/); }); }); @@ -452,19 +583,28 @@ describe("mock", () => { test("records calls with method, args, namespace", async () => { using idp = mockIdp(); const client = new (globalThis as any).tailor.idp.Client({ namespace: "ns" }); - await client.user("u-1"); - expect(idp.calls).toEqual([{ method: "user", args: ["u-1"], namespace: "ns" }]); + await client.user("11111111-1111-4111-8111-111111111111"); + expect(idp.calls).toEqual([ + { method: "user", args: ["11111111-1111-4111-8111-111111111111"], namespace: "ns" }, + ]); }); test("enqueueResults provides ordered responses", async () => { using idp = mockIdp(); - idp.enqueueResults({ id: "u-1", name: "alice", disabled: false }, true); + idp.enqueueResults( + { id: "11111111-1111-4111-8111-111111111111", name: "alice", disabled: false }, + true, + ); const client = new (globalThis as any).tailor.idp.Client({ namespace: "ns" }); - const user = await client.user("u-1"); - expect(user).toEqual({ id: "u-1", name: "alice", disabled: false }); + const user = await client.user("11111111-1111-4111-8111-111111111111"); + expect(user).toEqual({ + id: "11111111-1111-4111-8111-111111111111", + name: "alice", + disabled: false, + }); - const deleted = await client.deleteUser("u-1"); + const deleted = await client.deleteUser("11111111-1111-4111-8111-111111111111"); expect(deleted).toBe(true); }); @@ -473,7 +613,7 @@ describe("mock", () => { idp.setResolver((method) => { if (method === "users") return { - users: [{ id: "u-1", name: "bob", disabled: false }], + users: [{ id: "11111111-1111-4111-8111-111111111111", name: "bob", disabled: false }], nextPageToken: null, totalCount: 1, }; @@ -488,7 +628,7 @@ describe("mock", () => { test("reset clears state", async () => { using idp = mockIdp(); const client = new (globalThis as any).tailor.idp.Client({ namespace: "ns" }); - await client.user("u-1"); + await client.user("11111111-1111-4111-8111-111111111111"); idp.reset(); expect(idp.calls).toHaveLength(0); }); @@ -546,48 +686,9 @@ describe("mock", () => { expect(file.calls).toHaveLength(0); }); - test("openDownloadStream rejects raw bytes to guide callers to structured chunks", async () => { - using file = mockFile(); - file.enqueueResult(new Uint8Array([1, 2, 3])); - await expect( - (globalThis as any).tailordb.file.openDownloadStream("ns", "T", "f", "r"), - ).rejects.toThrow(/iterable of StreamValue items/); - }); - - test("openDownloadStream rejects non-StreamValue elements yielded by the iterable", async () => { - using file = mockFile(); - // Uint8Array[] is iterable but its elements aren't StreamValue items. - file.enqueueResult([new Uint8Array([1]), new Uint8Array([2])]); - const stream = await (globalThis as any).tailordb.file.openDownloadStream( - "ns", - "T", - "f", - "r", - ); - await expect(stream.next()).rejects.toThrow(/StreamValue/); - }); - - test("openDownloadStream yields the enqueued StreamValue sequence", async () => { - using file = mockFile(); - const bytes = new Uint8Array([1, 2, 3]); - const sequence = [ - { - type: "metadata" as const, - metadata: { contentType: "application/octet-stream", fileSize: 3, sha256sum: "h" }, - }, - { type: "chunk" as const, data: bytes, position: 0 }, - { type: "complete" as const }, - ]; - file.enqueueResult(sequence); - const stream = await (globalThis as any).tailordb.file.openDownloadStream( - "ns", - "T", - "f", - "r", - ); - const chunks: unknown[] = []; - for await (const chunk of stream) chunks.push(chunk); - expect(chunks).toEqual(sequence); + test("does not install the removed openDownloadStream file mock", () => { + using _file = mockFile(); + expect("openDownloadStream" in (globalThis as any).tailordb.file).toBe(false); }); test("default fallback is cloned so test mutations cannot leak across tests", async () => { diff --git a/packages/sdk/src/vitest/mock.ts b/packages/sdk/src/vitest/mock.ts index f2a3e88763..cbd369baed 100644 --- a/packages/sdk/src/vitest/mock.ts +++ b/packages/sdk/src/vitest/mock.ts @@ -23,15 +23,10 @@ */ import { type Mock, vi } from "vitest"; -import { - getRegisteredJob, - getRegisteredWorkflow, - TRIGGER_DEFAULT, -} from "#/configure/services/workflow/registry"; +import { TRIGGER_DEFAULT } from "#/configure/services/workflow/registry"; import { assertDefined } from "#/utils/assert"; import { platformSerialize } from "#/utils/test/platform-serialize"; import { - buildJobContext, clearWorkflowTestEnv, writeWorkflowTestEnv, } from "../configure/services/workflow/test-env-key"; @@ -45,6 +40,7 @@ import type { AIGatewayName, AuthConnectionTokenResult, ConnectionName, + UUIDString, } from "@tailor-platform/sdk"; export { RUNTIME_FLAG_KEY } from "./globals"; @@ -351,26 +347,22 @@ export function mockWorkflow() { const root = tailorRoot(); const prev = root.workflow; - // Default impls (also restored by reset): run the registered body by name so a - // `.trigger()` with no handler/result executes the real job locally. - const defaultTriggerJob = (jobName: string, args?: unknown): unknown => { - const body = getRegisteredJob(jobName); - return body ? body(args, buildJobContext()) : null; + const defaultTriggerJob = (jobName: string, _args?: unknown): unknown => { + throw new Error( + `No workflow job mock for "${jobName}". Call mockWorkflow().setJobHandler(...) or enqueueResult(...), or use runWorkflowLocally() for local workflow execution.`, + ); }; const defaultTriggerWorkflow = async ( - workflowName: string, - args?: unknown, + _workflowName: string, + _args?: unknown, _options?: TriggerWorkflowOptions, ): Promise => { - const wf = getRegisteredWorkflow(workflowName); - if (wf) await installedTriggerJobFunction(wf.mainJobName, args); return TRIGGER_DEFAULT; }; const defaultResumeWorkflow = async (executionId: string): Promise => executionId; // Inner vi.fns hold the overridable behavior + call recording; the installed - // shims below cross the platform JSON boundary (serialize args + results) once - // so every path (default body, setJobHandler, enqueueResult) is covered. + // shims below cross the platform JSON boundary (serialize args + results) once. const triggerJobFunction = vi.fn(defaultTriggerJob); const triggerWorkflow = vi.fn(defaultTriggerWorkflow); const resumeWorkflow = vi.fn(defaultResumeWorkflow); @@ -497,7 +489,7 @@ export function mockWorkflow() { }) as SetWaitHandler, /** - * Set the `env` passed to job bodies invoked via `createWorkflowJob().trigger()`. + * Set the `env` passed to job bodies invoked via `runWorkflowLocally()`. * Cleared on dispose / reset. * @param env - Env passed to job bodies. */ @@ -718,23 +710,29 @@ export function mockAuthconnection() { const IDP_DEFAULTS: Record = { users: { users: [], nextPageToken: null, totalCount: 0 }, - user: { id: "mock-id", name: "mock-user", disabled: false, mfaEnrolled: false, mfaFactorIds: [] }, + user: { + id: "123e4567-e89b-12d3-a456-426614174000", + name: "mock-user", + disabled: false, + mfaEnrolled: false, + mfaFactorIds: [], + }, userByName: { - id: "mock-id", + id: "123e4567-e89b-12d3-a456-426614174000", name: "mock-user", disabled: false, mfaEnrolled: false, mfaFactorIds: [], }, createUser: { - id: "mock-id", + id: "123e4567-e89b-12d3-a456-426614174000", name: "mock-user", disabled: false, mfaEnrolled: false, mfaFactorIds: [], }, updateUser: { - id: "mock-id", + id: "123e4567-e89b-12d3-a456-426614174000", name: "mock-user", disabled: false, mfaEnrolled: false, @@ -755,7 +753,7 @@ const IDP_DEFAULTS: Record = { * test("resolver-based", async () => { * using idp = mockIdp(); * idp.setResolver((method) => - * method === "user" ? { id: "u-1", name: "alice", disabled: false } : null, + * method === "user" ? { id: "11111111-1111-4111-8111-111111111111", name: "alice", disabled: false } : null, * ); * // … * }); @@ -788,11 +786,11 @@ export function mockIdp() { ) { const namespace = config.namespace; this.users = async (options?: unknown) => handle("users", [options], namespace); - this.user = async (userId: string) => handle("user", [userId], namespace); + this.user = async (userId: UUIDString) => handle("user", [userId], namespace); this.userByName = async (name: string) => handle("userByName", [name], namespace); this.createUser = async (input: unknown) => handle("createUser", [input], namespace); this.updateUser = async (input: unknown) => handle("updateUser", [input], namespace); - this.deleteUser = async (userId: string) => handle("deleteUser", [userId], namespace); + this.deleteUser = async (userId: UUIDString) => handle("deleteUser", [userId], namespace); this.sendPasswordResetEmail = async (input: unknown) => handle("sendPasswordResetEmail", [input], namespace); this.unenrollMfa = async (input: unknown) => handle("unenrollMfa", [input], namespace); @@ -800,21 +798,21 @@ export function mockIdp() { users(options?: { first?: number; after?: string; - query?: { ids?: string[]; names?: string[] }; + query?: { ids?: UUIDString[]; names?: string[] }; }): Promise<{ users: IdpUser[]; nextPageToken: string | null; totalCount: number }>; - user(userId: string): Promise; + user(userId: UUIDString): Promise; userByName(name: string): Promise; createUser(input: { name: string; password?: string; disabled?: boolean }): Promise; updateUser(input: { - id: string; + id: UUIDString; name?: string; password?: string; clearPassword?: boolean; disabled?: boolean; }): Promise; - deleteUser(userId: string): Promise; - sendPasswordResetEmail(input: { userId: string; redirectUri: string }): Promise; - unenrollMfa(input: { userId: string; mfaFactorId: string }): Promise; + deleteUser(userId: UUIDString): Promise; + sendPasswordResetEmail(input: { userId: UUIDString; redirectUri: string }): Promise; + unenrollMfa(input: { userId: UUIDString; mfaFactorId: string }): Promise; }; root.idp = { Client }; @@ -981,83 +979,6 @@ const FILE_DEFAULTS: Record = { uploadStream: { metadata: { fileSize: 0, sha256sum: "" } }, }; -type FileStream = AsyncIterableIterator & { close(): Promise }; - -function toFileStream(value: unknown): FileStream { - if ( - value !== null && - typeof value === "object" && - Symbol.asyncIterator in value && - typeof (value as { close?: unknown }).close === "function" - ) { - return value as FileStream; - } - if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { - throw new TypeError( - "openDownloadStream expects an iterable of StreamValue items " + - '(e.g. [{ type: "chunk", data, position }, { type: "complete" }]); ' + - "got raw bytes. Wrap the bytes in a structured chunk first.", - ); - } - if ( - value !== null && - typeof value === "object" && - (Symbol.iterator in value || Symbol.asyncIterator in value) - ) { - const source = value as Iterable | AsyncIterable; - const inner = - Symbol.asyncIterator in source - ? (source as AsyncIterable)[Symbol.asyncIterator]() - : (source as Iterable)[Symbol.iterator](); - const stream: FileStream = { - async next() { - const r = await inner.next(); - if (!r.done) { - assertStreamValue(r.value); - } - return r.done ? { done: true as const, value: undefined } : r; - }, - async close() {}, - [Symbol.asyncIterator]() { - return stream; - }, - }; - return stream; - } - const empty: FileStream = { - async next() { - return { done: true as const, value: undefined }; - }, - async close() {}, - [Symbol.asyncIterator]() { - return empty; - }, - }; - return empty; -} - -function assertStreamValue(v: unknown): void { - if (v === null || typeof v !== "object") { - throw new TypeError( - 'openDownloadStream expected a StreamValue item ({ type: "metadata" | "chunk" | "complete", ... }); ' + - `got ${typeof v === "object" ? "null" : typeof v}.`, - ); - } - if (v instanceof ArrayBuffer || ArrayBuffer.isView(v)) { - throw new TypeError( - "openDownloadStream expected a StreamValue item, got raw bytes. " + - 'Wrap the bytes in a structured chunk first (e.g. { type: "chunk", data, position }).', - ); - } - const type = (v as { type?: unknown }).type; - if (type !== "metadata" && type !== "chunk" && type !== "complete") { - throw new TypeError( - 'openDownloadStream expected a StreamValue item with type "metadata" | "chunk" | "complete"; ' + - `got ${JSON.stringify(type)}.`, - ); - } -} - /** * Acquire a disposable mock for `tailordb.file`. Restored on dispose. * @returns Disposable File mock control object @@ -1117,14 +1038,6 @@ export function mockFile() { async getMetadata(namespace: string, typeName: string, fieldName: string, recordId: string) { return handle("getMetadata", namespace, typeName, fieldName, recordId); }, - async openDownloadStream( - namespace: string, - typeName: string, - fieldName: string, - recordId: string, - ) { - return toFileStream(handle("openDownloadStream", namespace, typeName, fieldName, recordId)); - }, async downloadStream(namespace: string, typeName: string, fieldName: string, recordId: string) { const resolved = handle("downloadStream", namespace, typeName, fieldName, recordId); if (resolved != null) return resolved; diff --git a/packages/sdk/src/vitest/workflow-local.ts b/packages/sdk/src/vitest/workflow-local.ts new file mode 100644 index 0000000000..deda5c1d3c --- /dev/null +++ b/packages/sdk/src/vitest/workflow-local.ts @@ -0,0 +1,279 @@ +/* oxlint-disable typescript/no-explicit-any */ +import { TRIGGER_DEFAULT, getRegisteredJob } from "../configure/services/workflow/registry"; +import { + buildJobContext, + clearWorkflowTestEnv, + readWorkflowTestEnv, + writeWorkflowTestEnv, +} from "../configure/services/workflow/test-env-key"; +import { platformSerialize } from "../utils/test/platform-serialize"; +import type { Workflow, WorkflowJob } from "../configure/services/workflow"; +import type { TailorEnv } from "../runtime/types"; +import type { TailorWorkflowAPI } from "../runtime/workflow"; + +type AnyWorkflowJob = WorkflowJob; +type AnyWorkflow = Workflow; + +type WorkflowInput = + W["mainJob"] extends WorkflowJob ? I : never; +type WorkflowOutput = + W["mainJob"] extends WorkflowJob ? Awaited : never; + +type GlobalWithTailor = { + tailor?: { + workflow?: TailorWorkflowAPI; + }; +}; + +type TriggerRecord = { + jobName: string; + args: unknown; +} & ( + | { + status: "fulfilled"; + result: unknown; + } + | { + status: "rejected"; + error: unknown; + } +); + +interface LocalExecution { + records: TriggerRecord[]; + cursor: number; + pending?: PendingTrigger; +} + +class PendingTrigger { + constructor( + readonly jobName: string, + readonly args: unknown, + ) {} +} + +export interface RunWorkflowLocallyOptions { + /** Env passed to workflow job bodies during this local run. */ + env?: TailorEnv; +} + +/** + * Run a workflow's main job and dependent job triggers locally with real job bodies. + * + * Use this for local full-chain workflow tests. Regular `.trigger()` calls + * delegate to the platform workflow runtime and should be mocked with + * `mockWorkflow()` when you are not intentionally running the local chain. + * @param workflow - Workflow definition to run + * @returns The main job output + */ +export function runWorkflowLocally>>( + workflow: W, +): Promise>; +/** + * Run a no-input workflow locally with optional runner settings. + * @param workflow - Workflow definition to run + * @param args - Must be `undefined` for no-input workflows + * @param options - Local runner options + * @returns The main job output + */ +export function runWorkflowLocally>>( + workflow: W, + args: undefined, + options?: RunWorkflowLocallyOptions, +): Promise>; +/** + * Run a workflow locally with real job bodies. + * @param workflow - Workflow definition to run + * @param args - Arguments passed to the workflow's main job + * @param options - Local runner options + * @returns The main job output + */ +export function runWorkflowLocally( + workflow: W, + args: WorkflowInput, + options?: RunWorkflowLocallyOptions, +): Promise>; +export async function runWorkflowLocally( + workflow: W, + args?: WorkflowInput, + options?: RunWorkflowLocallyOptions, +): Promise> { + const root = globalThis as unknown as GlobalWithTailor; + const previousTailor = root.tailor; + const previousEnv = readWorkflowTestEnv(); + const hasPreviousEnv = previousEnv !== undefined; + const runner = createLocalJobRunner(); + + if (options?.env !== undefined) { + writeWorkflowTestEnv({ ...options.env }); + } + + root.tailor = { + ...previousTailor, + workflow: createLocalWorkflowRuntime(previousTailor?.workflow, runner.triggerJobFunction), + }; + + try { + return (await runner.runJob(workflow.mainJob.name, args)) as WorkflowOutput; + } finally { + if (previousTailor) { + root.tailor = previousTailor; + } else { + delete root.tailor; + } + + if (options?.env !== undefined) { + if (hasPreviousEnv) { + writeWorkflowTestEnv(previousEnv); + } else { + clearWorkflowTestEnv(); + } + } + } +} + +function createLocalJobRunner(): { + runJob: (name: string, args?: unknown) => Promise; + triggerJobFunction: (name: string, args?: unknown) => unknown; +} { + let activeExecution: LocalExecution | undefined; + + const triggerJobFunction = (jobName: string, args?: unknown): unknown => { + if (!activeExecution) { + throw new Error( + `Cannot trigger workflow job "${jobName}" outside runWorkflowLocally() job execution.`, + ); + } + if (activeExecution.pending) { + throw activeExecution.pending; + } + + const serializedArgs = platformSerialize(args); + const index = activeExecution.cursor; + activeExecution.cursor += 1; + + const cached = activeExecution.records[index]; + if (cached) { + assertSameTrigger(cached, jobName, serializedArgs); + if (cached.status === "rejected") { + throw cached.error; + } + return platformSerialize(cached.result); + } + + const pending = new PendingTrigger(jobName, serializedArgs); + activeExecution.pending = pending; + throw pending; + }; + + const runJob = async (name: string, args?: unknown): Promise => { + const body = getRegisteredJob(name); + if (!body) { + return null; + } + + const records: TriggerRecord[] = []; + + for (;;) { + const execution: LocalExecution = { records, cursor: 0 }; + const previousExecution = activeExecution; + activeExecution = execution; + + try { + const out = await body(platformSerialize(args), buildJobContext()); + if (execution.pending) { + await settlePendingTrigger(records, execution.pending, runJob); + continue; + } + if (execution.cursor !== records.length) { + throw new Error( + `Workflow job trigger sequence changed while replaying "${name}". Expected ${records.length} trigger(s), but replay reached ${execution.cursor}.`, + ); + } + return platformSerialize(out); + } catch (cause) { + const pending = cause instanceof PendingTrigger ? cause : execution.pending; + if (pending) { + await settlePendingTrigger(records, pending, runJob); + continue; + } + throw cause; + } finally { + activeExecution = previousExecution; + } + } + }; + + return { runJob, triggerJobFunction }; +} + +async function settlePendingTrigger( + records: TriggerRecord[], + pending: PendingTrigger, + runJob: (name: string, args?: unknown) => Promise, +): Promise { + try { + records.push({ + jobName: pending.jobName, + args: pending.args, + status: "fulfilled", + result: await runJob(pending.jobName, pending.args), + }); + } catch (error) { + records.push({ + jobName: pending.jobName, + args: pending.args, + status: "rejected", + error, + }); + } +} + +function assertSameTrigger(record: TriggerRecord, jobName: string, args: unknown): void { + if (record.jobName === jobName && JSON.stringify(record.args) === JSON.stringify(args)) { + return; + } + + throw new Error( + `Workflow job trigger sequence changed while replaying. Expected ${record.jobName}(${JSON.stringify(record.args)}), but got ${jobName}(${JSON.stringify(args)}).`, + ); +} + +function createLocalWorkflowRuntime( + previous: TailorWorkflowAPI | undefined, + triggerJobFunction: (name: string, args?: unknown) => unknown, +): TailorWorkflowAPI { + return { + triggerJobFunction, + triggerWorkflow: async (name, args, options) => { + if (previous) { + return await previous.triggerWorkflow(name, args, options); + } + platformSerialize(args); + return TRIGGER_DEFAULT; + }, + resumeWorkflow: async (executionId) => { + if (previous) { + return await previous.resumeWorkflow(executionId); + } + return executionId; + }, + wait: (key, payload) => { + if (previous) { + return previous.wait(key, payload); + } + throw new Error( + `No wait handler for "${key}". Acquire mockWorkflow() and call setWaitHandler(...).`, + ); + }, + resolve: async (executionId, key, callback) => { + if (previous) { + await previous.resolve(executionId, key, callback); + return; + } + throw new Error( + "No resolve handler. Acquire mockWorkflow() and call setResolveHandler(...).", + ); + }, + }; +} diff --git a/packages/sdk/src/vitest/workflow-runtime.ts b/packages/sdk/src/vitest/workflow-runtime.ts index e5f5b72654..7a6621da96 100644 --- a/packages/sdk/src/vitest/workflow-runtime.ts +++ b/packages/sdk/src/vitest/workflow-runtime.ts @@ -1,7 +1,8 @@ // Default `tailor.workflow` runner installed by the `tailor-runtime` environment. // Must stay free of `vitest` (`vi`): it loads via `./globals` in the environment // realm where `vi` is unavailable, hence relative imports only (no `@/` alias). -import { runRegisteredJob, runRegisteredWorkflow } from "../configure/services/workflow/registry"; +import { TRIGGER_DEFAULT } from "../configure/services/workflow/registry"; +import { platformSerialize } from "../utils/test/platform-serialize"; export interface DefaultWorkflowRuntime { triggerJobFunction: (name: string, args?: unknown) => unknown; @@ -17,8 +18,15 @@ export interface DefaultWorkflowRuntime { export function createDefaultWorkflowRuntime(): DefaultWorkflowRuntime { return { - triggerJobFunction: (name, args) => runRegisteredJob(name, args), - triggerWorkflow: (name, args) => runRegisteredWorkflow(name, args), + triggerJobFunction: (name) => { + throw new Error( + `No workflow job mock for "${name}". Acquire mockWorkflow() and call setJobHandler(...) or enqueueResult(...), or use runWorkflowLocally() for local workflow execution.`, + ); + }, + triggerWorkflow: async (_name, args) => { + platformSerialize(args); + return TRIGGER_DEFAULT; + }, resumeWorkflow: async (executionId: string): Promise => executionId, wait: (key: string): unknown => { throw new Error( diff --git a/packages/sdk/tsdown.config.ts b/packages/sdk/tsdown.config.ts index 6284d24851..fab1941287 100644 --- a/packages/sdk/tsdown.config.ts +++ b/packages/sdk/tsdown.config.ts @@ -1,14 +1,10 @@ -import { cpSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, rmSync } from "node:fs"; import path from "node:path"; import Sonda from "sonda/rolldown"; import { defineConfig, type TsdownPluginOption } from "tsdown"; import { entry } from "./scripts/build-entries.mjs"; import { loadYamlText } from "./scripts/yaml-text-plugin.mjs"; -const runtimeGlobalsBanner = '/// '; -const runtimeGlobalsBannerPattern = - /^\/\/\/ \r?\n/; - function copyErdViewerAssets(outDir: string): void { const source = path.resolve("src/cli/commands/tailordb/erd/viewer-assets"); const target = path.resolve(outDir, "cli/erd-viewer-assets"); @@ -16,24 +12,6 @@ function copyErdViewerAssets(outDir: string): void { cpSync(source, target, { recursive: true }); } -function stripBannerExceptConfigureEntry(outDir: string): void { - const root = path.resolve(outDir); - const keep = path.join(root, "configure", "index.d.mts"); - const walk = (current: string): void => { - for (const dirent of readdirSync(current, { withFileTypes: true })) { - const full = path.join(current, dirent.name); - if (dirent.isDirectory()) { - walk(full); - } else if (dirent.isFile() && dirent.name.endsWith(".d.mts") && full !== keep) { - const content = readFileSync(full, "utf-8"); - const cleaned = content.replace(runtimeGlobalsBannerPattern, ""); - if (cleaned !== content) writeFileSync(full, cleaned, "utf-8"); - } - } - }; - walk(root); -} - function yamlText() { return { name: "yaml-text", @@ -83,12 +61,15 @@ export default defineConfig([ sourcemap: true, // peer dependencies: prevent bundling, resolve at runtime. // `@tailor-platform/sdk` (self-name) is kept external so subpath entries can reference - // types like `ConnectionName` from the main entry instead of inlining them, letting a - // single `declare module "@tailor-platform/sdk"` augmentation narrow every entry point. + // types like `ConnectionName`/`MachineUserName` from the main entry instead of inlining + // them, letting a single `declare module "@tailor-platform/sdk"` augmentation narrow + // every entry point. deps: { neverBundle: externalDeps }, plugins: jsPlugins, onSuccess: (config) => { copyErdViewerAssets(config.outDir); + cpSync(path.resolve("src/cli/ts-hook.mjs"), path.join(config.outDir, "cli/ts-hook.mjs")); + cpSync(path.resolve("src/cli/ts-hook.d.mts"), path.join(config.outDir, "cli/ts-hook.d.mts")); }, }, { @@ -99,12 +80,6 @@ export default defineConfig([ }, unbundle: true, root: "src", - banner: { - dts: runtimeGlobalsBanner, - }, deps: { neverBundle: externalDeps }, - onSuccess: (config) => { - stripBannerExceptConfigureEntry(config.outDir); - }, }, ]); diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts index 0d3d2043f5..7b0f560b55 100644 --- a/packages/sdk/vitest.config.ts +++ b/packages/sdk/vitest.config.ts @@ -43,6 +43,13 @@ const integrationTestIncludes = [ "src/plugin/compat.test.ts", ]; +// The CLI plugin test exercises Windows-specific PATHEXT/`.cmd`/`.ps1` spawn +// branches, so it gets its own project ("unit-plugin", which the `unit*` glob +// still picks up on Linux) that a Windows CI job can run via `--project +// unit-plugin` — no separator-sensitive path filter needed. Excluded from the +// general unit split below so it does not run twice. +const pluginTestInclude = "src/cli/shared/plugin.test.ts"; + // Split unit tests by whether they mutate worker-global state. With // `isolate: false` a worker shares one module registry and one global object // across files, so per-file partial module mocks (e.g. `vi.mock("node:fs", ...)`) @@ -57,6 +64,8 @@ const classifyUnitTests = (): { isolated: string[]; shared: string[] } => { file.includes("/node_modules/") || file.includes("/__test_fixtures__/") || integrationTestFiles.has(file) || + // Carved into its own "unit-plugin" project (see below). + file === pluginTestInclude || // Self-contained nested vitest project with its own config. file.startsWith("src/vitest/integration/"); @@ -117,6 +126,16 @@ export default defineConfig({ include: sharedUnitTests, }, }, + { + extends: true, + test: { + // Carved out so a Windows CI job can run just the plugin test via + // `--project unit-plugin`; the `unit*` glob still runs it on Linux. + name: "unit-plugin", + include: [pluginTestInclude], + typecheck: { enabled: false }, + }, + }, { extends: true, test: { diff --git a/packages/tailor-proto/src/tailor/v1/http_adapter_resource_pb.js b/packages/tailor-proto/src/tailor/v1/http_adapter_resource_pb.js index 91f97ff034..f415ca6cf4 100644 --- a/packages/tailor-proto/src/tailor/v1/http_adapter_resource_pb.js +++ b/packages/tailor-proto/src/tailor/v1/http_adapter_resource_pb.js @@ -19,4 +19,3 @@ export const file_tailor_v1_http_adapter_resource = /*@__PURE__*/ */ export const HttpAdapterSchema = /*@__PURE__*/ messageDesc(file_tailor_v1_http_adapter_resource, 0); - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 767e5e4103..ef40c4f829 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@types/node': specifier: 24.13.2 version: 24.13.2 + eslint-plugin-zod: + specifier: 4.7.0 + version: 4.7.0(eslint@10.5.0(jiti@2.7.0))(oxlint@1.72.0(oxlint-tsgolint@0.24.0))(typescript@6.0.3)(zod@4.4.3) knip: specifier: 6.24.0 version: 6.24.0 @@ -72,6 +75,9 @@ importers: '@typescript/native-preview': specifier: 7.0.0-dev.20260621.1 version: 7.0.0-dev.20260621.1 + eslint-plugin-zod: + specifier: 4.7.0 + version: 4.7.0(eslint@10.5.0(jiti@2.7.0))(oxlint@1.72.0(oxlint-tsgolint@0.24.0))(typescript@6.0.3)(zod@4.4.3) graphql: specifier: 17.0.1 version: 17.0.1 @@ -98,13 +104,16 @@ importers: version: 6.0.3 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) llm-challenge: devDependencies: '@types/node': specifier: 24.13.2 version: 24.13.2 + eslint-plugin-zod: + specifier: 4.7.0 + version: 4.7.0(eslint@10.5.0(jiti@2.7.0))(oxlint@1.72.0(oxlint-tsgolint@0.24.0))(typescript@6.0.3)(zod@4.4.3) oxlint: specifier: 1.72.0 version: 1.72.0(oxlint-tsgolint@0.24.0) @@ -119,7 +128,7 @@ importers: version: 6.0.3 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) packages/create-sdk: dependencies: @@ -145,6 +154,9 @@ importers: '@types/node': specifier: 24.13.2 version: 24.13.2 + eslint-plugin-zod: + specifier: 4.7.0 + version: 4.7.0(eslint@10.5.0(jiti@2.7.0))(oxlint@1.72.0(oxlint-tsgolint@0.24.0))(typescript@6.0.3)(zod@4.4.3) oxlint: specifier: 1.72.0 version: 1.72.0(oxlint-tsgolint@0.24.0) @@ -180,7 +192,7 @@ importers: version: 6.0.3 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) packages/create-sdk/templates/generators: devDependencies: @@ -204,7 +216,7 @@ importers: version: 6.0.3 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) packages/create-sdk/templates/hello-world: devDependencies: @@ -291,7 +303,7 @@ importers: version: 6.0.3 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) packages/create-sdk/templates/static-web-site: devDependencies: @@ -336,7 +348,7 @@ importers: version: 6.0.3 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) packages/create-sdk/templates/workflow: devDependencies: @@ -366,7 +378,7 @@ importers: version: 6.0.3 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) packages/sdk: dependencies: @@ -433,6 +445,9 @@ importers: '@urql/core': specifier: 6.0.3 version: 6.0.3(graphql@17.0.1) + amaro: + specifier: 1.1.10 + version: 1.1.10 chalk: specifier: 5.6.2 version: 5.6.2 @@ -508,9 +523,6 @@ importers: ts-cron-validator: specifier: 1.1.5 version: 1.1.5 - tsx: - specifier: 4.22.5 - version: 4.22.5 type-fest: specifier: 5.7.0 version: 5.7.0 @@ -519,7 +531,7 @@ importers: version: 8.5.0 vite: specifier: ^6.0.0 || ^7.0.0 || ^8.0.0 - version: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0) + version: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0) xdg-basedir: specifier: 5.1.0 version: 5.1.0 @@ -551,6 +563,9 @@ importers: '@vitest/coverage-v8': specifier: 4.1.9 version: 4.1.9(vitest@4.1.9) + eslint-plugin-zod: + specifier: 4.7.0 + version: 4.7.0(eslint@10.5.0(jiti@2.7.0))(oxlint@1.72.0(oxlint-tsgolint@0.24.0))(typescript@6.0.3)(zod@4.4.3) oxfmt: specifier: 0.57.0 version: 0.57.0 @@ -571,7 +586,7 @@ importers: version: 6.0.3 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) zinfer: specifier: 0.2.3 version: 0.2.3(typescript@6.0.3)(zod@4.4.3) @@ -615,6 +630,9 @@ importers: '@types/semver': specifier: 7.7.1 version: 7.7.1 + eslint-plugin-zod: + specifier: 4.7.0 + version: 4.7.0(eslint@10.5.0(jiti@2.7.0))(oxlint@1.72.0(oxlint-tsgolint@0.24.0))(typescript@6.0.3)(zod@4.4.3) oxlint: specifier: 1.72.0 version: 1.72.0(oxlint-tsgolint@0.24.0) @@ -626,7 +644,7 @@ importers: version: 6.0.3 vitest: specifier: 4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) packages/tailor-proto: dependencies: @@ -870,18 +888,27 @@ packages: resolution: {integrity: sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==} engines: {node: '>=14.17.0'} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.0': resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.0': resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} @@ -1041,11 +1068,65 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint-zod/utils@2.3.0': + resolution: {integrity: sha512-6XEQLA5lDOOLnX05bgzDT3DyxlCGgLDFX3q0wYSSUSyVJj5WD+8wdlIAFY8kjatSDHWJQ+qFI/BdiDh9o50nBQ==} + engines: {node: ^20 || ^22 || >=24} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@graphql-typed-document-node/core@3.2.0': resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@inquirer/ansi@2.0.7': resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -1621,6 +1702,9 @@ packages: cpu: [x64] os: [win32] + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-project/types@0.137.0': resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} @@ -2008,43 +2092,80 @@ packages: resolution: {integrity: sha512-IfhLdXMjNaAt2Z/r0hAqMvH4UN/Eb6LAbnSHqF9Ay1EhQId8rFX58yIWXLUit6VjJTJAptnSPtO8nSu2/ggQsg==} engines: {node: '>=22.13'} - '@publint/pack@0.1.5': - resolution: {integrity: sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw==} + '@publint/pack@0.1.4': + resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} engines: {node: '>=18'} '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-android-arm64@1.1.4': resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-arm64@1.1.4': resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.1.4': resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-freebsd-x64@1.1.4': resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.1.4': resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2052,6 +2173,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.1.4': resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2059,6 +2187,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.1.4': resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2066,6 +2201,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.1.4': resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2073,6 +2215,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.1.4': resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2080,6 +2229,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-x64-musl@1.1.4': resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2087,23 +2243,46 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.1.4': resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.1.4': resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.1.4': resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.1.4': resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2169,12 +2348,18 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/madge@5.0.3': resolution: {integrity: sha512-NlQJd0qRAoyu+pawTDhLxkW940QT2dqASfwd2g/xEZu2F4Xjwa7TVRSPdbmZwUF1ygvAh0/nepeN7JjwEuOXCA==} @@ -2190,30 +2375,67 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@typescript-eslint/project-service@8.62.1': - resolution: {integrity: sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==} + '@typescript-eslint/project-service@8.61.0': + resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.62.1': - resolution: {integrity: sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==} + '@typescript-eslint/project-service@8.62.0': + resolution: {integrity: sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.62.1': - resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==} + '@typescript-eslint/scope-manager@8.62.0': + resolution: {integrity: sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.62.1': - resolution: {integrity: sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==} + '@typescript-eslint/tsconfig-utils@8.61.0': + resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.62.1': - resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==} + '@typescript-eslint/tsconfig-utils@8.62.0': + resolution: {integrity: sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.61.0': + resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.62.0': + resolution: {integrity: sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.61.0': + resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/typescript-estree@8.62.0': + resolution: {integrity: sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.62.0': + resolution: {integrity: sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.61.0': + resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.62.0': + resolution: {integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260621.1': @@ -2304,20 +2526,33 @@ packages: '@vitest/utils@4.1.9': resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} - '@vue/compiler-core@3.5.39': - resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} + '@vue/compiler-core@3.5.38': + resolution: {integrity: sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==} + + '@vue/compiler-dom@3.5.38': + resolution: {integrity: sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==} + + '@vue/compiler-sfc@3.5.38': + resolution: {integrity: sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==} - '@vue/compiler-dom@3.5.39': - resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} + '@vue/compiler-ssr@3.5.38': + resolution: {integrity: sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==} - '@vue/compiler-sfc@3.5.39': - resolution: {integrity: sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==} + '@vue/shared@3.5.38': + resolution: {integrity: sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==} - '@vue/compiler-ssr@3.5.39': - resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@vue/shared@3.5.39': - resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -2376,8 +2611,8 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} buffer@5.7.1: @@ -2403,8 +2638,8 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - chardet@2.2.0: - resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} @@ -2483,6 +2718,9 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} @@ -2576,8 +2814,8 @@ packages: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} - enhanced-resolve@5.24.1: - resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==} + enhanced-resolve@5.24.0: + resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} engines: {node: '>=10.13.0'} entities@7.0.1: @@ -2588,8 +2826,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@2.3.0: - resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} @@ -2599,20 +2837,69 @@ packages: engines: {node: '>=18'} hasBin: true + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + escodegen@2.1.0: resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} engines: {node: '>=6.0'} hasBin: true + eslint-plugin-zod@4.7.0: + resolution: {integrity: sha512-unziYvD8FGxhfL6rYuC3Cjdx/K3yMWSTv6t5uB9KEgzMajDXuQcp67/WDxdUiEFhGY1LlXJs03xQc+Pg6+vqrw==} + engines: {node: ^20 || ^22 || >=24} + peerDependencies: + eslint: ^9 || ^10 + oxlint: ^1.59.0 + zod: ^4 + peerDependenciesMeta: + eslint: + optional: true + oxlint: + optional: true + zod: + optional: true + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-visitor-keys@5.0.1: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -2631,24 +2918,30 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} - expect-type@1.4.0: - resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - exsolve@1.1.0: - resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.3: - resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -2669,6 +2962,10 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + filing-cabinet@5.5.1: resolution: {integrity: sha512-PzLBTChlVPn6LnNxF0KWs+XqPziVh3Sfmz/3TXOymHxu6a9yhrDcQn7YwgpcRM6mqhR2WHVGPR8RU4fmcF1IVA==} engines: {node: '>=18'} @@ -2678,6 +2975,17 @@ packages: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -2709,6 +3017,10 @@ packages: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -2763,6 +3075,10 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -2770,6 +3086,10 @@ packages: resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} engines: {node: ^22.18.0 || >=24.0.0} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + inflection@3.0.2: resolution: {integrity: sha512-+Bg3+kg+J6JUWn8J6bzFmOWkTQ6L/NHfDRSYU+EVvuKHDxUDHAXgqixHfVlzuBQaPOTac8hn43aPhMNk6rMe3g==} engines: {node: '>=18.0.0'} @@ -2789,10 +3109,18 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -2868,9 +3196,18 @@ packages: engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2879,6 +3216,9 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + knip@6.24.0: resolution: {integrity: sha512-PokLlgeEjLh1rAsB7ts+52wZ37HBr1nDhE6NNONwEaXdeZGCJOkP7ZlIAI2Gtu8xohquzTWy75bc/1diI9shQw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2945,6 +3285,10 @@ packages: resolution: {integrity: sha512-bwDaIOViTktE8kJLf9jP0p+H2/RDTlFFlc43Am2YgUsX22hI6Sq4RbzsrecwzY5y+MHTipOH7WsmWSEniePHWQ==} hasBin: true + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -3019,6 +3363,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash.truncate@4.4.2: resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} @@ -3101,11 +3449,14 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + nearley@2.20.1: resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} hasBin: true @@ -3130,6 +3481,10 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + ora@5.4.1: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} @@ -3175,12 +3530,20 @@ packages: vite-plus: optional: true + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + p-limit@7.3.0: resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} engines: {node: '>=20'} - package-manager-detector@1.7.0: - resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} parse-ms@2.1.0: resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==} @@ -3193,6 +3556,10 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3252,8 +3619,8 @@ packages: peerDependencies: postcss: ^8.2.9 - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.1.0: @@ -3265,6 +3632,10 @@ packages: engines: {node: '>=18'} hasBin: true + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + pretty-ms@7.0.1: resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} engines: {node: '>=10'} @@ -3278,6 +3649,10 @@ packages: engines: {node: '>=18'} hasBin: true + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -3355,6 +3730,11 @@ packages: vue-tsc: optional: true + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rolldown@1.1.4: resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3392,8 +3772,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.9.0: - resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} siginfo@2.0.0: @@ -3413,8 +3793,8 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} - smol-toml@1.7.0: - resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} sonda@0.13.1: @@ -3587,6 +3967,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-fest@5.7.0: resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} @@ -3601,8 +3985,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - unbash@4.0.2: - resolution: {integrity: sha512-8gwNZ29+0/3zmXw7ToIHZtg6wK37xnniRUdBt7B27xZxaxfgR5tGMaGHT0t0dLtBV9fXE7zurh0s6Z1DHVjfWg==} + unbash@4.0.1: + resolution: {integrity: sha512-1ajSo3813sDoVIHx4inJdUS4l5L2ic5cFiddemPiyjb/PZEoBAhFwHtbaEdRDFxbAKy7FCG7s5ww3/uCFawuIA==} engines: {node: '>=14'} unconfig-core@7.5.0: @@ -3619,16 +4003,19 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite@8.1.3: - resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.1.18 esbuild: 0.28.1 jiti: '>=1.21.0' less: ^4.0.0 @@ -3730,6 +4117,10 @@ packages: wonka@6.3.6: resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==} + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wsl-utils@0.3.1: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} @@ -3743,6 +4134,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -3924,7 +4319,7 @@ snapshots: '@changesets/format@0.1.0': dependencies: - package-manager-detector: 1.7.0 + package-manager-detector: 1.6.0 '@changesets/get-dependents-graph@3.0.0-next.6': dependencies: @@ -4000,6 +4395,12 @@ snapshots: '@discoveryjs/json-ext@1.1.0': {} + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.11.0': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -4012,6 +4413,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.0': dependencies: tslib: 2.8.1 @@ -4022,6 +4428,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 @@ -4105,10 +4516,65 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.7.0))': + dependencies: + eslint: 10.5.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint-zod/utils@2.3.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/utils': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + esquery: 1.7.0 + transitivePeerDependencies: + - eslint + - supports-color + - typescript + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + '@graphql-typed-document-node/core@3.2.0(graphql@17.0.1)': dependencies: graphql: 17.0.1 + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@inquirer/ansi@2.0.7': {} '@inquirer/checkbox@5.2.1(@types/node@24.13.2)': @@ -4156,7 +4622,7 @@ snapshots: '@inquirer/external-editor@3.0.3(@types/node@24.13.2)': dependencies: - chardet: 2.2.0 + chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: '@types/node': 24.13.2 @@ -4313,6 +4779,13 @@ snapshots: '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.3 + optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': dependencies: '@emnapi/core': 1.11.0 @@ -4531,6 +5004,8 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc@0.138.0': optional: true + '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.137.0': {} '@oxc-project/types@0.138.0': {} @@ -4730,50 +5205,91 @@ snapshots: '@pnpm/deps.graph-sequencer@1100.0.0': {} - '@publint/pack@0.1.5': - dependencies: - tinyexec: 1.2.4 + '@publint/pack@0.1.4': {} '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 + '@rolldown/binding-android-arm64@1.0.3': + optional: true + '@rolldown/binding-android-arm64@1.1.4': optional: true + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + '@rolldown/binding-darwin-arm64@1.1.4': optional: true + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + '@rolldown/binding-darwin-x64@1.1.4': optional: true + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + '@rolldown/binding-freebsd-x64@1.1.4': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.1.4': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + '@rolldown/binding-linux-arm64-musl@1.1.4': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.1.4': optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.1.4': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + '@rolldown/binding-linux-x64-gnu@1.1.4': optional: true + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + '@rolldown/binding-linux-x64-musl@1.1.4': optional: true + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + '@rolldown/binding-openharmony-arm64@1.1.4': optional: true + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + '@rolldown/binding-wasm32-wasi@1.1.4': dependencies: '@emnapi/core': 1.11.1 @@ -4781,9 +5297,15 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + '@rolldown/binding-win32-arm64-msvc@1.1.4': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + '@rolldown/binding-win32-x64-msvc@1.1.4': optional: true @@ -4839,10 +5361,14 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.9': {} '@types/jsesc@2.5.1': {} + '@types/json-schema@7.0.15': {} + '@types/madge@5.0.3': dependencies: '@types/node': 24.13.2 @@ -4857,27 +5383,47 @@ snapshots: '@types/semver@7.7.1': {} - '@typescript-eslint/project-service@8.62.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.61.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@5.9.3) - '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/tsconfig-utils@8.62.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.62.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@6.0.3) + '@typescript-eslint/types': 8.62.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.62.0': + dependencies: + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 + + '@typescript-eslint/tsconfig-utils@8.61.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/types@8.62.1': {} + '@typescript-eslint/tsconfig-utils@8.62.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/types@8.61.0': {} + + '@typescript-eslint/types@8.62.0': {} - '@typescript-eslint/typescript-estree@8.62.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.62.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@5.9.3) - '@typescript-eslint/types': 8.62.1 - '@typescript-eslint/visitor-keys': 8.62.1 + '@typescript-eslint/project-service': 8.61.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.5 @@ -4887,9 +5433,40 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.62.1': + '@typescript-eslint/typescript-estree@8.62.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.62.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@6.0.3) + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.62.1 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@6.0.3) + eslint: 10.5.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.61.0': + dependencies: + '@typescript-eslint/types': 8.61.0 + eslint-visitor-keys: 5.0.1 + + '@typescript-eslint/visitor-keys@8.62.0': + dependencies: + '@typescript-eslint/types': 8.62.0 eslint-visitor-keys: 5.0.1 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260621.1': @@ -4942,7 +5519,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) '@vitest/expect@4.1.9': dependencies: @@ -4953,13 +5530,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -4985,42 +5562,55 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@vue/compiler-core@3.5.39': + '@vue/compiler-core@3.5.38': dependencies: '@babel/parser': 7.29.7 - '@vue/shared': 3.5.39 + '@vue/shared': 3.5.38 entities: 7.0.1 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.5.39': + '@vue/compiler-dom@3.5.38': dependencies: - '@vue/compiler-core': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/compiler-core': 3.5.38 + '@vue/shared': 3.5.38 - '@vue/compiler-sfc@3.5.39': + '@vue/compiler-sfc@3.5.38': dependencies: '@babel/parser': 7.29.7 - '@vue/compiler-core': 3.5.39 - '@vue/compiler-dom': 3.5.39 - '@vue/compiler-ssr': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/compiler-core': 3.5.38 + '@vue/compiler-dom': 3.5.38 + '@vue/compiler-ssr': 3.5.38 + '@vue/shared': 3.5.38 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.16 + postcss: 8.5.15 source-map-js: 1.2.1 - '@vue/compiler-ssr@3.5.39': + '@vue/compiler-ssr@3.5.38': + dependencies: + '@vue/compiler-dom': 3.5.38 + '@vue/shared': 3.5.38 + + '@vue/shared@3.5.38': {} + + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: - '@vue/compiler-dom': 3.5.39 - '@vue/shared': 3.5.39 + acorn: 8.17.0 - '@vue/shared@3.5.39': {} + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.3 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -5068,7 +5658,7 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - brace-expansion@5.0.7: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -5092,7 +5682,7 @@ snapshots: chalk@5.6.2: {} - chardet@2.2.0: {} + chardet@2.1.1: {} chokidar@5.0.0: dependencies: @@ -5146,6 +5736,8 @@ snapshots: deep-extend@0.6.0: {} + deep-is@0.1.4: {} + default-browser-id@5.0.1: {} default-browser@5.5.0: @@ -5189,11 +5781,11 @@ snapshots: dependencies: node-source-walk: 7.0.2 - detective-postcss@8.0.4(postcss@8.5.16): + detective-postcss@8.0.4(postcss@8.5.15): dependencies: is-url-superb: 4.0.0 - postcss: 8.5.16 - postcss-values-parser: 6.0.2(postcss@8.5.16) + postcss: 8.5.15 + postcss-values-parser: 6.0.2(postcss@8.5.15) detective-sass@6.0.2: dependencies: @@ -5209,7 +5801,7 @@ snapshots: detective-typescript@14.1.2(typescript@5.9.3): dependencies: - '@typescript-eslint/typescript-estree': 8.62.1(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) ast-module-types: 6.0.2 node-source-walk: 7.0.2 typescript: 5.9.3 @@ -5219,7 +5811,7 @@ snapshots: detective-vue2@2.3.0(typescript@5.9.3): dependencies: '@dependents/detective-less': 5.0.3 - '@vue/compiler-sfc': 3.5.39 + '@vue/compiler-sfc': 3.5.38 detective-es6: 5.0.2 detective-sass: 6.0.2 detective-scss: 5.0.2 @@ -5241,7 +5833,7 @@ snapshots: empathic@2.0.1: {} - enhanced-resolve@5.24.1: + enhanced-resolve@5.24.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -5250,7 +5842,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@2.3.0: {} + es-module-lexer@2.1.0: {} es-toolkit@1.49.0: {} @@ -5283,6 +5875,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escape-string-regexp@4.0.0: {} + escodegen@2.1.0: dependencies: esprima: 4.0.1 @@ -5291,10 +5885,82 @@ snapshots: optionalDependencies: source-map: 0.6.1 + eslint-plugin-zod@4.7.0(eslint@10.5.0(jiti@2.7.0))(oxlint@1.72.0(oxlint-tsgolint@0.24.0))(typescript@6.0.3)(zod@4.4.3): + dependencies: + '@eslint-zod/utils': 2.3.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + optionalDependencies: + eslint: 10.5.0(jiti@2.7.0) + oxlint: 1.72.0(oxlint-tsgolint@0.24.0) + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + - typescript + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + eslint-visitor-keys@5.0.1: {} + eslint@10.5.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + esprima@4.0.1: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + estraverse@5.3.0: {} estree-walker@2.0.2: {} @@ -5320,19 +5986,23 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - expect-type@1.4.0: {} + expect-type@1.3.0: {} - exsolve@1.1.0: {} + exsolve@1.0.8: {} fast-deep-equal@3.1.3: {} + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.3: {} + fast-uri@3.1.2: {} fast-wrap-ansi@0.2.2: dependencies: @@ -5350,11 +6020,15 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + filing-cabinet@5.5.1: dependencies: app-module-path: 2.2.0 commander: 12.1.0 - enhanced-resolve: 5.24.1 + enhanced-resolve: 5.24.0 module-definition: 6.0.2 module-lookup-amd: 9.1.3 resolve: 1.22.12 @@ -5366,6 +6040,18 @@ snapshots: find-up-simple@1.0.1: {} + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -5395,6 +6081,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -5436,10 +6126,14 @@ snapshots: ieee754@1.2.1: {} + ignore@5.3.2: {} + import-meta-resolve@4.2.0: {} import-without-cache@0.4.0: {} + imurmurhash@0.1.4: {} + inflection@3.0.2: {} inherits@2.0.4: {} @@ -5452,8 +6146,14 @@ snapshots: is-docker@3.0.0: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -5503,12 +6203,22 @@ snapshots: jsesc@3.1.0: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} jsonc-parser@3.3.1: {} + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + knip@6.24.0: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -5518,10 +6228,10 @@ snapshots: oxc-parser: 0.137.0 oxc-resolver: 11.21.3 picomatch: 4.0.4 - smol-toml: 1.7.0 + smol-toml: 1.6.1 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 - unbash: 4.0.2 + unbash: 4.0.1 yaml: 2.9.0 zod: 4.4.3 @@ -5530,7 +6240,7 @@ snapshots: launch-editor@2.14.1: dependencies: picocolors: 1.1.1 - shell-quote: 1.9.0 + shell-quote: 1.8.4 lefthook-darwin-arm64@2.1.9: optional: true @@ -5575,6 +6285,11 @@ snapshots: lefthook-windows-arm64: 2.1.9 lefthook-windows-x64: 2.1.9 + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: optional: true @@ -5624,6 +6339,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash.truncate@4.4.2: {} log-symbols@4.1.0: @@ -5676,7 +6395,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.6 minimist@1.2.8: {} @@ -5703,7 +6422,9 @@ snapshots: mute-stream@3.0.0: {} - nanoid@3.3.15: {} + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} nearley@2.20.1: dependencies: @@ -5736,6 +6457,15 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + ora@5.4.1: dependencies: bl: 4.1.0 @@ -5876,11 +6606,19 @@ snapshots: '@oxlint/binding-win32-x64-msvc': 1.72.0 oxlint-tsgolint: 0.24.0 + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + p-limit@7.3.0: dependencies: yocto-queue: 1.2.2 - package-manager-detector@1.7.0: {} + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-manager-detector@1.6.0: {} parse-ms@2.1.0: {} @@ -5888,6 +6626,8 @@ snapshots: path-browserify@1.0.1: {} + path-exists@4.0.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -5915,7 +6655,7 @@ snapshots: pkg-types@2.3.1: dependencies: confbox: 0.2.4 - exsolve: 1.1.0 + exsolve: 1.0.8 pathe: 2.0.3 pluralize@8.0.0: {} @@ -5928,16 +6668,16 @@ snapshots: '@clack/prompts': 1.6.0 '@inquirer/prompts': 8.5.2(@types/node@24.13.2) - postcss-values-parser@6.0.2(postcss@8.5.16): + postcss-values-parser@6.0.2(postcss@8.5.15): dependencies: color-name: 1.1.4 is-url-superb: 4.0.0 - postcss: 8.5.16 + postcss: 8.5.15 quote-unquote: 1.0.0 - postcss@8.5.16: + postcss@8.5.15: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -5950,7 +6690,7 @@ snapshots: detective-amd: 6.1.0 detective-cjs: 6.1.1 detective-es6: 5.0.2 - detective-postcss: 8.0.4(postcss@8.5.16) + detective-postcss: 8.0.4(postcss@8.5.15) detective-sass: 6.0.2 detective-scss: 5.0.2 detective-stylus: 5.0.1 @@ -5958,11 +6698,13 @@ snapshots: detective-vue2: 2.3.0(typescript@5.9.3) module-definition: 6.0.2 node-source-walk: 7.0.2 - postcss: 8.5.16 + postcss: 8.5.15 typescript: 5.9.3 transitivePeerDependencies: - supports-color + prelude-ls@1.2.1: {} + pretty-ms@7.0.1: dependencies: parse-ms: 2.1.0 @@ -5973,11 +6715,13 @@ snapshots: publint@0.3.21: dependencies: - '@publint/pack': 0.1.5 - package-manager-detector: 1.7.0 + '@publint/pack': 0.1.4 + package-manager-detector: 1.6.0 picocolors: 1.1.1 sade: 1.8.1 + punycode@2.3.1: {} + quansync@1.0.0: {} quote-unquote@1.0.0: {} @@ -6048,6 +6792,27 @@ snapshots: transitivePeerDependencies: - oxc-resolver + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + rolldown@1.1.4: dependencies: '@oxc-project/types': 0.138.0 @@ -6082,7 +6847,7 @@ snapshots: sass-lookup@6.1.2: dependencies: commander: 12.1.0 - enhanced-resolve: 5.24.1 + enhanced-resolve: 5.24.0 semver@7.8.5: {} @@ -6092,7 +6857,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.9.0: {} + shell-quote@1.8.4: {} siginfo@2.0.0: {} @@ -6108,7 +6873,7 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 - smol-toml@1.7.0: {} + smol-toml@1.6.1: {} sonda@0.13.1: dependencies: @@ -6201,6 +6966,10 @@ snapshots: dependencies: typescript: 5.9.3 + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + ts-cron-validator@1.1.5: {} ts-graphviz@2.1.6: @@ -6257,6 +7026,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-fest@5.7.0: dependencies: tagged-tag: 1.0.0 @@ -6265,7 +7038,7 @@ snapshots: typescript@6.0.3: {} - unbash@4.0.2: {} + unbash@4.0.1: {} unconfig-core@7.5.0: dependencies: @@ -6278,14 +7051,18 @@ snapshots: unicorn-magic@0.3.0: {} + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + util-deprecate@1.0.2: {} - vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0): + vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.16 - rolldown: 1.1.4 + postcss: 8.5.15 + rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.2 @@ -6295,17 +7072,17 @@ snapshots: tsx: 4.22.5 yaml: 2.9.0 - vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 '@vitest/spy': 4.1.9 '@vitest/utils': 4.1.9 - es-module-lexer: 2.3.0 - expect-type: 1.4.0 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 @@ -6315,7 +7092,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -6343,6 +7120,8 @@ snapshots: wonka@6.3.6: {} + word-wrap@1.2.5: {} + wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 @@ -6352,6 +7131,8 @@ snapshots: yaml@2.9.0: {} + yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} yoctocolors@2.1.2: {} diff --git a/scripts/oxlint/tailor-zod-plugin.cjs b/scripts/oxlint/tailor-zod-plugin.cjs new file mode 100644 index 0000000000..83e187f371 --- /dev/null +++ b/scripts/oxlint/tailor-zod-plugin.cjs @@ -0,0 +1,72 @@ +"use strict"; + +const OBJECT_POLICY_COMMENT_PATTERN = /\b(catchall|strip)\b/i; + +function getPropertyName(property) { + if (property == null) { + return undefined; + } + + if (property.type === "Identifier") { + return property.name; + } + + if (property.type === "Literal") { + return property.value; + } + + return undefined; +} + +function isZObjectCall(node) { + return ( + node.callee.type === "MemberExpression" && + node.callee.object.type === "Identifier" && + node.callee.object.name === "z" && + getPropertyName(node.callee.property) === "object" + ); +} + +function hasObjectPolicyComment(sourceCode, node) { + const previousLine = sourceCode.getText().split(/\r?\n/)[node.loc.start.line - 2] ?? ""; + const trimmedPreviousLine = previousLine.trimStart(); + return ( + (trimmedPreviousLine.startsWith("//") || trimmedPreviousLine.startsWith("/*")) && + OBJECT_POLICY_COMMENT_PATTERN.test(trimmedPreviousLine) + ); +} + +module.exports = { + meta: { + name: "tailor-zod", + }, + rules: { + "require-object-policy-comment": { + meta: { + type: "problem", + docs: { + description: "Require an unknown-key policy comment for z.object().", + }, + schema: [], + messages: { + missingObjectPolicyComment: + 'Add a previous-line comment containing "strip" or "catchall" for z.object(), or use z.strictObject() / z.looseObject().', + }, + }, + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + return { + CallExpression(node) { + if (isZObjectCall(node) && !hasObjectPolicyComment(sourceCode, node)) { + context.report({ + node, + messageId: "missingObjectPolicyComment", + }); + } + }, + }; + }, + }, + }, +}; diff --git a/skills/tailor-sdk/SKILL.md b/skills/tailor/SKILL.md similarity index 98% rename from skills/tailor-sdk/SKILL.md rename to skills/tailor/SKILL.md index 7d1dcdcf2d..68c54ad5b1 100644 --- a/skills/tailor-sdk/SKILL.md +++ b/skills/tailor/SKILL.md @@ -1,5 +1,5 @@ --- -name: tailor-sdk +name: tailor description: Use this skill when working with @tailor-platform/sdk projects, including service configuration, CLI usage, and docs navigation. ---