diff --git a/.changeset/v2-codemods-phase1.md b/.changeset/v2-codemods-phase1.md new file mode 100644 index 000000000..c06c874b2 --- /dev/null +++ b/.changeset/v2-codemods-phase1.md @@ -0,0 +1,12 @@ +--- +"@tailor-platform/sdk-codemod": minor +--- + +Add three v2 codemods that the upgrade runner can apply when migrating across the 1.x → 2.x boundary: + +- `v2/test-run-arg-input` strips the deprecated `{ "input": ... }` wrapper from `tailor-sdk function test-run --arg` JSON inside `package.json` scripts, shell scripts, and Markdown code blocks. +- `v2/sdk-skills-shim` rewrites `tailor-sdk-skills` invocations to `tailor-sdk skills install` across `package.json`, shell, YAML, and Markdown files. +- `v2/principal-unify` renames `TailorUser` / `TailorActor` / `TailorInvoker` to the unified `TailorPrincipal`, drops `unauthenticatedTailorUser` (replacing standalone value references with `null`; member-access forms are left as-is so the resulting type error points authors at sites that need manual review), and renames `user` to `caller` inside `createResolver` body parameters and member accesses. +- `v2/apply-to-deploy` rewrites `tailor-sdk apply` invocations in `package.json` scripts, shell scripts, CI YAML, and Markdown to the v2-recommended `tailor-sdk deploy` alias. Optional `@version` pins (`tailor-sdk@latest`, `tailor-sdk@1.45.2`) are preserved. +- `v2/cli-rename` rewrites `tailor-sdk crash-report` invocations to the v2 single-word `tailor-sdk crashreport` form across `package.json` scripts, shell scripts, CI YAML, and Markdown. Optional `@version` pins are preserved. +- `v2/auth-invoker-unwrap` replaces `auth.invoker("name")` calls with the bare `"name"` string literal and drops the `auth` import when it has no other reference. Calls whose argument is not a literal string (`auth.invoker(variable)`, template literals) are left untouched so the author can decide. diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 9e663b90e..720b565ee 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -21,6 +21,7 @@ "example/tests/fixtures/", "example/seed/", "packages/tailor-proto/", + "packages/sdk-codemod/codemods/**/tests/", "generated/" ] } diff --git a/lefthook.yml b/lefthook.yml index 198ae673d..f7e220d19 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -25,6 +25,7 @@ pre-commit: - "example/generated/**" - "example/seed/**" - "packages/tailor-proto/**" + - "packages/sdk-codemod/codemods/**/tests/**" run: pnpm oxfmt --check {staged_files} checks: diff --git a/packages/sdk-codemod/.oxlintrc.json b/packages/sdk-codemod/.oxlintrc.json new file mode 100644 index 000000000..b089efdef --- /dev/null +++ b/packages/sdk-codemod/.oxlintrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "ignorePatterns": ["dist/", "codemods/**/tests/**"] +} diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/codemod.yaml b/packages/sdk-codemod/codemods/v2/apply-to-deploy/codemod.yaml new file mode 100644 index 000000000..f9a072de5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/apply-to-deploy" +version: "1.0.0" +description: "Rewrite `tailor-sdk apply` invocations to `tailor-sdk deploy` (the v2 recommended name)" +engine: jssg +language: text +since: "1.0.0" +until: "2.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 new file mode 100644 index 000000000..bf50fec76 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/scripts/transform.ts @@ -0,0 +1,66 @@ +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. +// `(?![-\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; + +function replaceApply(value: string): string { + return value.replace( + APPLY_PATTERN, + (_match, ver: string | undefined, sep: string) => `tailor-sdk${ver ?? ""}${sep}deploy`, + ); +} + +function transformText(source: string): string | null { + if (!APPLY_PATTERN.test(source)) return null; + APPLY_PATTERN.lastIndex = 0; + const updated = replaceApply(source); + 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 = replaceApply(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; +} + +/** + * Replace `tailor-sdk apply` invocations with `tailor-sdk deploy`. + * + * `deploy` is a v1 alias of `apply` and the recommended name going forward. + * @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); + 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 new file mode 100644 index 000000000..cb68f1c59 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/expected.json @@ -0,0 +1,9 @@ +{ + "name": "my-app", + "version": "1.0.0", + "scripts": { + "deploy:dev": "tailor-sdk deploy --profile dev", + "deploy:prod": "tailor-sdk deploy --profile prod -y", + "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 new file mode 100644 index 000000000..26cdfa98a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-package-json/input.json @@ -0,0 +1,9 @@ +{ + "name": "my-app", + "version": "1.0.0", + "scripts": { + "deploy:dev": "tailor-sdk apply --profile dev", + "deploy:prod": "tailor-sdk apply --profile prod -y", + "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 new file mode 100644 index 000000000..c3705af3f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/expected.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +pnpm exec tailor-sdk deploy --dry-run +npx tailor-sdk deploy -y --profile prod +bunx tailor-sdk deploy --no-cache 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 new file mode 100644 index 000000000..f197add5e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-shell/input.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +pnpm exec tailor-sdk apply --dry-run +npx tailor-sdk apply -y --profile prod +bunx tailor-sdk apply --no-cache diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-yaml/expected.yml b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-yaml/expected.yml new file mode 100644 index 000000000..8c20a131a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-yaml/expected.yml @@ -0,0 +1,11 @@ +name: Deploy +on: + push: + branches: [main] +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pnpm install + - run: pnpm exec tailor-sdk deploy --profile prod -y diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-yaml/input.yml b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-yaml/input.yml new file mode 100644 index 000000000..c149c181c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/basic-yaml/input.yml @@ -0,0 +1,11 @@ +name: Deploy +on: + push: + branches: [main] +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pnpm install + - run: pnpm exec tailor-sdk apply --profile prod -y 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 new file mode 100644 index 000000000..004f8c9d8 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/no-match/input.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Should not match: bare `apply` without `tailor-sdk` prefix is a generic word +echo "How to apply this configuration" +# Should not match: hypothetical sibling subcommand starting with `apply-` +pnpm exec tailor-sdk apply-foo +# Should not match: word continuation +pnpm exec tailor-sdk applyConfig diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/version-qualified/expected.sh b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/version-qualified/expected.sh new file mode 100644 index 000000000..0dfe8ee88 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/version-qualified/expected.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +npx tailor-sdk@latest deploy --dry-run +pnpm dlx tailor-sdk@1.45.2 deploy -y diff --git a/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/version-qualified/input.sh b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/version-qualified/input.sh new file mode 100644 index 000000000..020bd1b89 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/apply-to-deploy/tests/version-qualified/input.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +npx tailor-sdk@latest apply --dry-run +pnpm dlx tailor-sdk@1.45.2 apply -y diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/codemod.yaml b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/codemod.yaml new file mode 100644 index 000000000..fae6d759f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/codemod.yaml @@ -0,0 +1,7 @@ +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' +engine: jssg +language: typescript +since: "1.0.0" +until: "2.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 new file mode 100644 index 000000000..63bbd413b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/scripts/transform.ts @@ -0,0 +1,185 @@ +import { parse, Lang } from "@ast-grep/napi"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const QUICK_FILTER_NEEDLE = "auth.invoker"; + +function quickFilter(source: string): boolean { + return source.includes(QUICK_FILTER_NEEDLE); +} + +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; +} + +interface InvokerCall { + /** The full `auth.invoker(...)` call expression node. */ + callNode: SgNode; + /** The string literal argument node, including its surrounding quotes. */ + argText: string; + /** Byte range covered by this call expression. */ + range: [number, number]; +} + +/** + * Find every `auth.invoker()` call in `root`. Calls whose + * argument is not a literal string (e.g. `auth.invoker(name)`, + * `auth.invoker(\`x\${y}\`)`) are intentionally ignored: only the literal form + * is safely replaceable. + */ +function findInvokerCalls(root: SgNode): InvokerCall[] { + const matches = root.findAll({ rule: { pattern: "auth.invoker($NAME)" } }); + const out: InvokerCall[] = []; + for (const match of matches) { + const arg = match.getMatch("NAME"); + if (!arg) continue; + if (arg.kind() !== "string") continue; + const r = match.range(); + out.push({ callNode: match, argText: arg.text(), range: [r.start.index, r.end.index] }); + } + return out; +} + +/** + * Count `auth` identifier references that are not part of an import statement + * and not part of any of the `auth.invoker(...)` calls already scheduled for + * replacement. A non-zero return means the `auth` import must be preserved. + */ +function countRemainingAuthRefs( + root: SgNode, + scheduledCallRanges: Array<[number, number]>, +): number { + const idents = root.findAll({ rule: { kind: "identifier", regex: "^auth$" } }); + let count = 0; + for (const node of idents) { + if (isInsideImportStatement(node)) continue; + const r = node.range(); + const start = r.start.index; + const inScheduled = scheduledCallRanges.some(([s, e]) => start >= s && start < e); + if (inScheduled) continue; + count++; + } + return count; +} + +interface ImportSpec { + spec: SgNode; + importedName: string; + localName: string; +} + +function* iterateImportSpecs(importStmt: SgNode): Generator { + 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 aliasNode = idents[1]; + yield { + spec, + importedName, + localName: aliasNode?.text() ?? importedName, + }; + } +} + +/** + * Build an Edit that removes the `auth` specifier from `importStmt`. Returns + * null if the statement does not import `auth`. When `auth` is the only + * specifier the entire import line is removed (including a trailing newline); + * otherwise just the `auth,` / `, auth` fragment is dropped. + */ +function buildAuthImportRemovalEdit(source: string, importStmt: SgNode): Edit | null { + const specs = Array.from(iterateImportSpecs(importStmt)); + const authSpec = specs.find((s) => s.localName === "auth" && s.importedName === "auth"); + if (!authSpec) return null; + + if (specs.length === 1) { + const r = importStmt.range(); + let end = r.end.index; + while (end < source.length && (source[end] === "\n" || source[end] === "\r")) end++; + return { + startPos: r.start.index, + endPos: end, + insertedText: "", + }; + } + + const r = authSpec.spec.range(); + let start = r.start.index; + let end = r.end.index; + // Eat one neighbor `,` (and adjacent whitespace) so the resulting list stays + // syntactically valid. + while (end < source.length && (source[end] === " " || source[end] === "\t")) end++; + if (source[end] === ",") { + end++; + while (end < source.length && (source[end] === " " || source[end] === "\t")) end++; + return { startPos: start, endPos: end, insertedText: "" }; + } + while (start > 0 && (source[start - 1] === " " || source[start - 1] === "\t")) start--; + if (source[start - 1] === ",") { + start--; + while (start > 0 && (source[start - 1] === " " || source[start - 1] === "\t")) start--; + return { startPos: start, endPos: end, insertedText: "" }; + } + return { startPos: r.start.index, endPos: r.end.index, insertedText: "" }; +} + +function findAuthImports(root: SgNode): SgNode[] { + const stmts = root.findAll({ rule: { kind: "import_statement" } }); + return stmts.filter((stmt) => { + for (const { localName, importedName } of iterateImportSpecs(stmt)) { + if (localName === "auth" && importedName === "auth") return true; + } + return false; + }); +} + +/** + * Replace `auth.invoker("name")` calls with the bare `"name"` string literal. + * 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. + * @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 calls = findInvokerCalls(root); + if (calls.length === 0) return null; + + const edits: Edit[] = calls.map((c) => c.callNode.replace(c.argText)); + + const remaining = countRemainingAuthRefs( + root, + calls.map((c) => c.range), + ); + if (remaining === 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); + + // Normalize: drop the leading blank line that an import removal at the top + // of the file leaves behind, and collapse runs of 3+ newlines. + result = result.replace(/^[\t ]*\n+/, "").replace(/\n{3,}/g, "\n\n"); + + return result === source ? null : result; +} 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 new file mode 100644 index 000000000..70ea71c4b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/basic-resolver/expected.ts @@ -0,0 +1,21 @@ +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/basic-resolver/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/basic-resolver/input.ts new file mode 100644 index 000000000..e0fedfc84 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/basic-resolver/input.ts @@ -0,0 +1,22 @@ +import { createResolver, t } from "@tailor-platform/sdk"; +import { auth } from "../tailor.config"; +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: auth.invoker("manager-machine-user") }, + ); + return workflowRunId; + }, +}); 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 new file mode 100644 index 000000000..be4be98b8 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/expected.ts @@ -0,0 +1,6 @@ +import { db } from "../tailor.config"; + +export const cfg = { + authInvoker: "kiosk", + table: 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 new file mode 100644 index 000000000..b9f79afd2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-drops-only-auth/input.ts @@ -0,0 +1,6 @@ +import { auth, db } from "../tailor.config"; + +export const cfg = { + authInvoker: auth.invoker("kiosk"), + table: 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 new file mode 100644 index 000000000..9db289de7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/expected.ts @@ -0,0 +1,8 @@ +import { auth, db } from "../tailor.config"; + +export const cfg = { + authInvoker: "kiosk", + // `auth` is still referenced below, so the import must be preserved. + ownerType: auth.machineUser, + table: 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 new file mode 100644 index 000000000..2381e1fd6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/multi-import-keeps-auth/input.ts @@ -0,0 +1,8 @@ +import { auth, db } from "../tailor.config"; + +export const cfg = { + authInvoker: auth.invoker("kiosk"), + // `auth` is still referenced below, so the import must be preserved. + ownerType: auth.machineUser, + table: db.type("Order"), +}; diff --git a/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/no-match/input.ts b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/no-match/input.ts new file mode 100644 index 000000000..a0d997203 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/no-match/input.ts @@ -0,0 +1,9 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +// No `auth.invoker(...)` call here, the codemod should be a no-op. +export default createResolver({ + name: "noop", + type: "Query", + input: {}, + body: () => ({ ok: true }), +}); 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 new file mode 100644 index 000000000..feee54fe2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/auth-invoker-unwrap/tests/non-literal-arg-untouched/input.ts @@ -0,0 +1,9 @@ +import { auth } from "../tailor.config"; + +const machineUserName = "kiosk"; + +export const cfg = { + // 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/cli-rename/codemod.yaml b/packages/sdk-codemod/codemods/v2/cli-rename/codemod.yaml new file mode 100644 index 000000000..2937f57aa --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/cli-rename" +version: "1.0.0" +description: "Apply v2 CLI naming conventions: single-word command names and kebab-case option names" +engine: jssg +language: text +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/cli-rename/scripts/transform.ts new file mode 100644 index 000000000..fbe72152c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/scripts/transform.ts @@ -0,0 +1,71 @@ +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 COMMAND_PATTERN = new RegExp( + `\\btailor-sdk(@[^\\s'"\`]+)?(\\s+)(${COMMAND_RENAMES.map(([from]) => from).join("|")})\\b`, + "g", +); + +const COMMAND_MAP = new Map(COMMAND_RENAMES); + +function replaceAll(value: string): string { + return value.replace( + COMMAND_PATTERN, + (_match, ver: string | undefined, sep: string, cmd: string) => + `tailor-sdk${ver ?? ""}${sep}${COMMAND_MAP.get(cmd) ?? cmd}`, + ); +} + +function transformText(source: string): string | null { + if (!COMMAND_PATTERN.test(source)) return null; + COMMAND_PATTERN.lastIndex = 0; + const updated = replaceAll(source); + 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; + const updated = replaceAll(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; +} + +/** + * 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. + * + * 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. + */ +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); +} 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 new file mode 100644 index 000000000..c8a36feda --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/expected.json @@ -0,0 +1,9 @@ +{ + "name": "my-app", + "version": "1.0.0", + "scripts": { + "report:tail": "tailor-sdk crashreport list", + "report:send": "tailor-sdk crashreport send --file ./latest.crash.log", + "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 new file mode 100644 index 000000000..fe43c4b44 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-package-json/input.json @@ -0,0 +1,9 @@ +{ + "name": "my-app", + "version": "1.0.0", + "scripts": { + "report:tail": "tailor-sdk crash-report list", + "report:send": "tailor-sdk crash-report send --file ./latest.crash.log", + "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 new file mode 100644 index 000000000..274b44990 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/expected.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +pnpm exec tailor-sdk crashreport list +pnpm exec tailor-sdk crashreport send --file ./latest.crash.log 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 new file mode 100644 index 000000000..6202aa2aa --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-shell/input.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +pnpm exec tailor-sdk crash-report list +pnpm exec tailor-sdk crash-report send --file ./latest.crash.log 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 new file mode 100644 index 000000000..0cddd575f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/expected.yml @@ -0,0 +1,10 @@ +name: Tail crash reports +on: workflow_dispatch +jobs: + tail: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pnpm install + - run: pnpm exec tailor-sdk crashreport list + - run: pnpm exec tailor-sdk crashreport send --file latest.crash.log 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 new file mode 100644 index 000000000..83ab2b5f5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/basic-yaml/input.yml @@ -0,0 +1,10 @@ +name: Tail crash reports +on: workflow_dispatch +jobs: + tail: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pnpm install + - run: pnpm exec tailor-sdk crash-report list + - run: pnpm exec tailor-sdk crash-report send --file latest.crash.log 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 new file mode 100644 index 000000000..a17ea544a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/no-match/input.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Should not match: command name is part of a longer word +pnpm exec tailor-sdk crash-reporter 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 diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/version-qualified/expected.sh b/packages/sdk-codemod/codemods/v2/cli-rename/tests/version-qualified/expected.sh new file mode 100644 index 000000000..5edd7a651 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/version-qualified/expected.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +npx tailor-sdk@latest crashreport list +pnpm dlx tailor-sdk@1.45.2 crashreport send --file ./latest.crash.log diff --git a/packages/sdk-codemod/codemods/v2/cli-rename/tests/version-qualified/input.sh b/packages/sdk-codemod/codemods/v2/cli-rename/tests/version-qualified/input.sh new file mode 100644 index 000000000..1296a3364 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/cli-rename/tests/version-qualified/input.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +npx tailor-sdk@latest crash-report list +pnpm dlx tailor-sdk@1.45.2 crash-report send --file ./latest.crash.log diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/codemod.yaml b/packages/sdk-codemod/codemods/v2/principal-unify/codemod.yaml new file mode 100644 index 000000000..1dd94b1ac --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/principal-unify" +version: "1.0.0" +description: "Unify TailorUser/TailorActor/TailorInvoker into TailorPrincipal and rename resolver body `user` → `caller`" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.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 new file mode 100644 index 000000000..af16cd7f0 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/scripts/transform.ts @@ -0,0 +1,634 @@ +import { parse, Lang } from "@ast-grep/napi"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +const TYPE_RENAME_MAP: Record = { + TailorUser: "TailorPrincipal", + TailorActor: "TailorPrincipal", + TailorInvoker: "TailorPrincipal", +}; + +const UNAUTHENTICATED = "unauthenticatedTailorUser"; + +const QUICK_FILTER_NEEDLES = [...Object.keys(TYPE_RENAME_MAP), UNAUTHENTICATED, "createResolver"]; + +function quickFilter(source: string): boolean { + if (!source.includes("@tailor-platform/sdk")) return false; + return QUICK_FILTER_NEEDLES.some((needle) => source.includes(needle)); +} + +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; +} + +function isMemberExpressionObject(node: SgNode): boolean { + const parent = node.parent(); + if (!parent || parent.kind() !== "member_expression") return false; + const obj = parent.field("object"); + if (!obj) return false; + const r = node.range(); + const or = obj.range(); + return r.start.index === or.start.index && r.end.index === or.end.index; +} + +interface ImportRewriteResult { + newText: string; + touched: boolean; +} + +function extractModuleSource(importText: string): string { + const m = importText.match(/from\s+(["'])([^"']+)\1/); + return m?.[2] ?? "@tailor-platform/sdk"; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +interface ImportSpec { + spec: SgNode; + importedName: string; + aliasNode: SgNode | undefined; + localName: string; +} + +/** + * Yield each import specifier in `importStmt` along with its imported name and + * optional alias. `import { Foo as Bar }` produces `{ importedName: "Foo", + * aliasNode: , localName: "Bar" }`; `import { Foo }` produces + * `{ importedName: "Foo", aliasNode: undefined, localName: "Foo" }`. + */ +function* iterateImportSpecs(importStmt: SgNode): Generator { + 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 aliasNode = idents[1]; + yield { + spec, + importedName, + aliasNode, + localName: aliasNode?.text() ?? importedName, + }; + } +} + +function rebuildImportStatement( + importStmt: SgNode, + globalEmittedRenamed: Set, + unauthenticatedLocalNames: Set, +): ImportRewriteResult { + const importText = importStmt.text(); + const isImportType = /^\s*import\s+type\b/.test(importText); + const trailingSemi = importText.trimEnd().endsWith(";") ? ";" : ""; + const sourceRaw = extractModuleSource(importText); + + const newSpecTexts: string[] = []; + const seenLocal = new Set(); + let touched = false; + + for (const { spec, importedName, aliasNode, localName } of iterateImportSpecs(importStmt)) { + const specText = spec.text(); + const isTypeOnly = /^\s*type\s+/.test(specText); + + const renamed = TYPE_RENAME_MAP[importedName]; + if (renamed) { + touched = true; + const finalLocal = 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; + seenLocal.add(finalLocal); + if (!aliasNode) globalEmittedRenamed.add(renamed); + const asPart = aliasNode ? ` as ${aliasNode.text()}` : ""; + newSpecTexts.push(`${isTypeOnly ? "type " : ""}${renamed}${asPart}`); + } else if (importedName === UNAUTHENTICATED) { + touched = true; + // Track the local binding so aliased forms like + // `import { unauthenticatedTailorUser as testUser } ...` get their references + // rewritten to `null` alongside the canonical name. + unauthenticatedLocalNames.add(localName); + } else { + if (seenLocal.has(localName)) continue; + seenLocal.add(localName); + newSpecTexts.push(specText); + } + } + + if (!touched) return { newText: importText, touched: false }; + if (newSpecTexts.length === 0) return { newText: "", touched: true }; + + const prefix = isImportType ? "import type " : "import "; + return { + newText: `${prefix}{ ${newSpecTexts.join(", ")} } from "${sourceRaw}"${trailingSemi}`, + touched: true, + }; +} + +const SCOPE_KINDS = new Set([ + "statement_block", + "function_body", + "for_statement", + "for_in_statement", + "for_of_statement", + "arrow_function", + "function_expression", + "function_declaration", + "method_definition", +]); + +const NESTED_FN_KINDS = [ + "arrow_function", + "function_expression", + "function_declaration", + "method_definition", +]; + +function isInsideAnyRange(pos: number, ranges: Array<[number, number]>): boolean { + return ranges.some(([s, e]) => pos >= s && pos < e); +} + +/** + * Walk up from `decl` and return the byte range of its enclosing scope, or + * null if no recognized scope ancestor exists. + */ +function enclosingScopeRange(decl: SgNode): [number, number] | null { + let scope: SgNode | null = decl.parent(); + while (scope && !SCOPE_KINDS.has(scope.kind())) scope = scope.parent(); + if (!scope) return null; + const range = scope.range(); + return [range.start.index, range.end.index]; +} + +function patternBindsName(pat: SgNode, name: string): boolean { + const k = pat.kind(); + if (k === "identifier") return pat.text() === name; + if (k === "object_pattern") { + for (const child of pat.children()) { + const ck = child.kind(); + if (ck === "shorthand_property_identifier_pattern" && child.text() === name) return true; + if (ck === "pair_pattern") { + const value = child.field("value"); + if (value && patternBindsName(value, name)) return true; + } + if (ck === "object_assignment_pattern") { + const inner = child + .children() + .find((c: SgNode) => c.kind() === "shorthand_property_identifier_pattern"); + if (inner && inner.text() === name) return true; + } + if (ck === "rest_pattern") { + const inner = child.children().find((c: SgNode) => c.kind() === "identifier"); + if (inner && inner.text() === name) return true; + } + } + } else if (k === "array_pattern") { + for (const child of pat.children()) { + if (patternBindsName(child, name)) return true; + } + } else if (k === "assignment_pattern") { + const left = pat.field("left"); + if (left && patternBindsName(left, name)) return true; + } + return false; +} + +function functionRebindsName(fn: SgNode, name: string): boolean { + const single = fn.field("parameter"); + if (single && patternBindsName(single, name)) return true; + const params = + fn.field("parameters") ?? fn.children().find((c: SgNode) => c.kind() === "formal_parameters"); + if (!params) return false; + for (const child of params.children()) { + const k = child.kind(); + if (k === "identifier" && child.text() === name) return true; + if (k === "object_pattern" || k === "array_pattern") { + if (patternBindsName(child, name)) return true; + } + if (k === "required_parameter" || k === "optional_parameter") { + const pat = child.field("pattern"); + if (pat && patternBindsName(pat, name)) return true; + } + } + return false; +} + +/** + * Collect byte ranges of inner functions that re-bind `ctxName` as a parameter. + * + * Member-accesses to `ctxName.user` whose start byte falls inside any of these + * ranges refer to the inner function's parameter, not the resolver context, and + * must not be renamed. + * @param body - The resolver body node. + * @param ctxName - The context parameter identifier name. + * @param resolverArrow - The resolver's outer arrow/function expression to exclude. + */ +function collectCtxShadowRanges( + body: SgNode, + ctxName: string, + resolverArrow: SgNode, +): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + const ar = resolverArrow.range(); + for (const k of NESTED_FN_KINDS) { + const fns = body.findAll({ rule: { kind: k } }); + for (const fn of fns) { + const r = fn.range(); + if (r.start.index === ar.start.index && r.end.index === ar.end.index) continue; + if (functionRebindsName(fn, ctxName)) { + ranges.push([r.start.index, r.end.index]); + } + } + } + // Also treat re-binding via `var ctx = ...` / `let ctx = ...` as a shadow. + // We only check direct identifier-named declarators here — pattern-style + // bindings (`const { ctx } = something`) that happen to share the name are + // unrelated to the context parameter. + const declarators = body.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const nameNode = decl.field("name"); + if (!nameNode || nameNode.kind() !== "identifier") continue; + if (nameNode.text() !== ctxName) continue; + const range = enclosingScopeRange(decl); + if (range) ranges.push(range); + } + return ranges; +} + +/** + * Collect every byte range across the file where `name` is locally re-bound, + * so identifier references inside the range are treated as shadowed. + * + * Combines variable declarations (var/let/const, including object-pattern + * shorthand declarations) with function-parameter bindings. + */ +function collectAllShadowRanges(root: SgNode, name: string): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + + // Field-precise scan over `variable_declarator` nodes — only the binding + // pattern (`name` field) counts, not value-side identifier references. + // `inside: { kind: "variable_declarator" }` would also match `user` in + // `const x = user.id`, which would shadow the entire enclosing scope and + // suppress every body rename. + const declarators = root.findAll({ rule: { kind: "variable_declarator" } }); + for (const decl of declarators) { + const nameNode = decl.field("name"); + if (!nameNode) continue; + if (!patternBindsName(nameNode, name)) continue; + const range = enclosingScopeRange(decl); + if (range) ranges.push(range); + } + + for (const k of NESTED_FN_KINDS) { + const fns = root.findAll({ rule: { kind: k } }); + for (const fn of fns) { + if (functionRebindsName(fn, name)) { + const range = fn.range(); + ranges.push([range.start.index, range.end.index]); + } + } + } + return ranges; +} + +function findResolverBodyArrow(call: SgNode): SgNode | null { + const args = call.field("arguments"); + if (!args) return null; + const objArg = args.children().find((c: SgNode) => c.kind() === "object"); + if (!objArg) return null; + + const pairs = objArg.findAll({ rule: { kind: "pair" } }); + for (const pair of pairs) { + const key = pair.field("key"); + if (key?.text() !== "body") continue; + const value = pair.field("value"); + if (!value) continue; + if (value.kind() === "arrow_function" || value.kind() === "function_expression") { + return value; + } + } + return 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 + * a duplicate `let`/`const`, or alias an unrelated value, so the codemod + * leaves the body alone for manual migration instead. + */ +function hasCallerBindingConflict(pattern: SgNode, body: SgNode): boolean { + for (const child of pattern.children()) { + const k = child.kind(); + if (k === "shorthand_property_identifier_pattern" && child.text() === "caller") return true; + if (k === "pair_pattern") { + // The property key can also collide: `{ user, caller: x }` after the + // shorthand rename becomes `{ caller, caller: x }`, a duplicate key. + const key = child.field("key"); + if (key && key.text() === "caller") return true; + const value = child.field("value"); + if (value && value.kind() === "identifier" && value.text() === "caller") return true; + } + if (k === "object_assignment_pattern") { + const inner = child + .children() + .find((c: SgNode) => c.kind() === "shorthand_property_identifier_pattern"); + 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; + for (const k of NESTED_FN_KINDS) { + const fns = body.findAll({ rule: { kind: k } }); + for (const fn of fns) { + if (functionRebindsName(fn, "caller")) return true; + } + } + return false; +} + +function transformResolverBody(arrowNode: SgNode, edits: Edit[]): void { + const params = + arrowNode.field("parameters") ?? + arrowNode.field("parameter") ?? + arrowNode.children().find((c: SgNode) => c.kind() === "formal_parameters"); + const body = arrowNode.field("body"); + if (!params || !body) return; + + const firstParam = params + .children() + .find( + (c: SgNode) => + c.kind() === "required_parameter" || + c.kind() === "optional_parameter" || + c.kind() === "identifier" || + c.kind() === "object_pattern", + ); + if (!firstParam) return; + + let pattern: SgNode | undefined; + if (firstParam.kind() === "object_pattern" || firstParam.kind() === "identifier") { + pattern = firstParam; + } else { + const inner = firstParam.field("pattern"); + if (inner) pattern = inner; + } + if (!pattern) return; + + if (pattern.kind() === "object_pattern") { + if (hasCallerBindingConflict(pattern, body)) return; + + let renamedShorthandUser = false; + // 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; + } else if (kind === "pair_pattern") { + const key = child.field("key"); + if (key && key.text() === "user") { + edits.push(key.replace("caller")); + } + } else if (kind === "object_assignment_pattern") { + // `{ user = fallback }` — the inner shorthand is the binding; default + // expression is preserved. + const inner = child + .children() + .find((c: SgNode) => c.kind() === "shorthand_property_identifier_pattern"); + if (inner && inner.text() === "user") { + edits.push(inner.replace("caller")); + renamedShorthandUser = true; + } + } + } + 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)`) + // does not get its inner reference incorrectly renamed to `caller`. + const shadowRanges = collectAllShadowRanges(body, "user"); + // Plain identifier references to the renamed binding (e.g. `user.id`). + 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("caller")); + } + // Object literal shorthand (kind: `shorthand_property_identifier`, no + // `_pattern` suffix) is both the key and the value. Rewriting it to + // `caller` would silently change the resolver's output schema from a + // `user` field to a `caller` field. Expand to `user: caller` instead so + // the emitted shape stays the same while the value side reads from the + // renamed local binding. + 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("user: caller")); + } + } + return; + } + + // Single identifier param: rewrite `.user` → `.caller`, but skip + // member accesses that sit inside a nested function which re-binds ``. + const ctxName = pattern.text(); + const ctxShadowRanges = collectCtxShadowRanges(body, ctxName, arrowNode); + 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("caller")); + } + + // Also rewrite destructures of the context, e.g. `const { user } = ctx;` → + // `const { caller: user } = ctx;`. Local bindings stay the same so existing + // body references keep working. + const ctxDestructures = body.findAll({ + rule: { + kind: "variable_declarator", + has: { + field: "value", + kind: "identifier", + regex: `^${escapeRegex(ctxName)}$`, + }, + }, + }); + 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 k = child.kind(); + if (k === "shorthand_property_identifier_pattern" && child.text() === "user") { + edits.push(child.replace("caller: user")); + } else if (k === "pair_pattern") { + const key = child.field("key"); + if (key && key.text() === "user") { + edits.push(key.replace("caller")); + } + } + } + } +} + +/** + * 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; + + 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(); + for (const importStmt of sdkImports) { + for (const { importedName, aliasNode } of iterateImportSpecs(importStmt)) { + if (TYPE_RENAME_MAP[importedName] && !aliasNode) { + sdkRenameSourceNames.add(importedName); + } + } + } + + 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)); + } + + 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. + const unauthenticatedLocalNames = new Set(); + for (const importStmt of sdkImports) { + const { newText, touched } = rebuildImportStatement( + importStmt, + globalEmittedRenamed, + unauthenticatedLocalNames, + ); + if (!touched) continue; + edits.push(importStmt.replace(newText)); + if (newText === "") importRemoved = true; + } + + for (const localName of unauthenticatedLocalNames) { + const shadowRanges = collectAllShadowRanges(tree, localName); + const ids = tree.findAll({ + rule: { + kind: "identifier", + regex: `^${escapeRegex(localName)}$`, + }, + }); + for (const id of ids) { + if (isInsideImportStatement(id)) continue; + if (isMemberExpressionObject(id)) continue; + const pos = id.range().start.index; + if (isInsideAnyRange(pos, shadowRanges)) continue; + edits.push(id.replace("null")); + } + } + + // Resolve which local names refer to the SDK's `createResolver` so aliased + // imports like `import { createResolver as makeResolver } ...` are migrated + // and unrelated local helpers named `createResolver` (when the SDK import + // does not actually bring `createResolver` in) are not. + const createResolverLocalNames = new Set(); + for (const importStmt of sdkImports) { + for (const { importedName, localName } of iterateImportSpecs(importStmt)) { + if (importedName === "createResolver") { + createResolverLocalNames.add(localName); + } + } + } + for (const localName of createResolverLocalNames) { + 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; + const arrow = findResolverBodyArrow(call); + if (arrow) transformResolverBody(arrow, edits); + } + } + + if (edits.length === 0) return null; + let result = tree.commitEdits(edits); + + if (importRemoved) { + result = result.replace(/^[\t ]*\n+/, "").replace(/\n{3,}/g, "\n\n"); + } + return result; +} 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 new file mode 100644 index 000000000..696045d1a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-create-resolver/expected.ts @@ -0,0 +1,8 @@ +import { createResolver as makeResolver, t } from "@tailor-platform/sdk"; + +export default makeResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ caller }) => caller.id, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-create-resolver/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-create-resolver/input.ts new file mode 100644 index 000000000..e208a238a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-create-resolver/input.ts @@ -0,0 +1,8 @@ +import { createResolver as makeResolver, t } from "@tailor-platform/sdk"; + +export default makeResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ user }) => user.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 new file mode 100644 index 000000000..3aa4c354f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-destructure/expected.ts @@ -0,0 +1,8 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ caller: currentUser }) => currentUser.id, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-destructure/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-destructure/input.ts new file mode 100644 index 000000000..1a98f5ae2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-destructure/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: 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 new file mode 100644 index 000000000..d3498e2ca --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/expected.ts @@ -0,0 +1,5 @@ +import { type TailorPrincipal as MyUser } from "@tailor-platform/sdk"; + +export type Props = { + caller: MyUser; +}; 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 new file mode 100644 index 000000000..d3611f901 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-type-import/input.ts @@ -0,0 +1,5 @@ +import { type TailorUser as MyUser } from "@tailor-platform/sdk"; + +export type Props = { + caller: MyUser; +}; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-unauthenticated/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-unauthenticated/expected.ts new file mode 100644 index 000000000..f3ffcf102 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-unauthenticated/expected.ts @@ -0,0 +1 @@ +export const fallback = null; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-unauthenticated/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-unauthenticated/input.ts new file mode 100644 index 000000000..e3215215d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/aliased-unauthenticated/input.ts @@ -0,0 +1,3 @@ +import { unauthenticatedTailorUser as testUser } from "@tailor-platform/sdk/test"; + +export const fallback = testUser; 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 new file mode 100644 index 000000000..5c1553ae6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/expected.ts @@ -0,0 +1,13 @@ +import { createResolver, t, type TailorPrincipal } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "getUser", + operation: "query", + input: t.object({ id: t.string() }), + output: t.object({ id: t.string() }), + body: ({ input, caller }) => { + return { id: caller.id }; + }, +}); + +export const helper = (u: TailorPrincipal) => u.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 new file mode 100644 index 000000000..76bb3630e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/basic/input.ts @@ -0,0 +1,13 @@ +import { createResolver, t, type TailorUser } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "getUser", + operation: "query", + input: t.object({ id: t.string() }), + output: t.object({ id: t.string() }), + body: ({ input, user }) => { + return { id: user.id }; + }, +}); + +export const helper = (u: TailorUser) => u.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 new file mode 100644 index 000000000..a0c293c7e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/body-with-derived-const/expected.ts @@ -0,0 +1,11 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ caller }) => { + const userId = caller.id; + return userId; + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/body-with-derived-const/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/body-with-derived-const/input.ts new file mode 100644 index 000000000..b5f7518a2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/body-with-derived-const/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 }) => { + const userId = user.id; + return userId; + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-collision/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-collision/input.ts new file mode 100644 index 000000000..ab63c0483 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-collision/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 }) => { + const caller = "anonymous"; + return user.id ?? caller; + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-key-conflict/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-key-conflict/input.ts new file mode 100644 index 000000000..d20cb11f1 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/caller-key-conflict/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, caller: x }) => user.id ?? x, +}); 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 new file mode 100644 index 000000000..997a81ed6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/context-arg/expected.ts @@ -0,0 +1,11 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "show", + operation: "query", + output: t.object({ id: t.string(), type: t.string() }), + body: (ctx) => ({ + id: ctx.caller.id, + type: ctx.caller.type, + }), +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/context-arg/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/context-arg/input.ts new file mode 100644 index 000000000..41e93d0f2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/context-arg/input.ts @@ -0,0 +1,11 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "show", + operation: "query", + output: t.object({ id: t.string(), type: t.string() }), + body: (ctx) => ({ + id: ctx.user.id, + type: ctx.user.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 new file mode 100644 index 000000000..00575ecc3 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-destructure/expected.ts @@ -0,0 +1,11 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: (ctx) => { + const { caller: user } = ctx; + return user.id; + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-destructure/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-destructure/input.ts new file mode 100644 index 000000000..37319717e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-destructure/input.ts @@ -0,0 +1,11 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: (ctx) => { + const { user } = ctx; + return user.id; + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-rebound/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-rebound/input.ts new file mode 100644 index 000000000..3e8ffd6ea --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/ctx-rebound/input.ts @@ -0,0 +1,13 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +declare function getOther(): { user: { id: string } }; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: (ctx) => { + var ctx = getOther(); + return ctx.user.id; + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/dedupe-imports/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/dedupe-imports/expected.ts new file mode 100644 index 000000000..e3443d47d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/dedupe-imports/expected.ts @@ -0,0 +1,6 @@ +import { TailorPrincipal } from "@tailor-platform/sdk"; +import { foo } from "@tailor-platform/sdk"; + +export type X = TailorPrincipal; +export type Y = TailorPrincipal; +export const v = foo; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/dedupe-imports/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/dedupe-imports/input.ts new file mode 100644 index 000000000..73aa9a3fb --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/dedupe-imports/input.ts @@ -0,0 +1,6 @@ +import { TailorUser } from "@tailor-platform/sdk"; +import { TailorActor, foo } from "@tailor-platform/sdk"; + +export type X = TailorUser; +export type Y = TailorActor; +export const v = foo; 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 new file mode 100644 index 000000000..5f726a55a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/defaulted-destructure/expected.ts @@ -0,0 +1,10 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +const fallback = { id: "anon" }; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ caller = fallback }) => caller.id, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/defaulted-destructure/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/defaulted-destructure/input.ts new file mode 100644 index 000000000..82c4086bb --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/defaulted-destructure/input.ts @@ -0,0 +1,10 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +const fallback = { id: "anon" }; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ user = fallback }) => user.id, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/local-tailor-types/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/local-tailor-types/input.ts new file mode 100644 index 000000000..0d6bc3a3d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/local-tailor-types/input.ts @@ -0,0 +1,13 @@ +import { createResolver } from "@tailor-platform/sdk"; +import type { TailorUser } from "./domain"; + +export type Props = { + user: TailorUser; +}; + +export default createResolver({ + name: "n", + operation: "query", + output: { id: "string" } as never, + body: () => null, +}); 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 new file mode 100644 index 000000000..a645dde3d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-ctx-shadow/expected.ts @@ -0,0 +1,13 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +const items: Array<{ user: { id: string } }> = []; + +export default createResolver({ + name: "n", + operation: "query", + output: t.array(t.string()), + body: (ctx) => ({ + me: ctx.caller.id, + others: items.map((ctx) => ctx.user.id), + }), +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-ctx-shadow/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-ctx-shadow/input.ts new file mode 100644 index 000000000..37e1ffc95 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-ctx-shadow/input.ts @@ -0,0 +1,13 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +const items: Array<{ user: { id: string } }> = []; + +export default createResolver({ + name: "n", + operation: "query", + output: t.array(t.string()), + body: (ctx) => ({ + me: ctx.user.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 new file mode 100644 index 000000000..bd3efce01 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure-shadow/expected.ts @@ -0,0 +1,10 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +const items: Array<{ user: { id: string } }> = []; + +export default createResolver({ + name: "n", + operation: "query", + output: t.array(t.string()), + body: ({ caller }) => items.map(({ user }) => user.id).concat(caller.id), +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure-shadow/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure-shadow/input.ts new file mode 100644 index 000000000..ec04f1582 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure-shadow/input.ts @@ -0,0 +1,10 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +const items: Array<{ user: { id: string } }> = []; + +export default createResolver({ + name: "n", + operation: "query", + output: t.array(t.string()), + body: ({ user }) => items.map(({ user }) => user.id).concat(user.id), +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure/input.ts new file mode 100644 index 000000000..a48c00f60 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/nested-destructure/input.ts @@ -0,0 +1,8 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "n", + operation: "query", + output: t.string(), + body: ({ input: { user } }) => user, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/no-match/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/no-match/input.ts new file mode 100644 index 000000000..f830fa51e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/no-match/input.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "@tailor-platform/sdk"; + +export default defineConfig({ + name: "demo", +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/object-shorthand-return/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/object-shorthand-return/expected.ts new file mode 100644 index 000000000..ad214cfb3 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/object-shorthand-return/expected.ts @@ -0,0 +1,8 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "n", + operation: "query", + output: t.object({ user: t.string() }), + body: ({ caller }) => ({ user: caller }), +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/object-shorthand-return/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/object-shorthand-return/input.ts new file mode 100644 index 000000000..f228fa4ee --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/object-shorthand-return/input.ts @@ -0,0 +1,8 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "n", + operation: "query", + output: t.object({ user: t.string() }), + body: ({ user }) => ({ user }), +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/param-shadow-user/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/param-shadow-user/expected.ts new file mode 100644 index 000000000..a60459ab3 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/param-shadow-user/expected.ts @@ -0,0 +1,10 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +const items: Array<{ id: string }> = []; + +export default createResolver({ + name: "n", + operation: "query", + output: t.array(t.string()), + body: ({ caller }) => items.map((user) => user.id), +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/param-shadow-user/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/param-shadow-user/input.ts new file mode 100644 index 000000000..40c8a994b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/param-shadow-user/input.ts @@ -0,0 +1,10 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +const items: Array<{ id: string }> = []; + +export default createResolver({ + name: "n", + operation: "query", + output: t.array(t.string()), + body: ({ user }) => items.map((user) => user.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 new file mode 100644 index 000000000..bf9246993 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadow/expected.ts @@ -0,0 +1,15 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "shadow", + operation: "query", + input: t.object({ flag: t.bool() }), + output: t.object({ id: t.string() }), + body: ({ input, caller }) => { + if (input.flag) { + const user = { id: "fake" }; + return { id: user.id }; + } + return { id: caller.id }; + }, +}); diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadow/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadow/input.ts new file mode 100644 index 000000000..d1e2a95b3 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/shadow/input.ts @@ -0,0 +1,15 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "shadow", + operation: "query", + input: t.object({ flag: t.bool() }), + output: t.object({ id: t.string() }), + body: ({ input, user }) => { + if (input.flag) { + const user = { id: "fake" }; + return { id: user.id }; + } + return { id: user.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 new file mode 100644 index 000000000..ee7750963 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/expected.ts @@ -0,0 +1,7 @@ +import type { TailorPrincipal } from "@tailor-platform/sdk"; + +export type Props = { + user: TailorPrincipal; + actor: TailorPrincipal; + invoker: TailorPrincipal; +}; 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 new file mode 100644 index 000000000..d42dfc898 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/type-imports/input.ts @@ -0,0 +1,7 @@ +import type { TailorUser, TailorActor, TailorInvoker } from "@tailor-platform/sdk"; + +export type Props = { + user: TailorUser; + actor: TailorActor; + invoker: TailorInvoker; +}; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated-sdk-test/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated-sdk-test/expected.ts new file mode 100644 index 000000000..f3ffcf102 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated-sdk-test/expected.ts @@ -0,0 +1 @@ +export const fallback = null; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated-sdk-test/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated-sdk-test/input.ts new file mode 100644 index 000000000..cd918d18e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated-sdk-test/input.ts @@ -0,0 +1,3 @@ +import { unauthenticatedTailorUser } from "@tailor-platform/sdk/test"; + +export const fallback = unauthenticatedTailorUser; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated-shadowed/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated-shadowed/expected.ts new file mode 100644 index 000000000..bb11a2ace --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated-shadowed/expected.ts @@ -0,0 +1,2 @@ +export const ids = [1].map((testUser) => testUser); +export const fallback = null; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated-shadowed/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated-shadowed/input.ts new file mode 100644 index 000000000..efe2cae7a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated-shadowed/input.ts @@ -0,0 +1,4 @@ +import { unauthenticatedTailorUser as testUser } from "@tailor-platform/sdk/test"; + +export const ids = [1].map((testUser) => testUser); +export const fallback = testUser; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated/expected.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated/expected.ts new file mode 100644 index 000000000..987f524e6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated/expected.ts @@ -0,0 +1,2 @@ +export const fallback = null; +export const id = unauthenticatedTailorUser.id; diff --git a/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated/input.ts b/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated/input.ts new file mode 100644 index 000000000..cef74d31e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/principal-unify/tests/unauthenticated/input.ts @@ -0,0 +1,4 @@ +import { unauthenticatedTailorUser } from "@tailor-platform/sdk"; + +export const fallback = unauthenticatedTailorUser; +export const id = unauthenticatedTailorUser.id; diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/codemod.yaml b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/codemod.yaml new file mode 100644 index 000000000..07ab7bbff --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/sdk-skills-shim" +version: "1.0.0" +description: "Replace deprecated `tailor-sdk-skills` binary with `tailor-sdk skills install`" +engine: jssg +language: text +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/scripts/transform.ts new file mode 100644 index 000000000..bdac5661a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/scripts/transform.ts @@ -0,0 +1,65 @@ +import * as path from "pathe"; + +// Match the deprecated binary plus the optional `@version` suffix that +// package-manager run commands can add (`npx tailor-sdk-skills@latest`, +// `pnpm dlx tailor-sdk-skills@1.2.3`) and the optional ` install` subcommand, +// so the rewrite drops the version pin and avoids leaving `@latest` attached +// to the new subcommand. `[ \t]+` (not `\s+`) prevents the optional-install +// alternative from greedily reaching across newlines into the next command. +const SHIM_PATTERN = /\btailor-sdk-skills(?:@[^\s'"`]+)?(?:[ \t]+install)?\b(?!-)/g; +const REPLACEMENT = "tailor-sdk skills install"; + +function replaceShim(value: string): string { + return value.replace(SHIM_PATTERN, REPLACEMENT); +} + +function transformText(source: string): string | null { + if (!SHIM_PATTERN.test(source)) return null; + SHIM_PATTERN.lastIndex = 0; + const updated = replaceShim(source); + 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-skills")) continue; + const updated = replaceShim(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; +} + +/** + * Replace `tailor-sdk-skills` invocations with `tailor-sdk skills install`. + * + * The standalone `tailor-sdk-skills` binary is removed in v2; users must call + * the subcommand on the main `tailor-sdk` CLI instead. + * @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-skills")) return null; + + const ext = path.extname(filePath).toLowerCase(); + if (ext === ".json") return transformPackageJson(source); + return transformText(source); +} diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-package-json/expected.json b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-package-json/expected.json new file mode 100644 index 000000000..fddd67a36 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-package-json/expected.json @@ -0,0 +1,7 @@ +{ + "name": "demo", + "scripts": { + "postinstall": "tailor-sdk skills install", + "skills": "pnpm exec tailor-sdk skills install" + } +} diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-package-json/input.json b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-package-json/input.json new file mode 100644 index 000000000..6323614ad --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-package-json/input.json @@ -0,0 +1,7 @@ +{ + "name": "demo", + "scripts": { + "postinstall": "tailor-sdk-skills", + "skills": "pnpm exec tailor-sdk-skills" + } +} diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-shell/expected.sh b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-shell/expected.sh new file mode 100644 index 000000000..9d6ee6bda --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-shell/expected.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +pnpm exec tailor-sdk skills install +npx tailor-sdk skills install --help diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-shell/input.sh b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-shell/input.sh new file mode 100644 index 000000000..0b9e4c78b --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-shell/input.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +pnpm exec tailor-sdk-skills +npx tailor-sdk-skills --help diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-yaml/expected.yml b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-yaml/expected.yml new file mode 100644 index 000000000..d410ac2fd --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-yaml/expected.yml @@ -0,0 +1,5 @@ +jobs: + install-skills: + runs-on: ubuntu-latest + steps: + - run: tailor-sdk skills install diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-yaml/input.yml b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-yaml/input.yml new file mode 100644 index 000000000..3a4b36762 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/basic-yaml/input.yml @@ -0,0 +1,5 @@ +jobs: + install-skills: + runs-on: ubuntu-latest + steps: + - run: tailor-sdk-skills diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/no-match/input.sh b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/no-match/input.sh new file mode 100644 index 000000000..a120be9a0 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/no-match/input.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "no shim references here" diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/version-qualified/expected.sh b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/version-qualified/expected.sh new file mode 100644 index 000000000..8022201c7 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/version-qualified/expected.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +npx tailor-sdk skills install +pnpm dlx tailor-sdk skills install --help +tailor-sdk-skills-helper run diff --git a/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/version-qualified/input.sh b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/version-qualified/input.sh new file mode 100644 index 000000000..0d977850d --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/sdk-skills-shim/tests/version-qualified/input.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +npx tailor-sdk-skills@latest +pnpm dlx tailor-sdk-skills@1.2.3 install --help +tailor-sdk-skills-helper run diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/codemod.yaml b/packages/sdk-codemod/codemods/v2/test-run-arg-input/codemod.yaml new file mode 100644 index 000000000..4b76f3835 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/test-run-arg-input" +version: "1.0.0" +description: "Remove the deprecated {input: ...} wrapper from `function test-run --arg` JSON" +engine: jssg +language: text +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/test-run-arg-input/scripts/transform.ts new file mode 100644 index 000000000..ed2c0c3a1 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/scripts/transform.ts @@ -0,0 +1,185 @@ +import * as path from "pathe"; + +const COMMAND_PATTERN = /\btailor-sdk\s+function\s+test-run\b/; + +// Sentinel used to fold `\` continuations into a single logical line +// before splitting on `\n`. Plain ASCII so the SHELL_ARG_PATTERN can reference +// it directly as a separator alternative; the chosen token is unlikely to +// appear inside a `tailor-sdk function test-run` invocation. +const JOIN_MARKER = "SDK_CODEMOD_JOIN"; + +// The separator group accepts the JOIN_MARKER literal so a backslash-newline +// continuation between `--arg` and the quoted JSON (joined into the line via +// the marker by `transformShellLikeText`) still matches. +const SHELL_ARG_PATTERN = new RegExp( + `(--arg|-a)(\\s*=\\s*|(?:\\s|${JOIN_MARKER})+)(['"\`])((?:\\\\.|(?!\\3)[^\\\\])*)\\3`, + "g", +); + +function isInputWrapper(parsed: unknown): parsed is { input: unknown } { + return ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) && + Object.keys(parsed).length === 1 && + "input" in parsed + ); +} + +function unwrapJsonString(body: string, quote: string): string | null { + const decoded = quote === '"' ? body.replace(/\\(["\\])/g, "$1") : body; + let parsed: unknown; + try { + parsed = JSON.parse(decoded); + } catch { + return null; + } + if (!isInputWrapper(parsed)) return null; + const inner = JSON.stringify(parsed.input); + if (quote === '"') { + return inner.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + } + return inner; +} + +/** + * Apply the unwrap to one shell command segment. Quotes are tracked so command + * boundary characters inside strings (e.g. `'a;b'`) are not split. + */ +function applyUnwrapToSegment(segment: string): string { + return segment.replace(SHELL_ARG_PATTERN, (match, flag, sep, quote, body) => { + const unwrapped = unwrapJsonString(body, quote); + if (unwrapped == null) return match; + return `${flag}${sep}${quote}${unwrapped}${quote}`; + }); +} + +/** + * Walk the line splitting on unquoted shell command boundaries (`;`, `&&`, + * `||`, `|`, `&`) and only run the unwrap on segments that actually invoke + * `tailor-sdk function test-run`. Without this, a chained line like + * `tailor-sdk function test-run ... --arg '{"input":...}' && other-cli --arg '{"input":...}'` + * would have the unrelated `other-cli` argument unwrapped too. + */ +function transformShellLine(line: string): string { + if (!COMMAND_PATTERN.test(line)) return line; + + let result = ""; + let segBuf = ""; + let i = 0; + let quoteChar: string | null = null; + const N = line.length; + + const flushSegment = () => { + if (COMMAND_PATTERN.test(segBuf)) { + result += applyUnwrapToSegment(segBuf); + } else { + result += segBuf; + } + segBuf = ""; + }; + + while (i < N) { + const ch = line[i]!; + if (quoteChar) { + if (ch === "\\" && quoteChar !== "'" && i + 1 < N) { + segBuf += line.slice(i, i + 2); + i += 2; + continue; + } + if (ch === quoteChar) quoteChar = null; + segBuf += ch; + i++; + continue; + } + if (ch === '"' || ch === "'" || ch === "`") { + quoteChar = ch; + segBuf += ch; + i++; + continue; + } + const two = line.slice(i, i + 2); + if (two === "&&" || two === "||") { + flushSegment(); + result += two; + i += 2; + continue; + } + if (ch === ";" || ch === "|" || ch === "&") { + flushSegment(); + result += ch; + i++; + continue; + } + segBuf += ch; + i++; + } + flushSegment(); + return result; +} + +function transformShellLikeText(source: string): string | null { + if (!COMMAND_PATTERN.test(source)) return null; + + // Treat backslash-newline as a line continuation so a multi-line invocation + // like `tailor-sdk function test-run resolvers/x.ts \\\n --arg '{"input":...}'` + // is recognized as a single command. The original `\\\n` is restored after + // transformation so line breaks remain in place. + const joined = source.replace(/\\\n/g, JOIN_MARKER); + + let modified = false; + const lines = joined.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const transformed = transformShellLine(line); + if (transformed !== line) { + lines[i] = transformed; + modified = true; + } + } + if (!modified) return null; + return lines.join("\n").split(JOIN_MARKER).join("\\\n"); +} + +function transformPackageJson(source: string): string | null { + let parsed: Record; + try { + parsed = JSON.parse(source) as Record; + } catch { + return null; + } + const scripts = parsed.scripts; + if (typeof scripts !== "object" || scripts == null || Array.isArray(scripts)) return null; + + let modified = false; + for (const [name, value] of Object.entries(scripts as Record)) { + if (typeof value !== "string") continue; + if (!COMMAND_PATTERN.test(value)) continue; + const updated = transformShellLikeText(value); + if (updated != null) { + (scripts as Record)[name] = updated; + modified = true; + } + } + if (!modified) return null; + + const trailing = source.endsWith("\n") ? "\n" : ""; + return JSON.stringify(parsed, null, 2) + trailing; +} + +/** + * Strip `{ "input": ... }` wrappers from `tailor-sdk function test-run --arg` JSON. + * + * In v2 the resolver `--arg` JSON must be the input fields directly. Old format + * wrapped them under `input` and is removed. + * @param source - File contents + * @param filePath - Absolute path to the file (used to dispatch by extension) + * @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); + return transformShellLikeText(source); +} diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/already-migrated/input.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/already-migrated/input.sh new file mode 100644 index 000000000..fe0c25f71 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/already-migrated/input.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +tailor-sdk function test-run resolvers/add.ts --arg '{"a":1,"b":2}' diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-markdown/expected.md b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-markdown/expected.md new file mode 100644 index 000000000..ed6c2f721 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-markdown/expected.md @@ -0,0 +1,7 @@ +# Test runner + +Run a resolver locally: + +```bash +tailor-sdk function test-run resolvers/add.ts -a '{"a":1,"b":2}' +``` diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-markdown/input.md b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-markdown/input.md new file mode 100644 index 000000000..2f1b218d4 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-markdown/input.md @@ -0,0 +1,7 @@ +# Test runner + +Run a resolver locally: + +```bash +tailor-sdk function test-run resolvers/add.ts -a '{"input":{"a":1,"b":2}}' +``` diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-package-json/expected.json b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-package-json/expected.json new file mode 100644 index 000000000..c547fdf78 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-package-json/expected.json @@ -0,0 +1,7 @@ +{ + "name": "demo", + "scripts": { + "seed": "tailor-sdk function test-run resolvers/seed.ts --arg '{\"users\":[]}'", + "build": "tsdown" + } +} diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-package-json/input.json b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-package-json/input.json new file mode 100644 index 000000000..22c1c402f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-package-json/input.json @@ -0,0 +1,7 @@ +{ + "name": "demo", + "scripts": { + "seed": "tailor-sdk function test-run resolvers/seed.ts --arg '{\"input\":{\"users\":[]}}'", + "build": "tsdown" + } +} diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-shell/expected.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-shell/expected.sh new file mode 100644 index 000000000..4dbbba1ed --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-shell/expected.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +tailor-sdk function test-run resolvers/add.ts --arg '{"a":1,"b":2}' +tailor-sdk function test-run resolvers/seed.ts -a '{"users":[]}' diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-shell/input.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-shell/input.sh new file mode 100644 index 000000000..9ade45e4f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/basic-shell/input.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +tailor-sdk function test-run resolvers/add.ts --arg '{"input":{"a":1,"b":2}}' +tailor-sdk function test-run resolvers/seed.ts -a '{"input":{"users":[]}}' diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/chained-commands/expected.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/chained-commands/expected.sh new file mode 100644 index 000000000..189b400c6 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/chained-commands/expected.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +tailor-sdk function test-run resolvers/add.ts --arg '{"a":1}' && other-cli --arg '{"input":{"keep":true}}' diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/chained-commands/input.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/chained-commands/input.sh new file mode 100644 index 000000000..9267e7313 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/chained-commands/input.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +tailor-sdk function test-run resolvers/add.ts --arg '{"input":{"a":1}}' && other-cli --arg '{"input":{"keep":true}}' diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/double-quoted-shell/expected.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/double-quoted-shell/expected.sh new file mode 100644 index 000000000..8fcce5ee3 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/double-quoted-shell/expected.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +tailor-sdk function test-run resolvers/add.ts --arg "{\"a\":1}" diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/double-quoted-shell/input.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/double-quoted-shell/input.sh new file mode 100644 index 000000000..536cf9719 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/double-quoted-shell/input.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +tailor-sdk function test-run resolvers/add.ts --arg "{\"input\":{\"a\":1}}" diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/equals-form/expected.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/equals-form/expected.sh new file mode 100644 index 000000000..3008a9930 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/equals-form/expected.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +tailor-sdk function test-run resolvers/add.ts --arg='{"a":1}' diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/equals-form/input.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/equals-form/input.sh new file mode 100644 index 000000000..cbce5b7ad --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/equals-form/input.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +tailor-sdk function test-run resolvers/add.ts --arg='{"input":{"a":1}}' diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multi-key/input.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multi-key/input.sh new file mode 100644 index 000000000..cc71f5aa2 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multi-key/input.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +tailor-sdk function test-run resolvers/add.ts --arg '{"input":{"a":1},"foo":2}' diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multiline-arg-continuation/expected.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multiline-arg-continuation/expected.sh new file mode 100644 index 000000000..21e4c82fa --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multiline-arg-continuation/expected.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +tailor-sdk function test-run resolvers/x.ts --arg \ + '{"a":1}' diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multiline-arg-continuation/input.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multiline-arg-continuation/input.sh new file mode 100644 index 000000000..18ecf6e11 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multiline-arg-continuation/input.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +tailor-sdk function test-run resolvers/x.ts --arg \ + '{"input":{"a":1}}' diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multiline-shell/expected.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multiline-shell/expected.sh new file mode 100644 index 000000000..378aaa5b0 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multiline-shell/expected.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +tailor-sdk function test-run resolvers/add.ts \ + --arg '{"a":1}' diff --git a/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multiline-shell/input.sh b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multiline-shell/input.sh new file mode 100644 index 000000000..1a9ae0a8f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/test-run-arg-input/tests/multiline-shell/input.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +tailor-sdk function test-run resolvers/add.ts \ + --arg '{"input":{"a":1}}' diff --git a/packages/sdk-codemod/src/registry.ts b/packages/sdk-codemod/src/registry.ts index 950f8a7d9..ddd4bf315 100644 --- a/packages/sdk-codemod/src/registry.ts +++ b/packages/sdk-codemod/src/registry.ts @@ -16,6 +16,67 @@ const allCodemods: CodemodPackage[] = [ scriptPath: "v2/define-generators-to-plugins/scripts/transform.js", legacyPatterns: ["defineGenerators"], }, + { + 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", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/test-run-arg-input/scripts/transform.js", + filePatterns: ["**/package.json", "**/*.{sh,bash,zsh}", "**/*.md"], + }, + { + id: "v2/sdk-skills-shim", + name: "tailor-sdk-skills → tailor-sdk skills install", + description: + "Replace deprecated `tailor-sdk-skills` invocations with `tailor-sdk skills install`", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/sdk-skills-shim/scripts/transform.js", + filePatterns: ["**/package.json", "**/*.{sh,bash,zsh,yml,yaml}", "**/*.md"], + legacyPatterns: ["tailor-sdk-skills"], + }, + { + id: "v2/principal-unify", + name: "Unify TailorUser/TailorActor/TailorInvoker → TailorPrincipal", + description: + "Rename TailorUser/TailorActor/TailorInvoker to TailorPrincipal, drop unauthenticatedTailorUser, and rename resolver body `user` to `caller`", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/principal-unify/scripts/transform.js", + legacyPatterns: ["TailorUser", "TailorActor", "TailorInvoker", "unauthenticatedTailorUser"], + }, + { + 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", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/apply-to-deploy/scripts/transform.js", + filePatterns: ["**/package.json", "**/*.{sh,bash,zsh,yml,yaml}", "**/*.md"], + }, + { + id: "v2/cli-rename", + name: "v2 CLI rename (single-word commands)", + 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", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/cli-rename/scripts/transform.js", + filePatterns: ["**/package.json", "**/*.{sh,bash,zsh,yml,yaml}", "**/*.md"], + }, + { + id: "v2/auth-invoker-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.', + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/auth-invoker-unwrap/scripts/transform.js", + legacyPatterns: ["auth.invoker"], + }, ]; /** diff --git a/packages/sdk-codemod/src/transform.test.ts b/packages/sdk-codemod/src/transform.test.ts index 9e30b4c04..cef6eb1ac 100644 --- a/packages/sdk-codemod/src/transform.test.ts +++ b/packages/sdk-codemod/src/transform.test.ts @@ -5,29 +5,89 @@ import type { TransformFn } from "./runner"; const CODEMODS_DIR = path.resolve(__dirname, "../codemods"); -/** - * Run a transform against its fixture files (input.ts → expected.ts). - * @param codemodPath - Relative path from the codemods root - */ -async function runFixtureTest(codemodPath: string): Promise { - const scriptPath = path.join(CODEMODS_DIR, codemodPath, "scripts/transform.ts"); - const inputPath = path.join(CODEMODS_DIR, codemodPath, "tests/basic/input.ts"); - const expectedPath = path.join(CODEMODS_DIR, codemodPath, "tests/basic/expected.ts"); +interface FixtureCase { + caseName: string; + caseDir: string; + inputFile: string; + expectedFile: string | null; +} + +async function discoverCases(codemodPath: string): Promise { + const testsDir = path.join(CODEMODS_DIR, codemodPath, "tests"); + const entries = await fs.promises.readdir(testsDir, { withFileTypes: true }); + const cases: FixtureCase[] = []; - const input = await fs.promises.readFile(inputPath, "utf-8"); - const expected = await fs.promises.readFile(expectedPath, "utf-8"); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const caseDir = path.join(testsDir, entry.name); + const files = await fs.promises.readdir(caseDir); + const inputFile = files.find((f) => f.startsWith("input.")); + const expectedFile = files.find((f) => f.startsWith("expected.")); + if (!inputFile) { + throw new Error(`No input.* file found in fixture ${caseDir}`); + } + cases.push({ + caseName: entry.name, + caseDir, + inputFile, + expectedFile: expectedFile ?? null, + }); + } + + cases.sort((a, b) => a.caseName.localeCompare(b.caseName)); + return cases; +} +async function runFixtureCases(codemodPath: string): Promise { + const scriptPath = path.join(CODEMODS_DIR, codemodPath, "scripts/transform.ts"); const mod = await import(scriptPath); const transform = mod.default as TransformFn; - const result = await transform(input, inputPath); + const cases = await discoverCases(codemodPath); + expect(cases.length, `expected at least one fixture under ${codemodPath}/tests`).toBeGreaterThan( + 0, + ); + + for (const c of cases) { + const inputPath = path.join(c.caseDir, c.inputFile); + const input = await fs.promises.readFile(inputPath, "utf-8"); + const result = await transform(input, inputPath); - expect(result).not.toBeNull(); - expect(result).toBe(expected); + if (c.expectedFile) { + const expected = await fs.promises.readFile(path.join(c.caseDir, c.expectedFile), "utf-8"); + expect(result, `${codemodPath}/${c.caseName}`).toBe(expected); + } else { + expect(result, `${codemodPath}/${c.caseName} (no expected.* → expect no change)`).toBeNull(); + } + } } describe("codemod transforms", () => { it("v2/define-generators-to-plugins transforms correctly", async () => { - await runFixtureTest("v2/define-generators-to-plugins"); + await runFixtureCases("v2/define-generators-to-plugins"); + }); + + it("v2/test-run-arg-input transforms correctly", async () => { + await runFixtureCases("v2/test-run-arg-input"); + }); + + it("v2/sdk-skills-shim transforms correctly", async () => { + await runFixtureCases("v2/sdk-skills-shim"); + }); + + it("v2/principal-unify transforms correctly", async () => { + await runFixtureCases("v2/principal-unify"); + }); + + it("v2/apply-to-deploy transforms correctly", async () => { + await runFixtureCases("v2/apply-to-deploy"); + }); + + it("v2/cli-rename transforms correctly", async () => { + await runFixtureCases("v2/cli-rename"); + }); + + it("v2/auth-invoker-unwrap transforms correctly", async () => { + await runFixtureCases("v2/auth-invoker-unwrap"); }); }); diff --git a/packages/sdk-codemod/tsdown.config.ts b/packages/sdk-codemod/tsdown.config.ts index 54bba9aba..c6c799ed5 100644 --- a/packages/sdk-codemod/tsdown.config.ts +++ b/packages/sdk-codemod/tsdown.config.ts @@ -17,6 +17,14 @@ export default defineConfig([ entry: { "v2/define-generators-to-plugins/scripts/transform": "codemods/v2/define-generators-to-plugins/scripts/transform.ts", + "v2/test-run-arg-input/scripts/transform": + "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/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/auth-invoker-unwrap/scripts/transform": + "codemods/v2/auth-invoker-unwrap/scripts/transform.ts", }, format: ["esm"], target: "node18",