Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/v2-codemods-phase1.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"example/tests/fixtures/",
"example/seed/",
"packages/tailor-proto/",
"packages/sdk-codemod/codemods/**/tests/",
"generated/"
]
}
1 change: 1 addition & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk-codemod/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": ["dist/", "codemods/**/tests/**"]
}
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
try {
parsed = JSON.parse(source) as Record<string, unknown>;
} 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<string, unknown>)) {
if (typeof value !== "string") continue;
if (!value.includes("tailor-sdk")) continue;
const updated = replaceApply(value);
if (updated !== value) {
(scripts as Record<string, string>)[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);
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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(<stringLiteral>)` 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<ImportSpec> {
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("</") || 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;
}
Loading
Loading