Skip to content

Project typescript support#170

Draft
stevendborrelli wants to merge 6 commits into
crossplane:mainfrom
stevendborrelli:project-typescript-support
Draft

Project typescript support#170
stevendborrelli wants to merge 6 commits into
crossplane:mainfrom
stevendborrelli:project-typescript-support

Conversation

@stevendborrelli

@stevendborrelli stevendborrelli commented Jun 29, 2026

Copy link
Copy Markdown
Member

Add TypeScript support for composition functions

Fixes #169

Description

This PR adds first-class TypeScript support to the Crossplane CLI, enabling developers to write composition functions in TypeScript with full type safety. An example project using this branch is https://github.com/stevendborrelli/configuration-aws-network-ts-xp-cli.

Changes

TypeScript Function Builder (internal/project/functions/typescript.go)

  • Detects TypeScript functions by presence of package.json + src/ directory
  • Builds functions in a Node.js container (node:24-slim)
  • Runs npm install and npm run build (typically invoking tsgo)
  • Packages compiled JavaScript onto a distroless Node.js runtime (gcr.io/distroless/nodejs24-debian12)
  • Handles crossplane-models schema package as a file: dependency
  • Dereferences symlinks when copying to runtime image to ensure modules resolve correctly

TypeScript Schema Generation (internal/schemas/generator/typescript.go)

  • Generates TypeScript models from CRDs/XRDs using @kubernetes-models/crd-generate
  • Produces proper TypeScript classes with constructors (not just interfaces)
  • Includes runtime validation via @kubernetes-models/base
  • Outputs a crossplane-models npm package that functions can import
  • Supports subpath imports like crossplane-models/ec2.aws.upbound.io/v1beta1

Merged Schema Generation (internal/schemas/manager/manager.go, internal/project/build.go)

  • Added GenerateFromMultipleSources() to generate schemas from all CRD sources in a single pass
  • Collects dependency CRDs + local XRDs before running TypeScript generation
  • Ensures all types are available in a unified index.js with proper cross-references
  • Prevents filename collisions when multiple sources have files with the same name

Function Template (cmd/crossplane/function/generate.go)

  • Added TypeScript function template for crossplane function init --language=typescript
  • Pre-configured package.json with SDK dependencies
  • TypeScript configuration (tsconfig.json)
  • Example function implementation

Example Usage

# crossplane-project.yaml
apiVersion: dev.crossplane.io/v1alpha1
kind: Project
metadata:
  name: my-configuration
spec:
  schemas:
    languages:
    - typescript
  functions:
  - source: Directory
    directory:
      name: my-function
  dependencies:
  - type: xpkg
    xpkg:
      package: xpkg.upbound.io/upbound/provider-aws-ec2
      version: v2.6.0
// functions/my-function/src/function.ts
import { RunFunctionRequest, RunFunctionResponse } from '@crossplane-org/function-sdk-typescript';
import { VPC } from 'crossplane-models/ec2.aws.upbound.io/v1beta1';

export async function runFunction(req: RunFunctionRequest): Promise<RunFunctionResponse> {
  const vpc = new VPC({
    metadata: { name: 'my-vpc' },
    spec: {
      forProvider: {
        region: 'us-west-2',
        cidrBlock: '10.0.0.0/16',
      },
    },
  });
  // ... compose resources with full type safety
}

Generated Package Structure

schemas/typescript/
├── package.json
├── index.js
├── index.d.ts
├── ec2.aws.upbound.io/
│   └── v1beta1/
│       ├── VPC.js
│       ├── VPC.d.ts
│       ├── Subnet.js
│       └── ...
└── my.custom.api/
    └── v1alpha1/
        └── ...

Dependencies

This feature builds on existing ecosystem work:

Testing

  • crossplane project build builds TypeScript functions
  • crossplane function init --language=typescript creates a working template
  • TypeScript schemas are generated for all project dependencies (providers, XRDs)
  • Generated types include classes with constructors, interfaces, and validation
  • Built functions run correctly in Kubernetes

Breaking Changes

None. This is additive functionality.

Checklist

  • Code compiles without errors
  • Existing tests pass
  • New functionality has been manually tested
  • Documentation updated
  • Unit tests added for new functionality

I have:

Need help with this checklist? See the cheat sheet.

Signed-off-by: Steven Borrelli <steve@borrelli.org>
Signed-off-by: Steven Borrelli <steve@borrelli.org>
@stevendborrelli stevendborrelli requested review from a team, jcogilvie and tampakrap as code owners June 29, 2026 16:37
@stevendborrelli stevendborrelli requested review from haarchri and removed request for a team June 29, 2026 16:37
@stevendborrelli stevendborrelli marked this pull request as draft June 29, 2026 16:45
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds end-to-end TypeScript support to the Crossplane CLI: a new SchemaLanguageTypescript constant, a TypeScript schema generator that converts CRDs/XRDs to npm-ready TypeScript models via a Node.js container, a GenerateFromMultipleSources pipeline that merges dependency and local API sources before generation, a typescriptBuilder that packages and builds TypeScript functions using Node.js containers, and a crossplane function generate template for TypeScript scaffolding.

Changes

TypeScript Support

Layer / File(s) Summary
TypeScript schema language constant
apis/dev/v1alpha1/project_types.go
Adds SchemaLanguageTypescript constant, registers it in SupportedSchemaLanguages(), and updates ProjectSchemas docs to list typescript.
TypeScript schema generator
internal/schemas/generator/typescript.go, internal/schemas/generator/interface.go
Implements typescriptGenerator with CRD collection (XRD→CRD conversion, stagedCRDPath), a generateFromCRDFiles pipeline that runs crd-generate and tsc in a Node.js container, and a GenerateFromOpenAPI stub. Registers the generator in AllLanguages().
Multi-source schema pipeline and dependency collector
internal/dependency/manager.go, internal/schemas/manager/manager.go, internal/project/build.go
Adds CollectSources/collectSource/collectPackageSource to the dependency manager. Adds GenerateFromMultipleSources and generateFromMergedSources (with sanitizeSourceID) to the schema manager. Refactors Builder.Build to collect all sources first and then call GenerateFromMultipleSources in a single pass.
TypeScript function builder
internal/project/functions/typescript.go, internal/project/functions/build.go
Implements typescriptBuilder: detects TypeScript projects via package.json+src/, runs npm install/npm run build in a Node.js build container (optionally copying TypeScript schemas), assembles per-arch OCI images on the distroless Node.js runtime, and configures the entrypoint/workdir/port. Registers the builder in realIdentifier.
Function scaffold template and generate command
cmd/crossplane/function/generate.go, cmd/crossplane/function/help/generate.md, cmd/crossplane/function/templates/typescript/README.md
Embeds TypeScript templates, adds typescript to the Language enum and dispatch map, implements generateTypescriptFiles, and adds the template README and updated CLI help docs.

Sequence Diagram

sequenceDiagram
  rect rgba(70, 130, 180, 0.5)
    Note over Builder,SchemaManager: Schema generation
    Builder->>DependencyManager: CollectSources(ctx, ch)
    DependencyManager-->>Builder: []Source (xpkg resolved + versioned)
    Builder->>SchemaManager: GenerateFromMultipleSources(allSources)
    SchemaManager->>typescriptGenerator: GenerateFromCRD(mergedFS)
    typescriptGenerator->>SchemaRunner: run crd-generate + tsc in node container
    SchemaRunner-->>typescriptGenerator: compiled models/
    typescriptGenerator-->>SchemaManager: output FS
    SchemaManager->>SchemaManager: copy to schema repo, postProcess, update versions
  end
  rect rgba(60, 179, 113, 0.5)
    Note over Builder,typescriptBuilder: Function build
    Builder->>typescriptBuilder: Build(ctx, functionFS)
    typescriptBuilder->>SchemaRunner: buildFunction (npm install + build in node container)
    SchemaRunner-->>typescriptBuilder: /function tar
    typescriptBuilder->>RuntimeImage: fetch distroless nodejs base per arch
    typescriptBuilder->>RuntimeImage: append function layer + configureTypescriptImage
    RuntimeImage-->>Builder: arch-specific OCI images
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • crossplane/cli#24: Touches the same SupportedSchemaLanguages() and cmd/crossplane/function/generate language-dispatch plumbing that this PR extends with TypeScript support.

Suggested reviewers

  • jcogilvie
  • tampakrap
  • jbw976

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 inconclusive)

Check name Status Explanation Resolution
Feature Gate Requirement ❌ Error TypeScript was added unconditionally to SupportedSchemaLanguages() and AllLanguages(); the only gating here is command maturity, not a TS feature flag. Add an explicit feature flag/alpha gate for TypeScript schema/function support, and keep it out of the default apis/** language set until enabled.
Linked Issues check ❓ Inconclusive Most objectives are covered, but the TypeScript template files are excluded by path filters, so template compliance can't be fully verified. Review cmd/crossplane/function/templates/typescript/* files excluded by the path filter to verify the init template requirements.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, under 72 characters, and clearly describes the new TypeScript support.
Out of Scope Changes check ✅ Passed The changes stay focused on TypeScript function and schema support with no clear unrelated additions.
Breaking Changes ✅ Passed Inspected apis/dev/v1alpha1/project_types.go and cmd/crossplane/function/generate.go; changes only add typescript support, with no removed/renamed public fields or required flags.
Description check ✅ Passed The description clearly matches the changes, covering TypeScript function building, schema generation, merged sources, and the init template.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (1)
internal/project/functions/typescript.go (1)

59-64: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Tiny doc nit: the comment says npm ci but the build runs npm install.

The struct doc says we build via npm ci, but both build scripts use npm install (and the const comment even explains why). Mind tweaking the comment so it matches the actual behavior? Totally optional, just to avoid confusing future readers. 🙂

📝 Suggested wording
-// A TypeScript embedded function is a full function-sdk-typescript project
-// (package.json + src/). We build it by running npm ci and npm run build
-// (which invokes tsgo) in a Node.js build container, then copy the dist/
-// and node_modules/ onto a distroless Node.js base.
+// A TypeScript embedded function is a full function-sdk-typescript project
+// (package.json + src/). We build it by running npm install and npm run build
+// (which invokes tsgo) in a Node.js build container, then copy the dist/
+// and node_modules/ onto a distroless Node.js base.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/project/functions/typescript.go` around lines 59 - 64, The
TypeScript builder doc comment is out of sync with the actual install step,
since `typescriptBuilder` uses `npm install` rather than `npm ci`. Update the
comment on `typescriptBuilder` (and any nearby related comment if needed) so it
accurately describes the build flow: running `npm install` and `npm run build`
in the Node.js build container before copying `dist/` and `node_modules/`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/crossplane/function/generate.go`:
- Around line 417-423: The `generate.go` schema probe is swallowing the
`afero.DirExists` failure on `schemasFS` under `typescript`, which can hide real
filesystem errors. Update the `hasSchemas` check in the schema-loading path to
capture and return the `DirExists` error instead of defaulting to “no schemas,”
and keep the subsequent `afero.ReadDir` handling in the same flow so failures
are propagated clearly from this code path.

In `@internal/project/functions/typescript.go`:
- Line 168: The `afero.DirExists` call is ignoring its error, so transient
filesystem failures can be mistaken for “no schemas” and silently skip the
schemas layer. Update the code around the `hasTSSchemas` check to handle and
propagate the `DirExists` error the same way the sibling `match` logic handles
`DirExists`/`Exists`, using the surrounding function that builds the TypeScript
schema detection flow.
- Around line 49-56: The package-level typescriptBuildScript constant is dead
code because buildFunction uses its own inline buildScript instead. Either
remove typescriptBuildScript from internal/project/functions/typescript.go if it
is no longer needed, or update buildFunction to reference typescriptBuildScript
so there is a single source of truth for the TypeScript build pipeline.
- Around line 166-210: The TypeScript build path still hardcodes the schemas
container location in the buildScript inside typescript.go, so it can miss
dependencies when c.SchemasPath changes. Update the logic in the function that
prepares schemasTar and constructs the container command to derive the
in-container schemas path from c.SchemasPath (using the same tsSchemasRel/base
path used for FSToTar and StartWithCopyFiles) instead of checking
/schemas/typescript, so the npm install branch follows the configured schemas
root.
- Around line 47-48: Update the TypeScript runtime base image constant used by
baseImageForArch from gcr.io/distroless/nodejs24-debian12 to the published
gcr.io/distroless/nodejs24-debian13 tag. Keep the change confined to the
typescriptRuntimeImage symbol in internal/project/functions/typescript.go so any
runtime image resolution uses the correct Node 24 distroless base.

In `@internal/schemas/generator/interface.go`:
- Line 46: The default AllLanguages() registry currently includes
typescriptGenerator, which makes TypeScript generation run for projects that did
not explicitly opt in. Update the schema language selection logic so TypeScript
is only added when schemas.languages explicitly includes "typescript" or when a
dedicated feature flag enables it, and keep the default generator set unchanged
for existing builds. Use the AllLanguages() function and &typescriptGenerator{}
as the main points to adjust.

In `@internal/schemas/generator/typescript.go`:
- Around line 219-227: stagedCRDPath currently flattens nested paths by
replacing "/" with "_" in the staged filename, which can make distinct source
paths collide and overwrite each other. Update stagedCRDPath to preserve path
uniqueness when generating the staged CRD name, using a collision-resistant
encoding of the original sourcePath (including directories) while still applying
the suffix/extension logic, and ensure any caller relying on staged TypeScript
CRD paths uses the updated naming consistently.
- Line 43: The TypeScript generator setup is currently pulling dependencies at
runtime with floating versions, which makes schema generation non-reproducible.
Update the generator flow around typescriptImage and the TypeScript generator
invocation to use pinned dependencies via a lockfile with npm ci, or bake
crd-generate and its dependencies into the image so the generation environment
is deterministic. Make sure any npm install usage and ^ version ranges are
removed or replaced with exact pinned versions.

In `@internal/schemas/manager/manager.go`:
- Around line 288-314: The merged source prefix in manager.go can collide
because sanitizeSourceID() may produce the same directory for different source
IDs, causing resources to overwrite during CopyFilesBetweenFs. Update the prefix
generation in the merge loop (and the matching logic in the other affected
block) to include a collision-resistant suffix such as the source index or a
stable hash derived from src.ID(), while keeping the existing source ID context.
Use the same prefix strategy wherever mergedFS/prefixedFS is built so each
source gets a unique namespace.

---

Nitpick comments:
In `@internal/project/functions/typescript.go`:
- Around line 59-64: The TypeScript builder doc comment is out of sync with the
actual install step, since `typescriptBuilder` uses `npm install` rather than
`npm ci`. Update the comment on `typescriptBuilder` (and any nearby related
comment if needed) so it accurately describes the build flow: running `npm
install` and `npm run build` in the Node.js build container before copying
`dist/` and `node_modules/`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4c74d371-9f20-491a-96b8-a83f8b3f9eb8

📥 Commits

Reviewing files that changed from the base of the PR and between 86f5f7a and d425585.

⛔ Files ignored due to path filters (5)
  • .github/renovate.json5 is excluded by none and included by none
  • cmd/crossplane/function/templates/typescript/package.json.tmpl is excluded by none and included by none
  • cmd/crossplane/function/templates/typescript/src/function.ts is excluded by none and included by none
  • cmd/crossplane/function/templates/typescript/src/main.ts is excluded by none and included by none
  • cmd/crossplane/function/templates/typescript/tsconfig.json is excluded by none and included by none
📒 Files selected for processing (11)
  • apis/dev/v1alpha1/project_types.go
  • cmd/crossplane/function/generate.go
  • cmd/crossplane/function/help/generate.md
  • cmd/crossplane/function/templates/typescript/README.md
  • internal/dependency/manager.go
  • internal/project/build.go
  • internal/project/functions/build.go
  • internal/project/functions/typescript.go
  • internal/schemas/generator/interface.go
  • internal/schemas/generator/typescript.go
  • internal/schemas/manager/manager.go

Comment thread cmd/crossplane/function/generate.go Outdated
Comment on lines +417 to +423
hasSchemas, _ := afero.DirExists(c.schemasFS, "typescript")
if hasSchemas {
entries, err := afero.ReadDir(c.schemasFS, "typescript")
if err != nil {
return errors.Wrap(err, "cannot read typescript schemas directory")
}
hasSchemas = len(entries) > 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Please propagate failures when probing schemas/typescript.

Could we return the afero.DirExists error here instead of treating it as “no schemas”? Right now an unreadable schemas directory silently generates a project without the local schema dependency, which makes the real failure much harder to diagnose later.

Suggested fix
 func (c *generateCmd) generateTypescriptFiles(targetFS afero.Fs) error {
-	hasSchemas, _ := afero.DirExists(c.schemasFS, "typescript")
+	hasSchemas, err := afero.DirExists(c.schemasFS, "typescript")
+	if err != nil {
+		return errors.Wrap(err, "cannot inspect typescript schemas directory")
+	}
 	if hasSchemas {
 		entries, err := afero.ReadDir(c.schemasFS, "typescript")
 		if err != nil {
 			return errors.Wrap(err, "cannot read typescript schemas directory")
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
hasSchemas, _ := afero.DirExists(c.schemasFS, "typescript")
if hasSchemas {
entries, err := afero.ReadDir(c.schemasFS, "typescript")
if err != nil {
return errors.Wrap(err, "cannot read typescript schemas directory")
}
hasSchemas = len(entries) > 0
hasSchemas, err := afero.DirExists(c.schemasFS, "typescript")
if err != nil {
return errors.Wrap(err, "cannot inspect typescript schemas directory")
}
if hasSchemas {
entries, err := afero.ReadDir(c.schemasFS, "typescript")
if err != nil {
return errors.Wrap(err, "cannot read typescript schemas directory")
}
hasSchemas = len(entries) > 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/crossplane/function/generate.go` around lines 417 - 423, The
`generate.go` schema probe is swallowing the `afero.DirExists` failure on
`schemasFS` under `typescript`, which can hide real filesystem errors. Update
the `hasSchemas` check in the schema-loading path to capture and return the
`DirExists` error instead of defaulting to “no schemas,” and keep the subsequent
`afero.ReadDir` handling in the same flow so failures are propagated clearly
from this code path.

Comment on lines +47 to +48
// typescriptRuntimeImage is the distroless base used at runtime.
typescriptRuntimeImage = "gcr.io/distroless/nodejs24-debian12"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

gcr.io/distroless/nodejs24 available debian tag latest

💡 Result:

The Distroless Node.js 24 image is available under the repository name gcr.io/distroless/nodejs24-debian13 [1][2]. You can use the tag latest with this image (e.g., gcr.io/distroless/nodejs24-debian13:latest) [2][3]. Other available tags for this image include nonroot, debug, and debug-nonroot [2][3]. These images are maintained by the GoogleContainerTools project and are updated regularly to include the latest stable versions of Node.js [1][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files internal/project/functions/typescript.go
wc -l internal/project/functions/typescript.go
sed -n '1,260p' internal/project/functions/typescript.go
rg -n "typescriptRuntimeImage|nodejs24-debian12|nodejs24-debian13|baseImageForArch" internal/project/functions -S

Repository: crossplane/cli

Length of output: 9719


Switch the TypeScript runtime image to nodejs24-debian13

gcr.io/distroless/nodejs24-debian12 doesn’t appear to be published; the Node 24 distroless runtime is gcr.io/distroless/nodejs24-debian13, so this pull will fail when baseImageForArch tries to fetch the base image. Could this tag be updated?

🔧 Proposed change
-	typescriptRuntimeImage = "gcr.io/distroless/nodejs24-debian12"
+	typescriptRuntimeImage = "gcr.io/distroless/nodejs24-debian13"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// typescriptRuntimeImage is the distroless base used at runtime.
typescriptRuntimeImage = "gcr.io/distroless/nodejs24-debian12"
// typescriptRuntimeImage is the distroless base used at runtime.
typescriptRuntimeImage = "gcr.io/distroless/nodejs24-debian13"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/project/functions/typescript.go` around lines 47 - 48, Update the
TypeScript runtime base image constant used by baseImageForArch from
gcr.io/distroless/nodejs24-debian12 to the published
gcr.io/distroless/nodejs24-debian13 tag. Keep the change confined to the
typescriptRuntimeImage symbol in internal/project/functions/typescript.go so any
runtime image resolution uses the correct Node 24 distroless base.

Comment on lines +49 to +56
// typescriptBuildScript is the shell pipeline that runs in the build
// container. Installs dependencies and compiles TypeScript using tsgo.
// We use npm install instead of npm ci because the schemas package may
// be added dynamically and the lock file won't be in sync.
typescriptBuildScript = `set -eu
npm install --no-fund
npm run build
`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP '\btypescriptBuildScript\b' --type=go

Repository: crossplane/cli

Length of output: 152


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## internal/project/functions/typescript.go (relevant slice)\n'
sed -n '1,260p' internal/project/functions/typescript.go

printf '\n## references to buildScript in the same file\n'
rg -n 'buildScript|typescriptBuildScript' internal/project/functions/typescript.go

Repository: crossplane/cli

Length of output: 8950


Remove the unused typescriptBuildScript constant internal/project/functions/typescript.go:49

buildFunction already defines its own inline buildScript, so this package-level const looks like dead code. Should we drop it, or point buildFunction at it so there’s a single source of truth?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/project/functions/typescript.go` around lines 49 - 56, The
package-level typescriptBuildScript constant is dead code because buildFunction
uses its own inline buildScript instead. Either remove typescriptBuildScript
from internal/project/functions/typescript.go if it is no longer needed, or
update buildFunction to reference typescriptBuildScript so there is a single
source of truth for the TypeScript build pipeline.

Comment on lines +166 to +210
tsSchemasRel := path.Join(c.SchemasPath, "typescript")
tsSchemasFS := afero.NewBasePathFs(c.ProjectFS, tsSchemasRel)
hasTSSchemas, _ := afero.DirExists(tsSchemasFS, ".")
var schemasTar []byte
if hasTSSchemas {
schemasTar, err = filesystem.FSToTar(tsSchemasFS, tsSchemasRel)
if err != nil {
return nil, errors.Wrap(err, "failed to tar typescript schemas")
}
}

buildImage := b.buildImage
_, rewritten, err := b.configStore.RewritePath(ctx, b.buildImage)
if err != nil {
return nil, errors.Wrap(err, "failed to rewrite build image")
}
if rewritten != "" {
buildImage = rewritten
}

// Build script that:
// 1. Runs npm install and build in the function's original path (so relative deps resolve)
// 2. Copies the built artifacts to /function for the runtime layer
fnPath := "/" + filepath.ToSlash(c.FunctionPath)
buildScript := `set -eu
# First, install dependencies for the schemas package so TypeScript can resolve the base types
if [ -d "/schemas/typescript" ] && [ -f "/schemas/typescript/package.json" ]; then
cd /schemas/typescript && npm install --no-fund
cd -
fi
npm install --no-fund
npm run build
# Use -L to dereference symlinks so file: dependencies (like crossplane-models)
# are copied as actual files, not symlinks that won't resolve at runtime.
cp -rL . /function
`

opts := []docker.StartContainerOption{
docker.StartWithCopyFiles(fnTar, "/"),
docker.StartWithCommand([]string{"sh", "-c", buildScript}),
docker.StartWithWorkingDirectory(fnPath),
}
if schemasTar != nil {
opts = append(opts, docker.StartWithCopyFiles(schemasTar, "/"))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find how SchemasPath is defined/populated on BuildContext.
rg -nP '\bSchemasPath\b' --type=go -C3

Repository: crossplane/cli

Length of output: 152


🏁 Script executed:

#!/bin/bash
set -eu

# Map likely files and search for BuildContext, SchemasPath, and tar copy paths.
git ls-files | rg '(^|/)(.*typescript.*\.go|.*project.*\.go|.*context.*\.go|.*build.*\.go)$' || true

printf '\n## SchemasPath references\n'
rg -n --type=go 'SchemasPath|schemasPath|Schemas path|schema(s)?Path' .

printf '\n## BuildContext references\n'
rg -n --type=go 'type .*BuildContext|struct .*BuildContext|BuildContext' .

printf '\n## filesystem tar/copy usage\n'
rg -n --type=go 'FSToTar|StartWithCopyFiles|NewBasePathFs|StartWithWorkingDirectory|RewritePath' internal .

Repository: crossplane/cli

Length of output: 22859


🏁 Script executed:

#!/bin/bash
set -eu

# Inspect the SchemasPath field definition and any defaulting.
sed -n '1,220p' internal/project/functions/build.go
printf '\n---\n'
sed -n '1,240p' apis/dev/v1alpha1/project_types.go
printf '\n---\n'
sed -n '520,580p' internal/project/build.go
printf '\n---\n'
sed -n '150,220p' internal/project/functions/typescript.go
printf '\n---\n'
sed -n '150,220p' internal/project/functions/python.go

Repository: crossplane/cli

Length of output: 22219


Keep the schemas root dynamic
c.SchemasPath is configurable, but the container script still checks /schemas/typescript. If a project moves the schemas root, the npm install branch is skipped and the TypeScript build can miss the base types. Could this derive from c.SchemasPath instead of hardcoding /schemas?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/project/functions/typescript.go` around lines 166 - 210, The
TypeScript build path still hardcodes the schemas container location in the
buildScript inside typescript.go, so it can miss dependencies when c.SchemasPath
changes. Update the logic in the function that prepares schemasTar and
constructs the container command to derive the in-container schemas path from
c.SchemasPath (using the same tsSchemasRel/base path used for FSToTar and
StartWithCopyFiles) instead of checking /schemas/typescript, so the npm install
branch follows the configured schemas root.

// the relative path in package.json (e.g., "file:../../schemas/typescript").
tsSchemasRel := path.Join(c.SchemasPath, "typescript")
tsSchemasFS := afero.NewBasePathFs(c.ProjectFS, tsSchemasRel)
hasTSSchemas, _ := afero.DirExists(tsSchemasFS, ".")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Swallowed error on afero.DirExists.

The sibling match method checks the DirExists/Exists errors, but here the error is dropped. A transient FS error would be misread as "no schemas," silently skipping the schemas layer rather than failing loudly. Want to propagate it for consistency?

🛡️ Suggested change
-	hasTSSchemas, _ := afero.DirExists(tsSchemasFS, ".")
+	hasTSSchemas, err := afero.DirExists(tsSchemasFS, ".")
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to check for typescript schemas")
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
hasTSSchemas, _ := afero.DirExists(tsSchemasFS, ".")
hasTSSchemas, err := afero.DirExists(tsSchemasFS, ".")
if err != nil {
return nil, errors.Wrap(err, "failed to check for typescript schemas")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/project/functions/typescript.go` at line 168, The `afero.DirExists`
call is ignoring its error, so transient filesystem failures can be mistaken for
“no schemas” and silently skip the schemas layer. Update the code around the
`hasTSSchemas` check to handle and propagate the `DirExists` error the same way
the sibling `match` logic handles `DirExists`/`Exists`, using the surrounding
function that builds the TypeScript schema detection flow.

&jsonGenerator{},
&kclGenerator{},
&pythonGenerator{},
&typescriptGenerator{},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Keep TypeScript generation opt-in until the new build dependency is intentional.

Could we avoid adding this generator to the default AllLanguages() set unless the project explicitly requests schemas.languages: ["typescript"] or a feature flag enables it? Projects that omit schemas.languages currently generate every registered language, so this can make existing builds pull Node and run npm install unexpectedly. As per coding guidelines, “Implement feature flags for all new experimental features affecting apis/** or features that significantly affect behavior.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/schemas/generator/interface.go` at line 46, The default
AllLanguages() registry currently includes typescriptGenerator, which makes
TypeScript generation run for projects that did not explicitly opt in. Update
the schema language selection logic so TypeScript is only added when
schemas.languages explicitly includes "typescript" or when a dedicated feature
flag enables it, and keep the default generator set unchanged for existing
builds. Use the AllLanguages() function and &typescriptGenerator{} as the main
points to adjust.

Source: Coding guidelines

typescriptModelsFolder = "models"
// typescriptImage is the Docker image used to run crd-generate.
// We use a Node.js image and install the tool at runtime.
typescriptImage = "docker.io/library/node:22-slim"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File outline ==\n'
ast-grep outline internal/schemas/generator/typescript.go --view expanded || true

printf '\n== Relevant lines ==\n'
sed -n '1,140p' internal/schemas/generator/typescript.go
printf '\n== Later referenced lines ==\n'
sed -n '280,330p' internal/schemas/generator/typescript.go

printf '\n== Search for npm/install/version handling ==\n'
rg -n --hidden --glob '!**/vendor/**' 'npm install|npm ci|package-lock|yarn.lock|pnpm-lock|typescriptImage|node:22-slim|^\\s*\\^' internal/schemas/generator .github . || true

Repository: crossplane/cli

Length of output: 7784


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File outline ==\n'
ast-grep outline internal/schemas/generator/typescript.go --view expanded || true

printf '\n== Relevant lines ==\n'
sed -n '1,140p' internal/schemas/generator/typescript.go
printf '\n== Later referenced lines ==\n'
sed -n '280,330p' internal/schemas/generator/typescript.go

printf '\n== Search for npm/install/version handling ==\n'
rg -n --hidden --glob '!**/vendor/**' 'npm install|npm ci|package-lock|yarn.lock|pnpm-lock|typescriptImage|node:22-slim|^\s*\^' internal/schemas/generator .github . || true

Repository: crossplane/cli

Length of output: 7784


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "hello"

Repository: crossplane/cli

Length of output: 158


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== TypeScript generator call sites ==\n'
rg -n --hidden --glob '!**/vendor/**' 'typescriptGenerator|GenerateFromCRD\(|SchemaLanguageTypescript|generate.*typescript|typescript schema|crossplane-models' .

printf '\n== Surrounding lines near the generator execution ==\n'
sed -n '232,330p' internal/schemas/generator/typescript.go

Repository: crossplane/cli

Length of output: 7010


Pin the TypeScript generator dependencies Could we replace the runtime npm install and ^ ranges with a lockfile plus npm ci, or bake crd-generate into the image? The current setup can drift as upstream packages change, which makes the generated schemas non-reproducible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/schemas/generator/typescript.go` at line 43, The TypeScript
generator setup is currently pulling dependencies at runtime with floating
versions, which makes schema generation non-reproducible. Update the generator
flow around typescriptImage and the TypeScript generator invocation to use
pinned dependencies via a lockfile with npm ci, or bake crd-generate and its
dependencies into the image so the generation environment is deterministic. Make
sure any npm install usage and ^ version ranges are removed or replaced with
exact pinned versions.

Comment on lines +219 to +227
func stagedCRDPath(sourcePath, suffix string) string {
clean := filepath.ToSlash(filepath.Clean(sourcePath))
clean = strings.TrimPrefix(clean, "./")
clean = strings.TrimPrefix(clean, "/")
if suffix != "" {
ext := filepath.Ext(clean)
clean = strings.TrimSuffix(clean, ext) + "-" + suffix + ext
}
return strings.ReplaceAll(clean, "/", "_")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make staged CRD filenames collision-resistant.

Thanks for staging generated files before the TypeScript pass. One edge case: stagedCRDPath() flattens directories with _, so distinct paths like a/b_c.yaml and a_b/c.yaml both become a_b_c.yaml and one CRD can overwrite the other.

Suggested direction
 func stagedCRDPath(sourcePath, suffix string) string {
 	clean := filepath.ToSlash(filepath.Clean(sourcePath))
 	clean = strings.TrimPrefix(clean, "./")
 	clean = strings.TrimPrefix(clean, "/")
+	// Add a stable hash of the original clean path so flattened names do not collide.
+	sum := sha256.Sum256([]byte(clean))
+	hash := hex.EncodeToString(sum[:])[:12]
 	if suffix != "" {
 		ext := filepath.Ext(clean)
 		clean = strings.TrimSuffix(clean, ext) + "-" + suffix + ext
 	}
-	return strings.ReplaceAll(clean, "/", "_")
+	ext := filepath.Ext(clean)
+	flat := strings.ReplaceAll(strings.TrimSuffix(clean, ext), "/", "_")
+	return flat + "-" + hash + ext
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func stagedCRDPath(sourcePath, suffix string) string {
clean := filepath.ToSlash(filepath.Clean(sourcePath))
clean = strings.TrimPrefix(clean, "./")
clean = strings.TrimPrefix(clean, "/")
if suffix != "" {
ext := filepath.Ext(clean)
clean = strings.TrimSuffix(clean, ext) + "-" + suffix + ext
}
return strings.ReplaceAll(clean, "/", "_")
func stagedCRDPath(sourcePath, suffix string) string {
clean := filepath.ToSlash(filepath.Clean(sourcePath))
clean = strings.TrimPrefix(clean, "./")
clean = strings.TrimPrefix(clean, "/")
// Add a stable hash of the original clean path so flattened names do not collide.
sum := sha256.Sum256([]byte(clean))
hash := hex.EncodeToString(sum[:])[:12]
if suffix != "" {
ext := filepath.Ext(clean)
clean = strings.TrimSuffix(clean, ext) + "-" + suffix + ext
}
ext := filepath.Ext(clean)
flat := strings.ReplaceAll(strings.TrimSuffix(clean, ext), "/", "_")
return flat + "-" + hash + ext
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/schemas/generator/typescript.go` around lines 219 - 227,
stagedCRDPath currently flattens nested paths by replacing "/" with "_" in the
staged filename, which can make distinct source paths collide and overwrite each
other. Update stagedCRDPath to preserve path uniqueness when generating the
staged CRD name, using a collision-resistant encoding of the original sourcePath
(including directories) while still applying the suffix/extension logic, and
ensure any caller relying on staged TypeScript CRD paths uses the updated naming
consistently.

Comment thread internal/schemas/manager/manager.go Outdated
Comment on lines +288 to +314
for _, src := range sources {
version, err := src.Version(ctx)
if err != nil {
return errors.Wrapf(err, "failed to get version for source %s", src.ID())
}

// Check if this source is already up to date
existing, err := m.currentVersion(src.ID())
if err != nil {
return err
}
if existing == version {
// Source is up to date, but we still need to include its resources
// for the merged generation to work correctly
}

srcFS, err := src.Resources(ctx)
if err != nil {
return errors.Wrapf(err, "failed to get resources for source %s", src.ID())
}

// Copy resources into merged filesystem under a unique prefix
// to avoid file name collisions
prefix := sanitizeSourceID(src.ID())
prefixedFS := afero.NewBasePathFs(mergedFS, prefix)
if err := filesystem.CopyFilesBetweenFs(srcFS, prefixedFS); err != nil {
return errors.Wrapf(err, "failed to copy resources from source %s", src.ID())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Prevent sanitized source ID collisions when merging sources.

Nice approach using per-source prefixes. Could we include the source index or a stable hash in the prefix? sanitizeSourceID() can map distinct IDs to the same directory, causing one source’s CRDs to overwrite or merge with another before generation.

Suggested localized fix
-	for _, src := range sources {
+	for i, src := range sources {
 		version, err := src.Version(ctx)
 		if err != nil {
 			return errors.Wrapf(err, "failed to get version for source %s", src.ID())
 		}
@@
-		prefix := sanitizeSourceID(src.ID())
+		prefix := fmt.Sprintf("%04d_%s", i, sanitizeSourceID(src.ID()))
 		prefixedFS := afero.NewBasePathFs(mergedFS, prefix)

Also applies to: 390-397

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/schemas/manager/manager.go` around lines 288 - 314, The merged
source prefix in manager.go can collide because sanitizeSourceID() may produce
the same directory for different source IDs, causing resources to overwrite
during CopyFilesBetweenFs. Update the prefix generation in the merge loop (and
the matching logic in the other affected block) to include a collision-resistant
suffix such as the source index or a stable hash derived from src.ID(), while
keeping the existing source ID context. Use the same prefix strategy wherever
mergedFS/prefixedFS is built so each source gets a unique namespace.

Signed-off-by: Steven Borrelli <steve@borrelli.org>
Signed-off-by: Steven Borrelli <steve@borrelli.org>
Signed-off-by: Steven Borrelli <steve@borrelli.org>
Signed-off-by: Steven Borrelli <steve@borrelli.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Typescript support for crossplane projects

1 participant